92 Commits

Author SHA1 Message Date
Anthony Samms
46ddce2443 add funassyi 2026-01-23 10:39:35 -05:00
Anthony Samms
ae2702b3dd add funassyi 2026-01-23 10:38:48 -05:00
Anthony Samms
034be723c9 Update PyTaiko.py 2026-01-18 14:50:05 -05:00
Anthony Samms
81d67a7ab3 minor optimizations 2026-01-15 19:46:53 -05:00
Anthony Samms
1ba25d6ce6 Update osz.py 2026-01-15 16:47:41 -05:00
Anthony Samms
0c9645bda7 cache note arc points, untruncate time 2026-01-15 15:15:19 -05:00
Anthony Samms
f62201dbb5 Update game.py 2026-01-15 13:32:42 -05:00
Anthony Samms
027ef5408a Update test_texture.py 2026-01-15 13:20:26 -05:00
Anthony Samms
29d3fdd289 performance improvements 2026-01-15 13:16:18 -05:00
Anthony Samms
0fa765e58b Merge pull request #196 from QBaraki/better_hit_lane_effect
feat: added new 'judgment' for LaneHitEffect and makes lane hit effect more accurate
2026-01-15 06:35:36 -05:00
Anthony Samms
0e5100c3d0 Update PyTaikoGreen 2026-01-15 06:35:29 -05:00
Anthony Samms
7111677e0f Update osz.py 2026-01-15 06:34:30 -05:00
Anthony Samms
5a5f9d9d0d more crash fixes 2026-01-14 17:56:01 -05:00
Anthony Samms
c496ef2188 Update song_select.py 2026-01-14 17:53:09 -05:00
Anthony Samms
0a1327f4b5 crash fixes 2026-01-14 17:49:39 -05:00
Anthony Samms
69808f11e9 Merge branch 'main' of https://github.com/Yonokid/PyTaiko 2026-01-14 17:44:36 -05:00
Anthony Samms
165bd34390 Update osz.py 2026-01-14 17:43:55 -05:00
Anthony Samms
2692d40439 Merge pull request #197 from magickale/osu-parser
Added osu parsing
2026-01-14 17:31:17 -05:00
Anthony Samms
e600f8de0b add proper metadata and bgmovie 2026-01-14 17:30:15 -05:00
Anthony Samms
2704097ee2 fix certain diff names 2026-01-14 15:51:53 -05:00
Anthony Samms
29878623ca add scroll speed multiplier 2026-01-14 14:41:17 -05:00
Anthony Samms
70fcda4670 fix all errors 2026-01-14 13:27:47 -05:00
Anthony Samms
0e8e616576 Update file_navigator.py 2026-01-14 13:08:18 -05:00
Anthony Samms
9c846b8b12 oszs are automatically removed 2026-01-14 12:48:52 -05:00
Anthony Samms
d728bfa9c8 bare minimum to select and display in song select 2026-01-14 12:05:00 -05:00
Valerio
3d89e51030 More clean up 2026-01-13 06:58:05 -05:00
Valerio
e0f2d9e15c Cleaned up project and placed parser in new location 2026-01-12 12:59:57 -05:00
magickale
12d2077e2a Merge branch 'Yonokid:main' into osu-parser 2026-01-12 12:46:04 -05:00
Anthony Samms
ea9d135741 move tja to parsers folder 2026-01-12 09:08:01 -05:00
Valerio
29ffccab57 Fully feature complete except for BPM changes and gimmicks 2026-01-06 15:02:55 -05:00
QBaraki
a461a4efa9 feat: added new 'judgment' for LaneHitEffect
In order to make the hit lane effect to be arcade accurate,
this variable was introduced.

`Player.check_note()` and `LaneHitEffect.draw()` function has been changed
in order to process such accurate hit lane effect.

Please note that in file `Skins/SkinName/Graphics/game/animation.json`,
the field with `"id": 0` needs to have `initial_opacity` set to `0.3`
for better experiences.
2026-01-06 18:01:53 +07:00
Valerio
70ddafae02 Calculates BPM, ONCE 2026-01-06 00:38:38 -05:00
Valerio
2f2272947b Cleaned up implementation, added variables to grab rest of osu data 2026-01-05 23:55:31 -05:00
Valerio
299ac2c47b Cleaned up parser a little, more feature complete implementation soon 2026-01-05 18:38:33 -05:00
magickale
b766a9e170 Merge branch 'Yonokid:main' into osu-parser 2026-01-05 14:32:47 -05:00
Valerio
976f5683b2 Added osu parser, not fully implemented. Very much hardcoded to only play p names 2026-01-05 14:29:55 -05:00
Anthony Samms
183eb75bba minor bug fix 2026-01-04 10:54:30 -05:00
Anthony Samms
8a595aebb7 Update PyTaikoGreen 2026-01-03 19:14:15 -05:00
Anthony Samms
0236c8676c Update PyTaiko.py 2026-01-03 19:14:11 -05:00
Anthony Samms
bcdb485909 Update settings.py 2026-01-03 16:45:57 -05:00
Anthony Samms
f41ffd71d4 Update settings.py 2026-01-03 16:44:29 -05:00
Anthony Samms
1d901b22bf Update settings.py 2026-01-03 16:30:57 -05:00
Anthony Samms
1781960dcc finish settings menu 2026-01-03 16:28:34 -05:00
Yonokid
655c2683cf settings menu 2026-01-03 11:10:52 -05:00
Yonokid
e0b7f0a863 add sections to ai battle 2026-01-01 10:40:46 -05:00
Yonokid
7ef74a601b dancers can be removed 2025-12-31 14:45:08 -05:00
Yonokid
102d82001f Update README.md 2025-12-31 14:25:49 -05:00
Yonokid
f278868a83 better judgment improvements 2025-12-31 14:17:11 -05:00
Yonokid
ac7c7abf82 preliminary AI battle support 2025-12-31 00:29:52 -05:00
Yonokid
f384de454f add ai battle screens 2025-12-29 00:03:59 -05:00
Yonokid
e5f788f30c convert some calls to non pyray 2025-12-28 21:06:16 -05:00
Yonokid
b4fafa96b6 add simplification algorithm 2025-12-28 19:38:20 -05:00
Yonokid
8e5f485734 Update song_select.py 2025-12-28 12:20:54 -05:00
Yonokid
1d39a4a373 Update PyTaikoGreen 2025-12-28 11:46:48 -05:00
Yonokid
775e603d4c add more english 2025-12-28 11:45:55 -05:00
Yonokid
90412af455 Update file_navigator.py 2025-12-28 01:16:32 -05:00
Yonokid
9f905c669d add search 2025-12-28 01:10:33 -05:00
Yonokid
d88c671e63 Update PyTaiko.py 2025-12-27 23:20:06 -05:00
Yonokid
b1f9c4f2ac init the audio lol 2025-12-27 23:15:42 -05:00
Yonokid
7ca4050f1b Merge branch 'main' of https://github.com/yonokid/pytaiko 2025-12-27 21:12:09 -05:00
Yonokid
9055505eb6 Update __init__.py 2025-12-27 18:59:49 -05:00
Anthony Samms
0fca2e5f3f Remove GitHub Releases Downloads badge
Removed GitHub Releases Downloads badge from README.
2025-12-27 18:55:26 -05:00
Anthony Samms
83f376c1a7 Add tests badge to README 2025-12-27 18:55:02 -05:00
Yonokid
65abde116e fix tests 2025-12-27 18:52:38 -05:00
Yonokid
4ec426c34e Update texture.py 2025-12-27 18:46:08 -05:00
Yonokid
a21ea9b7bc Update tests.yml 2025-12-27 18:40:08 -05:00
Yonokid
c36be89728 test 2025-12-27 18:39:50 -05:00
Yonokid
109719b7f5 Update tests.yml 2025-12-27 18:34:34 -05:00
Yonokid
7afb1da1cd tests (alleged) 2025-12-27 18:30:58 -05:00
Yonokid
fbcd181667 organize main file 2025-12-27 15:52:59 -05:00
Yonokid
327c48aa1a Update python-app.yml 2025-12-27 13:45:41 -05:00
Yonokid
3a18a507c0 Update pyproject.toml 2025-12-27 13:29:48 -05:00
Yonokid
2769503899 Update python-app.yml 2025-12-27 13:27:45 -05:00
Yonokid
e719119764 chore: organize imports 2025-12-27 13:26:24 -05:00
Yonokid
174f322696 made navigation smooth 2025-12-27 13:19:13 -05:00
Anthony Samms
33125c6322 Merge pull request #186 from magickale/gen3-scoring
updated leaderboard logic
2025-12-26 21:04:07 -05:00
Anthony Samms
e97bd5bd4c Merge pull request #185 from magickale/scrollwheel
Added the ability to use the scroll wheel in the song select
2025-12-26 21:03:34 -05:00
Valerio
88acfe5e5b Changed gen3 leaderboard logic to not need any new textures 2025-12-26 01:23:27 -05:00
Valerio
73abcddf44 Added logic so gen3 scores can be drawn with a border around them. 2025-12-26 01:13:22 -05:00
Valerio
7ca8ff8c38 Added the ability to use the scroll wheel in the song select 2025-12-26 00:27:27 -05:00
Anthony Samms
22778dbd3d Update texture.py 2025-12-24 23:54:43 -05:00
Anthony Samms
d70e734661 update skin 2025-12-24 23:48:06 -05:00
Anthony Samms
2c09360bfd add more english 2025-12-24 15:06:59 -05:00
Anthony Samms
58d7043a50 Update PyTaikoGreen 2025-12-24 11:05:37 -05:00
Anthony Samms
27c58cc97e Update utils.py 2025-12-24 11:05:24 -05:00
Anthony Samms
20c1f1141e Update python-app.yml 2025-12-23 23:00:44 -05:00
Anthony Samms
4844792aaa Update python-app.yml 2025-12-23 22:45:16 -05:00
Anthony Samms
201b37dda0 Update python-app.yml 2025-12-23 22:32:51 -05:00
Anthony Samms
77886d34cb Update python-app.yml 2025-12-23 22:21:37 -05:00
Anthony Samms
74080634de Update PyTaikoGreen 2025-12-23 21:19:45 -05:00
Anthony Samms
f7ab62ab1d update skin 2025-12-23 21:03:48 -05:00
Anthony Samms
5c7e759385 fix skin loading 2025-12-23 21:02:56 -05:00
67 changed files with 6566 additions and 1047 deletions

View File

@@ -1,9 +1,6 @@
name: PyTaiko name: PyTaiko
on: on:
push: workflow_dispatch:
branches: ["main"]
pull_request:
branches: ["main"]
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
@@ -18,6 +15,8 @@ jobs:
steps: steps:
- name: Check-out repository - name: Check-out repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Install libaudio Dependencies (macOS) - name: Install libaudio Dependencies (macOS)
if: runner.os == 'macOS' if: runner.os == 'macOS'
@@ -85,6 +84,33 @@ jobs:
- name: Setup Python - name: Setup Python
run: uv python install run: uv python install
- name: Install dependencies
run: uv sync
- name: Copy libaudio to project root (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
cp libs/audio/*.dll . 2>/dev/null || echo "libaudio not found"
- name: Copy libaudio to project root (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
cp libs/audio/libaudio.dylib . 2>/dev/null || echo "libaudio not found"
- name: Copy libaudio to project root (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
cp libs/audio/libaudio.so . 2>/dev/null || echo "libaudio not found"
- name: Run tests (excluding audio tests)
run: uv run pytest test/libs/ -v --tb=short --color=yes --ignore=test/libs/test_audio.py
continue-on-error: false
env:
PYTHONPATH: ${{ github.workspace }}
- name: Build Executable - name: Build Executable
shell: bash shell: bash
run: | run: |
@@ -131,11 +157,28 @@ jobs:
fi fi
shell: bash shell: bash
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
name: pytaiko-${{ runner.os }}-${{ runner.arch }}
path: PyTaiko-${{ runner.os }}-${{ runner.arch }}.zip
retention-days: 1
release:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- name: Download All Artifacts
uses: actions/download-artifact@v4
with:
pattern: pytaiko-*
merge-multiple: true
- name: Upload Release - name: Upload Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
with: with:
files: PyTaiko-${{ runner.os }}-${{ runner.arch }}.zip files: PyTaiko-*.zip
name: "PyTaiko [Rolling Release]" name: "PyTaiko [Rolling Release]"
tag_name: "latest" tag_name: "latest"
make_latest: true make_latest: true

129
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: Tests
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
workflow_dispatch:
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest, macos-latest]
python-version: ["3.12"]
runs-on: ${{ matrix.os }}
steps:
- name: Check-out repository
uses: actions/checkout@v4
- name: Install libaudio Dependencies (macOS)
if: runner.os == 'macOS'
run: |
brew update
brew install portaudio libsndfile speexdsp ccache
- name: Install libaudio Dependencies (Windows)
if: runner.os == 'Windows'
uses: msys2/setup-msys2@v2
with:
update: true
install: >-
base-devel
mingw-w64-x86_64-gcc
mingw-w64-x86_64-libsndfile
mingw-w64-x86_64-speexdsp
mingw-w64-x86_64-ccache
- name: Install libaudio Dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libsndfile1-dev \
libspeexdsp-dev \
portaudio19-dev \
libpulse-dev \
ccache
- name: Build libaudio (Windows)
if: runner.os == 'Windows'
shell: msys2 {0}
run: |
cd libs/audio
make clean
make all
make verify
- name: Build libaudio (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
cd libs/audio
make clean
make all
make verify
- name: Copy libaudio to project root (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
cp libs/audio/*.dll . 2>/dev/null || echo "libaudio not found"
- name: Copy libaudio to project root (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
cp libs/audio/libaudio.dylib . 2>/dev/null || echo "libaudio not found"
- name: Copy libaudio to project root (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
cp libs/audio/libaudio.so . 2>/dev/null || echo "libaudio not found"
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Setup Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync
- name: Run tests (excluding audio tests)
run: uv run pytest test/libs/ -v --tb=short --color=yes --ignore=test/libs/test_audio.py
continue-on-error: false
env:
PYTHONPATH: ${{ github.workspace }}
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}-py${{ matrix.python-version }}
path: |
*.log
temp/
if-no-files-found: ignore
retention-days: 7
test-summary:
runs-on: ubuntu-latest
needs: test
if: always()
steps:
- name: Test Summary
run: |
echo "## Test Results Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "All platform tests completed!" >> $GITHUB_STEP_SUMMARY

2
.gitignore vendored
View File

@@ -5,8 +5,8 @@ cache
dev-config.toml dev-config.toml
libaudio.so libaudio.so
latest.log latest.log
libaudio.dll
libaudio.dylib libaudio.dylib
scores.db scores.db
scores_gen3.db scores_gen3.db
./libs/audio/audio.o ./libs/audio/audio.o
*.dll

View File

@@ -1,12 +1,13 @@
import argparse
import logging import logging
import os import os
from pathlib import Path
import sys
import argparse
import sqlite3 import sqlite3
import sys
from pathlib import Path
import pyray as ray import pyray
import raylib as ray
from pypresence.presence import Presence
from raylib.defines import ( from raylib.defines import (
RL_FUNC_ADD, RL_FUNC_ADD,
RL_ONE, RL_ONE,
@@ -15,35 +16,35 @@ from raylib.defines import (
) )
from libs.audio import audio from libs.audio import audio
from libs.global_data import PlayerNum, ScoreMethod from libs.config import get_config
from libs.global_data import Difficulty, PlayerNum, ScoreMethod
from libs.screen import Screen from libs.screen import Screen
from libs.song_hash import DB_VERSION from libs.song_hash import DB_VERSION
from libs.tja import TJAParser from libs.parsers.tja import TJAParser
from libs.utils import ( from libs.utils import (
force_dedicated_gpu, force_dedicated_gpu,
get_current_ms, get_current_ms,
global_data, global_data,
global_tex global_tex,
) )
from libs.config import get_config from scenes.ai_battle.game import AIBattleGameScreen
from scenes.ai_battle.song_select import AISongSelectScreen
from scenes.dan.dan_result import DanResultScreen
from scenes.dan.dan_select import DanSelectScreen
from scenes.dan.game_dan import DanGameScreen
from scenes.devtest import DevScreen from scenes.devtest import DevScreen
from scenes.entry import EntryScreen from scenes.entry import EntryScreen
from scenes.game import GameScreen from scenes.game import GameScreen
from scenes.dan.game_dan import DanGameScreen from scenes.loading import LoadScreen
from scenes.practice.game import PracticeGameScreen from scenes.practice.game import PracticeGameScreen
from scenes.practice.song_select import PracticeSongSelectScreen from scenes.practice.song_select import PracticeSongSelectScreen
from scenes.two_player.game import TwoPlayerGameScreen
from scenes.two_player.result import TwoPlayerResultScreen
from scenes.loading import LoadScreen
from scenes.result import ResultScreen from scenes.result import ResultScreen
from scenes.settings import SettingsScreen from scenes.settings import SettingsScreen
from scenes.song_select import SongSelectScreen from scenes.song_select import SongSelectScreen
from scenes.title import TitleScreen from scenes.title import TitleScreen
from scenes.two_player.game import TwoPlayerGameScreen
from scenes.two_player.result import TwoPlayerResultScreen
from scenes.two_player.song_select import TwoPlayerSongSelectScreen from scenes.two_player.song_select import TwoPlayerSongSelectScreen
from scenes.dan.dan_select import DanSelectScreen
from scenes.dan.dan_result import DanResultScreen
from pypresence.presence import Presence
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DISCORD_APP_ID = '1451423960401973353' DISCORD_APP_ID = '1451423960401973353'
@@ -69,6 +70,8 @@ class Screens:
DAN_RESULT = "DAN_RESULT" DAN_RESULT = "DAN_RESULT"
PRACTICE_SELECT = "PRACTICE_SELECT" PRACTICE_SELECT = "PRACTICE_SELECT"
GAME_PRACTICE = "GAME_PRACTICE" GAME_PRACTICE = "GAME_PRACTICE"
AI_SELECT = "AI_SELECT"
AI_GAME = "AI_GAME"
SETTINGS = "SETTINGS" SETTINGS = "SETTINGS"
DEV_MENU = "DEV_MENU" DEV_MENU = "DEV_MENU"
LOADING = "LOADING" LOADING = "LOADING"
@@ -163,12 +166,12 @@ def create_song_db():
def update_camera_for_window_size(camera, virtual_width, virtual_height): def update_camera_for_window_size(camera, virtual_width, virtual_height):
"""Update camera zoom, offset, scale, and rotation to maintain aspect ratio""" """Update camera zoom, offset, scale, and rotation to maintain aspect ratio"""
screen_width = ray.get_screen_width() screen_width = ray.GetScreenWidth()
screen_height = ray.get_screen_height() screen_height = ray.GetScreenHeight()
if screen_width == 0 or screen_height == 0: if screen_width == 0 or screen_height == 0:
camera.zoom = 1.0 camera.zoom = 1.0
camera.offset = ray.Vector2(0, 0) camera.offset = (0, 0)
camera.rotation = 0.0 camera.rotation = 0.0
return return
@@ -188,21 +191,14 @@ def update_camera_for_window_size(camera, virtual_width, virtual_height):
h_scale_offset_x = (virtual_width * scale * (h_scale - 1.0)) * 0.5 h_scale_offset_x = (virtual_width * scale * (h_scale - 1.0)) * 0.5
v_scale_offset_y = (virtual_height * scale * (v_scale - 1.0)) * 0.5 v_scale_offset_y = (virtual_height * scale * (v_scale - 1.0)) * 0.5
camera.offset = ray.Vector2( camera.offset = (
base_offset_x - zoom_offset_x - h_scale_offset_x + (global_data.camera.offset.x * scale), base_offset_x - zoom_offset_x - h_scale_offset_x + (global_data.camera.offset.x * scale),
base_offset_y - zoom_offset_y - v_scale_offset_y + (global_data.camera.offset.y * scale) base_offset_y - zoom_offset_y - v_scale_offset_y + (global_data.camera.offset.y * scale)
) )
camera.rotation = global_data.camera.rotation camera.rotation = global_data.camera.rotation
def main(): def setup_logging():
force_dedicated_gpu()
global_data.config = get_config()
match global_data.config["general"]["score_method"]:
case ScoreMethod.GEN3:
global_data.score_db = 'scores_gen3.db'
case ScoreMethod.SHINUCHI:
global_data.score_db = 'scores.db'
log_level = global_data.config["general"]["log_level"] log_level = global_data.config["general"]["log_level"]
if sys.platform == 'win32': if sys.platform == 'win32':
import io import io
@@ -221,41 +217,33 @@ def main():
handlers=[console_handler, file_handler] handlers=[console_handler, file_handler]
) )
sys.excepthook = handle_exception sys.excepthook = handle_exception
logger.info("Starting PyTaiko")
logger.debug(f"Loaded config: {global_data.config}")
screen_width = global_tex.screen_width
screen_height = global_tex.screen_height
def set_config_flags():
if global_data.config["video"]["vsync"]: if global_data.config["video"]["vsync"]:
ray.set_config_flags(ray.ConfigFlags.FLAG_VSYNC_HINT) ray.SetConfigFlags(ray.FLAG_VSYNC_HINT)
logger.info("VSync enabled") logger.info("VSync enabled")
if global_data.config["video"]["target_fps"] != -1: if global_data.config["video"]["target_fps"] != -1:
ray.set_target_fps(global_data.config["video"]["target_fps"]) ray.SetTargetFPS(global_data.config["video"]["target_fps"])
logger.info(f"Target FPS set to {global_data.config['video']['target_fps']}") logger.info(f"Target FPS set to {global_data.config['video']['target_fps']}")
ray.set_config_flags(ray.ConfigFlags.FLAG_MSAA_4X_HINT) ray.SetConfigFlags(ray.FLAG_MSAA_4X_HINT)
ray.set_config_flags(ray.ConfigFlags.FLAG_WINDOW_RESIZABLE) ray.SetConfigFlags(ray.FLAG_WINDOW_RESIZABLE)
ray.set_trace_log_level(ray.TraceLogLevel.LOG_WARNING) ray.SetTraceLogLevel(ray.LOG_WARNING)
ray.init_window(screen_width, screen_height, "PyTaiko") def init_audio():
logger.info(f"Window initialized: {screen_width}x{screen_height}") audio.set_log_level((logger.level-1)//10)
global_tex.load_screen_textures('global') old_stderr = os.dup(2)
logger.info("Global screen textures loaded") devnull = os.open(os.devnull, os.O_WRONLY)
global_tex.load_zip('chara', 'chara_0') os.dup2(devnull, 2)
global_tex.load_zip('chara', 'chara_1') os.close(devnull)
logger.info("Chara textures loaded") audio.init_audio_device()
if global_data.config["video"]["borderless"]: os.dup2(old_stderr, 2)
ray.toggle_borderless_windowed() os.close(old_stderr)
logger.info("Borderless window enabled") logger.info("Audio device initialized")
if global_data.config["video"]["fullscreen"]:
ray.toggle_fullscreen()
logger.info("Fullscreen enabled")
current_screen = Screens.LOADING
def check_args():
if len(sys.argv) == 1: if len(sys.argv) == 1:
pass return Screens.LOADING
else:
parser = argparse.ArgumentParser(description='Launch game with specified song file') parser = argparse.ArgumentParser(description='Launch game with specified song file')
parser.add_argument('song_path', type=str, help='Path to the TJA song file') parser.add_argument('song_path', type=str, help='Path to the TJA song file')
parser.add_argument('difficulty', type=int, nargs='?', default=None, parser.add_argument('difficulty', type=int, nargs='?', default=None,
@@ -275,25 +263,84 @@ def main():
if args.difficulty not in tja.metadata.course_data.keys(): if args.difficulty not in tja.metadata.course_data.keys():
parser.error(f"Invalid difficulty: {args.difficulty}. Available: {list(tja.metadata.course_data.keys())}") parser.error(f"Invalid difficulty: {args.difficulty}. Available: {list(tja.metadata.course_data.keys())}")
selected_difficulty = args.difficulty selected_difficulty = args.difficulty
else:
if not tja.metadata.course_data:
selected_difficulty = Difficulty.EASY
else: else:
selected_difficulty = max(tja.metadata.course_data.keys()) selected_difficulty = max(tja.metadata.course_data.keys())
current_screen = Screens.GAME_PRACTICE if args.practice else Screens.GAME current_screen = Screens.GAME_PRACTICE if args.practice else Screens.GAME
global_data.session_data[PlayerNum.P1].selected_song = path global_data.session_data[PlayerNum.P1].selected_song = path
global_data.session_data[PlayerNum.P1].selected_difficulty = selected_difficulty global_data.session_data[PlayerNum.P1].selected_difficulty = selected_difficulty
global_data.modifiers[PlayerNum.P1].auto = args.auto global_data.modifiers[PlayerNum.P1].auto = args.auto
return current_screen
def check_discord_heartbeat(current_screen):
if global_data.session_data[global_data.player_num].selected_song != Path():
details = f"Playing Song: {global_data.session_data[global_data.player_num].song_title}"
else:
details = "Idling"
RPC.update(
state=f"In Screen {current_screen}",
details=details,
large_text="PyTaiko",
start=get_current_ms()/1000,
buttons=[{"label": "Play Now", "url": "https://github.com/Yonokid/PyTaiko"}]
)
def draw_fps(last_fps: int):
curr_fps = ray.GetFPS()
pos = 20 * global_tex.screen_scale
if curr_fps != 0 and curr_fps != last_fps:
last_fps = curr_fps
if last_fps < 30:
pyray.draw_text_ex(global_data.font, f'{last_fps} FPS', (pos, pos), pos, 1, pyray.RED)
elif last_fps < 60:
pyray.draw_text_ex(global_data.font, f'{last_fps} FPS', (pos, pos), pos, 1, pyray.YELLOW)
else:
pyray.draw_text_ex(pyray.get_font_default(), f'{last_fps} FPS', (pos, pos), pos, 1, pyray.LIME)
def draw_outer_border(screen_width: int, screen_height: int, last_color: pyray.Color):
pyray.draw_rectangle(-screen_width, 0, screen_width, screen_height, last_color)
pyray.draw_rectangle(screen_width, 0, screen_width, screen_height, last_color)
pyray.draw_rectangle(0, -screen_height, screen_width, screen_height, last_color)
pyray.draw_rectangle(0, screen_height, screen_width, screen_height, last_color)
def main():
force_dedicated_gpu()
global_data.config = get_config()
match global_data.config["general"]["score_method"]:
case ScoreMethod.GEN3:
global_data.score_db = 'scores_gen3.db'
case ScoreMethod.SHINUCHI:
global_data.score_db = 'scores.db'
setup_logging()
logger.info("Starting PyTaiko")
logger.debug(f"Loaded config: {global_data.config}")
screen_width = global_tex.screen_width
screen_height = global_tex.screen_height
set_config_flags()
ray.InitWindow(screen_width, screen_height, "PyTaiko".encode('utf-8'))
logger.info(f"Window initialized: {screen_width}x{screen_height}")
global_tex.load_screen_textures('global')
global_tex.load_zip('chara', 'chara_0')
global_tex.load_zip('chara', 'chara_1')
global_tex.load_zip('chara', 'chara_4')
if global_data.config["video"]["borderless"]:
ray.ToggleBorderlessWindowed()
logger.info("Borderless window enabled")
if global_data.config["video"]["fullscreen"]:
ray.ToggleFullscreen()
logger.info("Fullscreen enabled")
init_audio()
current_screen = check_args()
logger.info(f"Initial screen: {current_screen}") logger.info(f"Initial screen: {current_screen}")
audio.set_log_level((log_level-1)//10)
old_stderr = os.dup(2)
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2)
os.close(devnull)
audio.init_audio_device()
os.dup2(old_stderr, 2)
os.close(old_stderr)
logger.info("Audio device initialized")
create_song_db() create_song_db()
title_screen = TitleScreen('title') title_screen = TitleScreen('title')
@@ -305,6 +352,8 @@ def main():
game_screen_2p = TwoPlayerGameScreen('game') game_screen_2p = TwoPlayerGameScreen('game')
game_screen_practice = PracticeGameScreen('game') game_screen_practice = PracticeGameScreen('game')
practice_select_screen = PracticeSongSelectScreen('song_select') practice_select_screen = PracticeSongSelectScreen('song_select')
ai_select_screen = AISongSelectScreen('song_select')
ai_game_screen = AIBattleGameScreen('game')
result_screen = ResultScreen('result') result_screen = ResultScreen('result')
result_screen_2p = TwoPlayerResultScreen('result') result_screen_2p = TwoPlayerResultScreen('result')
settings_screen = SettingsScreen('settings') settings_screen = SettingsScreen('settings')
@@ -322,6 +371,8 @@ def main():
Screens.GAME: game_screen, Screens.GAME: game_screen,
Screens.GAME_2P: game_screen_2p, Screens.GAME_2P: game_screen_2p,
Screens.GAME_PRACTICE: game_screen_practice, Screens.GAME_PRACTICE: game_screen_practice,
Screens.AI_SELECT: ai_select_screen,
Screens.AI_GAME: ai_game_screen,
Screens.RESULT: result_screen, Screens.RESULT: result_screen,
Screens.RESULT_2P: result_screen_2p, Screens.RESULT_2P: result_screen_2p,
Screens.SETTINGS: settings_screen, Screens.SETTINGS: settings_screen,
@@ -332,51 +383,44 @@ def main():
Screens.LOADING: load_screen Screens.LOADING: load_screen
} }
camera = ray.Camera2D() camera = pyray.Camera2D()
camera.target = ray.Vector2(0, 0) camera.target = pyray.Vector2(0, 0)
camera.rotation = 0.0 camera.rotation = 0.0
update_camera_for_window_size(camera, screen_width, screen_height) update_camera_for_window_size(camera, screen_width, screen_height)
logger.info("Camera2D initialized") logger.info("Camera2D initialized")
ray.rl_set_blend_factors_separate(RL_SRC_ALPHA, RL_ONE_MINUS_SRC_ALPHA, RL_ONE, RL_ONE_MINUS_SRC_ALPHA, RL_FUNC_ADD, RL_FUNC_ADD) ray.rlSetBlendFactorsSeparate(RL_SRC_ALPHA, RL_ONE_MINUS_SRC_ALPHA, RL_ONE, RL_ONE_MINUS_SRC_ALPHA, RL_FUNC_ADD, RL_FUNC_ADD)
ray.set_exit_key(global_data.config["keys"]["exit_key"]) ray.SetExitKey(global_data.config["keys"]["exit_key"])
ray.hide_cursor() ray.HideCursor()
logger.info("Cursor hidden") logger.info("Cursor hidden")
last_fps = 1 last_fps = 1
last_color = ray.BLACK last_color = pyray.BLACK
last_discord_check = 0
while not ray.window_should_close(): while not ray.WindowShouldClose():
if discord_connected: current_time = get_current_ms()
if global_data.session_data[global_data.player_num].selected_song != Path(): if discord_connected and current_time > last_discord_check + 1000:
details = f"Playing Song: {global_data.session_data[global_data.player_num].song_title}" check_discord_heartbeat(current_screen)
else: last_discord_check = current_time
details = "Idling"
RPC.update(
state=f"In Screen {current_screen}",
details=details,
large_text="PyTaiko",
start=get_current_ms()/1000,
buttons=[{"label": "Play Now", "url": "https://github.com/Yonokid/PyTaiko"}]
)
if ray.is_key_pressed(global_data.config["keys"]["fullscreen_key"]): if ray.IsKeyPressed(global_data.config["keys"]["fullscreen_key"]):
ray.toggle_fullscreen() ray.ToggleFullscreen()
logger.info("Toggled fullscreen") logger.info("Toggled fullscreen")
elif ray.is_key_pressed(global_data.config["keys"]["borderless_key"]): elif ray.IsKeyPressed(global_data.config["keys"]["borderless_key"]):
ray.toggle_borderless_windowed() ray.ToggleBorderlessWindowed()
logger.info("Toggled borderless windowed mode") logger.info("Toggled borderless windowed mode")
update_camera_for_window_size(camera, screen_width, screen_height) update_camera_for_window_size(camera, screen_width, screen_height)
ray.begin_drawing() ray.BeginDrawing()
if global_data.camera.border_color != last_color: if global_data.camera.border_color != last_color:
ray.clear_background(global_data.camera.border_color) pyray.clear_background(global_data.camera.border_color)
last_color = global_data.camera.border_color last_color = global_data.camera.border_color
ray.begin_mode_2d(camera) pyray.begin_mode_2d(camera)
ray.begin_blend_mode(ray.BlendMode.BLEND_CUSTOM_SEPARATE) ray.BeginBlendMode(ray.BLEND_CUSTOM_SEPARATE)
screen = screen_mapping[current_screen] screen = screen_mapping[current_screen]
@@ -390,29 +434,20 @@ def main():
global_data.input_locked = 0 global_data.input_locked = 0
if global_data.config["general"]["fps_counter"]: if global_data.config["general"]["fps_counter"]:
curr_fps = ray.get_fps() draw_fps(last_fps)
if curr_fps != 0 and curr_fps != last_fps:
last_fps = curr_fps
if last_fps < 30:
ray.draw_text(f'{last_fps} FPS', 20, 20, 20, ray.RED)
elif last_fps < 60:
ray.draw_text(f'{last_fps} FPS', 20, 20, 20, ray.YELLOW)
else:
ray.draw_text(f'{last_fps} FPS', 20, 20, 20, ray.LIME)
ray.draw_rectangle(-screen_width, 0, screen_width, screen_height, last_color) draw_outer_border(screen_width, screen_height, last_color)
ray.draw_rectangle(screen_width, 0, screen_width, screen_height, last_color)
ray.draw_rectangle(0, -screen_height, screen_width, screen_height, last_color)
ray.draw_rectangle(0, screen_height, screen_width, screen_height, last_color)
ray.end_blend_mode() ray.EndBlendMode()
ray.end_mode_2d() ray.EndMode2D()
ray.end_drawing() ray.EndDrawing()
ray.close_window() ray.CloseWindow()
audio.close_audio_device() audio.close_audio_device()
if discord_connected: if discord_connected:
RPC.close() RPC.close()
global_tex.unload_textures()
screen_mapping[current_screen].on_screen_end("LOADING")
logger.info("Window closed and audio device shut down") logger.info("Window closed and audio device shut down")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -4,10 +4,10 @@ A TJA player and Taiko simulator written in Python using the [raylib](https://ww
![License](https://img.shields.io/github/license/Yonokid/PyTaiko) ![License](https://img.shields.io/github/license/Yonokid/PyTaiko)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue) ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue)
[![GitHub Releases Downloads](https://img.shields.io/github/downloads/Yonokid/PyTaiko/total)](https://github.com/Yonokid/PyTaiko/releases)
[![GitHub Stars](https://img.shields.io/github/stars/Yonokid/PyTaiko?style=flat&label=stars)](https://github.com/Yonokid/PyTaiko/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/Yonokid/PyTaiko?style=flat&label=stars)](https://github.com/Yonokid/PyTaiko/stargazers)
[![Discord Members](https://img.shields.io/discord/722513061419810946.svg?label=Discord&logo=discord)](https://discord.gg/XHcVYKW) [![Discord Members](https://img.shields.io/discord/722513061419810946.svg?label=Discord&logo=discord)](https://discord.gg/XHcVYKW)
[![Builds](https://github.com/Yonokid/PyTaiko/actions/workflows/python-app.yml/badge.svg)](https://github.com/Yonokid/PyTaiko/actions/workflows/python-app.yml) [![Builds](https://github.com/Yonokid/PyTaiko/actions/workflows/python-app.yml/badge.svg)](https://github.com/Yonokid/PyTaiko/actions/workflows/python-app.yml)
[![Tests](https://github.com/Yonokid/PyTaiko/actions/workflows/tests.yml/badge.svg)](https://github.com/Yonokid/PyTaiko/actions/workflows/tests.yml)
<img src="/docs/demo.gif"> <img src="/docs/demo.gif">
@@ -47,18 +47,30 @@ Just make sure to use `/` and not `\`!<br>
Q: I'm trying to play on Mac and it can't open!<br> Q: I'm trying to play on Mac and it can't open!<br>
A: Delete your installation and follow these commands in the terminal:<br> A: Delete your installation and follow these commands in the terminal:<br>
``` ```
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # Move to the place where you want to download Pytaiko, this example will be in the desktop
curl -LsSf https://astral.sh/uv/install.sh | sh # Make sure the directory is not saved in iCloud! This will create an issue on macOS where the executable cannot compile.
source $HOME/.local/bin/env cd ~/Desktop
# Installing dependencies
brew install python@3.12 uv git speexdsp libsndfile
# Downloading Pytaiko project
git clone https://github.com/Yonokid/PyTaiko git clone https://github.com/Yonokid/PyTaiko
cd PyTaiko cd PyTaiko
brew install libsndfile
brew install speexdsp # Compiling audio libraries
cd libs/audio cd libs/audio
make make
mv libaudio.dylib ../../ mv libaudio.dylib ...
cd ../../ cd ...
# Running game (for testing)
uv run PyTaiko.py uv run PyTaiko.py
# Creating executable
uv add nuitka
uv run nuitka --mode=app --noinclude-setuptools-mode=nofollow --noinclude-IPython-mode=nofollow --assume-yes-for-downloads PyTaiko.py
``` ```
## Installation ## Installation
@@ -71,7 +83,7 @@ Download the latest release for your operating system from the [releases page](h
2. Run `PyTaiko.exe` 2. Run `PyTaiko.exe`
#### macOS #### macOS
- Run with Python directly (see [Building from Source](#building-from-source)) - Run with Python directly or self compile (see [Building from Source](#building-from-source))
#### Linux #### Linux
- Try running the compiled `PyTaiko.bin` binary - Try running the compiled `PyTaiko.bin` binary

View File

@@ -1 +0,0 @@
61506649d1a0f78c7759ffd83b010e58ab0e167bdeb06b11584933d7a7409f35|Dogbite|t+pazolite

4
Songs/18 Search/box.def Normal file
View File

@@ -0,0 +1,4 @@
#TITLE:Search Song
#TITLEJA:
#COLLECTION:SEARCH
#BACKCOLOR:#800000

View File

@@ -2,7 +2,7 @@
fps_counter = false fps_counter = false
audio_offset = 0 audio_offset = 0
visual_offset = 0 visual_offset = 0
language = "ja" language = "en"
timer_frozen = true timer_frozen = true
judge_counter = false judge_counter = false
nijiiro_notes = false nijiiro_notes = false
@@ -29,9 +29,7 @@ rainbow = false
[paths] [paths]
tja_path = ['Songs'] tja_path = ['Songs']
video_path = ['Videos'] skin = 'PyTaikoGreen'
#You can change this path to Graphics/GreenVer1080 for the 1080p skin
graphics_path = 'Graphics/GreenVer'
[keys] [keys]
exit_key = 'Q' exit_key = 'Q'
@@ -66,7 +64,7 @@ device_type = 0
sample_rate = 44100 sample_rate = 44100
# buffer_size: Size in samples per audio buffer # buffer_size: Size in samples per audio buffer
# - 0 = let driver choose (may result in very small buffers with ASIO, typically 64) # - 0 = let driver choose (may result in very small buffers with ASIO, typically 64)
buffer_size = 32 buffer_size = 128
[volume] [volume]
sound = 1.0 sound = 1.0

41
libs/__init__.py Normal file
View File

@@ -0,0 +1,41 @@
"""
PyTaiko Libraries Package
This package contains core libraries for PyTaiko.
Modules:
animation: Animation system for game objects
audio: Audio engine for sound and music playback
background: Background rendering system
chara_2d: 2D character animation system
config: Configuration management
file_navigator: File and song navigation UI
global_data: Global data structures and enums
global_objects: Global UI objects (nameplate, timer, etc.)
screen: Base screen class for game states
song_hash: Song hashing and database management
texture: Texture loading and management
tja: TJA chart file parser
transition: Screen transition effects
utils: Utility functions and helpers
video: Video playback system
"""
__version__ = "1.1"
__all__ = [
"animation",
"audio",
"background",
"chara_2d",
"config",
"file_navigator",
"global_data",
"global_objects",
"screen",
"song_hash",
"texture",
"tja",
"transition",
"utils",
"video",
]

View File

@@ -3,17 +3,8 @@ from typing import Any, Optional
from libs.global_data import global_data from libs.global_data import global_data
def get_current_ms() -> float:
def rounded(num: float) -> int: return time.time() * 1000
sign = 1 if (num >= 0) else -1
num = abs(num)
result = int(num)
if (num - result >= 0.5):
result += 1
return sign * result
def get_current_ms() -> int:
return rounded(time.time() * 1000)
class BaseAnimation(): class BaseAnimation():
@@ -84,6 +75,22 @@ class BaseAnimation():
self.restart() self.restart()
self.pause() self.pause()
def copy(self):
"""Create a copy of the animation with reset state."""
new_anim = self.__class__.__new__(self.__class__)
new_anim.duration = self.duration
new_anim.delay = self.delay_saved
new_anim.delay_saved = self.delay_saved
new_anim.start_ms = get_current_ms()
new_anim.is_finished = False
new_anim.attribute = 0
new_anim.is_started = False
new_anim.is_reversing = False
new_anim.unlocked = False
new_anim.loop = self.loop
new_anim.lock_input = self.lock_input
return new_anim
def _ease_in(self, progress: float, ease_type: str) -> float: def _ease_in(self, progress: float, ease_type: str) -> float:
if ease_type == "quadratic": if ease_type == "quadratic":
return progress * progress return progress * progress
@@ -133,6 +140,20 @@ class FadeAnimation(BaseAnimation):
self.final_opacity = self.final_opacity_saved self.final_opacity = self.final_opacity_saved
self.attribute = self.initial_opacity self.attribute = self.initial_opacity
def copy(self):
"""Create a copy of the fade animation with reset state."""
new_anim = super().copy()
new_anim.initial_opacity = self.initial_opacity_saved
new_anim.initial_opacity_saved = self.initial_opacity_saved
new_anim.final_opacity = self.final_opacity_saved
new_anim.final_opacity_saved = self.final_opacity_saved
new_anim.ease_in = self.ease_in
new_anim.ease_out = self.ease_out
new_anim.reverse_delay = self.reverse_delay_saved
new_anim.reverse_delay_saved = self.reverse_delay_saved
new_anim.attribute = self.initial_opacity_saved
return new_anim
def update(self, current_time_ms: float) -> None: def update(self, current_time_ms: float) -> None:
if not self.is_started: if not self.is_started:
return return
@@ -181,6 +202,20 @@ class MoveAnimation(BaseAnimation):
self.start_position = self.start_position_saved self.start_position = self.start_position_saved
self.attribute = self.start_position self.attribute = self.start_position
def copy(self):
"""Create a copy of the move animation with reset state."""
new_anim = super().copy()
new_anim.reverse_delay = self.reverse_delay_saved
new_anim.reverse_delay_saved = self.reverse_delay_saved
new_anim.total_distance = self.total_distance_saved
new_anim.total_distance_saved = self.total_distance_saved
new_anim.start_position = self.start_position_saved
new_anim.start_position_saved = self.start_position_saved
new_anim.ease_in = self.ease_in
new_anim.ease_out = self.ease_out
new_anim.attribute = self.start_position_saved
return new_anim
def update(self, current_time_ms: float) -> None: def update(self, current_time_ms: float) -> None:
if not self.is_started: if not self.is_started:
return return
@@ -217,6 +252,13 @@ class TextureChangeAnimation(BaseAnimation):
super().reset() super().reset()
self.attribute = self.textures[0][2] self.attribute = self.textures[0][2]
def copy(self):
"""Create a copy of the texture change animation with reset state."""
new_anim = super().copy()
new_anim.textures = self.textures # List of tuples, can be shared
new_anim.attribute = self.textures[0][2]
return new_anim
def update(self, current_time_ms: float) -> None: def update(self, current_time_ms: float) -> None:
if not self.is_started: if not self.is_started:
return return
@@ -234,6 +276,10 @@ class TextureChangeAnimation(BaseAnimation):
self.is_finished = True self.is_finished = True
class TextStretchAnimation(BaseAnimation): class TextStretchAnimation(BaseAnimation):
def copy(self):
"""Create a copy of the text stretch animation with reset state."""
return super().copy()
def update(self, current_time_ms: float) -> None: def update(self, current_time_ms: float) -> None:
if not self.is_started: if not self.is_started:
return return
@@ -275,6 +321,20 @@ class TextureResizeAnimation(BaseAnimation):
self.initial_size = self.initial_size_saved self.initial_size = self.initial_size_saved
self.final_size = self.final_size_saved self.final_size = self.final_size_saved
def copy(self):
"""Create a copy of the texture resize animation with reset state."""
new_anim = super().copy()
new_anim.initial_size = self.initial_size_saved
new_anim.initial_size_saved = self.initial_size_saved
new_anim.final_size = self.final_size_saved
new_anim.final_size_saved = self.final_size_saved
new_anim.reverse_delay = self.reverse_delay_saved
new_anim.reverse_delay_saved = self.reverse_delay_saved
new_anim.ease_in = self.ease_in
new_anim.ease_out = self.ease_out
new_anim.attribute = self.initial_size_saved
return new_anim
def update(self, current_time_ms: float) -> None: def update(self, current_time_ms: float) -> None:
if not self.is_started: if not self.is_started:

View File

@@ -1,10 +1,10 @@
import cffi
import platform
import logging import logging
import platform
from pathlib import Path from pathlib import Path
from libs.config import VolumeConfig import cffi
from libs.config import get_config
from libs.config import VolumeConfig, get_config
ffi = cffi.FFI() ffi = cffi.FFI()
@@ -92,6 +92,7 @@ ffi.cdef("""
void resume_music_stream(music music); void resume_music_stream(music music);
void stop_music_stream(music music); void stop_music_stream(music music);
void seek_music_stream(music music, float position); void seek_music_stream(music music, float position);
bool music_stream_needs_update(music music);
void update_music_stream(music music); void update_music_stream(music music);
bool is_music_stream_playing(music music); bool is_music_stream_playing(music music);
void set_music_volume(music music, float volume); void set_music_volume(music music, float volume);
@@ -119,7 +120,8 @@ except OSError as e:
class AudioEngine: class AudioEngine:
"""Initialize an audio engine for playing sounds and music.""" """Initialize an audio engine for playing sounds and music."""
def __init__(self, device_type: int, sample_rate: float, buffer_size: int, volume_presets: VolumeConfig): def __init__(self, device_type: int, sample_rate: float, buffer_size: int,
volume_presets: VolumeConfig, sounds_path: Path | None = None):
self.device_type = max(device_type, 0) self.device_type = max(device_type, 0)
if sample_rate < 0: if sample_rate < 0:
self.target_sample_rate = 44100 self.target_sample_rate = 44100
@@ -131,7 +133,10 @@ class AudioEngine:
self.audio_device_ready = False self.audio_device_ready = False
self.volume_presets = volume_presets self.volume_presets = volume_presets
self.sounds_path = Path("Sounds") if sounds_path is None:
self.sounds_path = Path(f"Skins/{get_config()['paths']['skin']}/Sounds")
else:
self.sounds_path = sounds_path
def set_log_level(self, level: int): def set_log_level(self, level: int):
lib.set_log_level(level) # type: ignore lib.set_log_level(level) # type: ignore
@@ -355,10 +360,18 @@ class AudioEngine:
else: else:
logger.warning(f"Music stream {name} not found") logger.warning(f"Music stream {name} not found")
def update_music_stream(self, name: str) -> None: def music_stream_needs_update(self, name: str) -> bool:
"""Update a music stream""" """Check if a music stream needs updating (buffers need refilling)"""
if name in self.music_streams: if name in self.music_streams:
music = self.music_streams[name] music = self.music_streams[name]
return lib.music_stream_needs_update(music) # type: ignore
return False
def update_music_stream(self, name: str) -> None:
"""Update a music stream (only if buffers need refilling)"""
if name in self.music_streams:
music = self.music_streams[name]
if lib.music_stream_needs_update(music): # type: ignore
lib.update_music_stream(music) # type: ignore lib.update_music_stream(music) # type: ignore
else: else:
logger.warning(f"Music stream {name} not found") logger.warning(f"Music stream {name} not found")

View File

@@ -175,6 +175,7 @@ void pause_music_stream(music music);
void resume_music_stream(music music); void resume_music_stream(music music);
void stop_music_stream(music music); void stop_music_stream(music music);
void seek_music_stream(music music, float position); void seek_music_stream(music music, float position);
bool music_stream_needs_update(music music);
void update_music_stream(music music); void update_music_stream(music music);
bool is_music_stream_playing(music music); bool is_music_stream_playing(music music);
void set_music_volume(music music, float volume); void set_music_volume(music music, float volume);
@@ -1064,6 +1065,17 @@ void seek_music_stream(music music, float position) {
pthread_mutex_unlock(&AUDIO.System.lock); pthread_mutex_unlock(&AUDIO.System.lock);
} }
bool music_stream_needs_update(music music) {
if (music.stream.buffer == NULL || music.ctxData == NULL) return false;
pthread_mutex_lock(&AUDIO.System.lock);
bool needs_update = music.stream.buffer->isSubBufferProcessed[0] ||
music.stream.buffer->isSubBufferProcessed[1];
pthread_mutex_unlock(&AUDIO.System.lock);
return needs_update;
}
void update_music_stream(music music) { void update_music_stream(music music) {
if (music.stream.buffer == NULL || music.ctxData == NULL) return; if (music.stream.buffer == NULL || music.ctxData == NULL) return;
@@ -1071,33 +1083,41 @@ void update_music_stream(music music) {
SNDFILE *sndFile = ctx->snd_file; SNDFILE *sndFile = ctx->snd_file;
if (sndFile == NULL) return; if (sndFile == NULL) return;
for (int i = 0; i < 2; i++) { bool needs_refill[2];
pthread_mutex_lock(&AUDIO.System.lock); pthread_mutex_lock(&AUDIO.System.lock);
bool needs_refill = music.stream.buffer->isSubBufferProcessed[i]; needs_refill[0] = music.stream.buffer->isSubBufferProcessed[0];
needs_refill[1] = music.stream.buffer->isSubBufferProcessed[1];
pthread_mutex_unlock(&AUDIO.System.lock); pthread_mutex_unlock(&AUDIO.System.lock);
if (needs_refill) { if (!needs_refill[0] && !needs_refill[1]) return;
unsigned int subBufferSizeFrames = music.stream.buffer->sizeInFrames / 2; unsigned int subBufferSizeFrames = music.stream.buffer->sizeInFrames / 2;
float *buffer_data = (float *)music.stream.buffer->data;
bool needs_resampling = (ctx->resampler != NULL);
bool needs_mono_to_stereo = (music.stream.channels == 1 && AUDIO_DEVICE_CHANNELS == 2);
unsigned int frames_to_read = subBufferSizeFrames; unsigned int frames_to_read = subBufferSizeFrames;
if (ctx->resampler) { if (needs_resampling) {
frames_to_read = (unsigned int)(subBufferSizeFrames / ctx->src_ratio) + 1; frames_to_read = (unsigned int)(subBufferSizeFrames / ctx->src_ratio) + 1;
} }
if (AUDIO.System.pcmBufferSize < frames_to_read * music.stream.channels * sizeof(float)) { size_t required_size = frames_to_read * music.stream.channels * sizeof(float);
if (AUDIO.System.pcmBufferSize < required_size) {
FREE(AUDIO.System.pcmBuffer); FREE(AUDIO.System.pcmBuffer);
AUDIO.System.pcmBuffer = calloc(1, frames_to_read * music.stream.channels * sizeof(float)); AUDIO.System.pcmBuffer = calloc(1, required_size);
AUDIO.System.pcmBufferSize = frames_to_read * music.stream.channels * sizeof(float); AUDIO.System.pcmBufferSize = required_size;
} }
for (int i = 0; i < 2; i++) {
if (!needs_refill[i]) continue;
sf_count_t frames_read = sf_readf_float(sndFile, (float*)AUDIO.System.pcmBuffer, frames_to_read); sf_count_t frames_read = sf_readf_float(sndFile, (float*)AUDIO.System.pcmBuffer, frames_to_read);
unsigned int subBufferOffset = i * subBufferSizeFrames * AUDIO_DEVICE_CHANNELS; unsigned int subBufferOffset = i * subBufferSizeFrames * AUDIO_DEVICE_CHANNELS;
float *buffer_data = (float *)music.stream.buffer->data;
float *input_ptr = (float *)AUDIO.System.pcmBuffer; float *input_ptr = (float *)AUDIO.System.pcmBuffer;
sf_count_t frames_written = 0; sf_count_t frames_written = 0;
if (ctx->resampler) { if (needs_resampling) {
spx_uint32_t in_len = frames_read; spx_uint32_t in_len = frames_read;
spx_uint32_t out_len = subBufferSizeFrames; spx_uint32_t out_len = subBufferSizeFrames;
@@ -1115,7 +1135,7 @@ void update_music_stream(music music) {
frames_written = out_len; frames_written = out_len;
} else { } else {
if (music.stream.channels == 1 && AUDIO_DEVICE_CHANNELS == 2) { if (needs_mono_to_stereo) {
for (int j = 0; j < frames_read; j++) { for (int j = 0; j < frames_read; j++) {
buffer_data[subBufferOffset + j*2] = input_ptr[j]; buffer_data[subBufferOffset + j*2] = input_ptr[j];
buffer_data[subBufferOffset + j*2 + 1] = input_ptr[j]; buffer_data[subBufferOffset + j*2 + 1] = input_ptr[j];
@@ -1131,12 +1151,12 @@ void update_music_stream(music music) {
unsigned int size = (subBufferSizeFrames - frames_written) * AUDIO_DEVICE_CHANNELS * sizeof(float); unsigned int size = (subBufferSizeFrames - frames_written) * AUDIO_DEVICE_CHANNELS * sizeof(float);
memset(buffer_data + offset, 0, size); memset(buffer_data + offset, 0, size);
} }
}
pthread_mutex_lock(&AUDIO.System.lock); pthread_mutex_lock(&AUDIO.System.lock);
music.stream.buffer->isSubBufferProcessed[i] = false; if (needs_refill[0]) music.stream.buffer->isSubBufferProcessed[0] = false;
if (needs_refill[1]) music.stream.buffer->isSubBufferProcessed[1] = false;
pthread_mutex_unlock(&AUDIO.System.lock); pthread_mutex_unlock(&AUDIO.System.lock);
}
}
} }
bool is_music_stream_playing(music music) { bool is_music_stream_playing(music music) {

View File

@@ -26,6 +26,7 @@ class Background:
"IMAS_CG": (libs.bg_collabs.imas.Background, 'background/collab/imas_cg', 3), "IMAS_CG": (libs.bg_collabs.imas.Background, 'background/collab/imas_cg', 3),
"IMAS_ML": (libs.bg_collabs.imas.Background, 'background/collab/imas_ml', 3), "IMAS_ML": (libs.bg_collabs.imas.Background, 'background/collab/imas_ml', 3),
"IMAS_SIDEM": (libs.bg_collabs.imas_sidem.Background, 'background/collab/imas_sidem', 3), "IMAS_SIDEM": (libs.bg_collabs.imas_sidem.Background, 'background/collab/imas_sidem', 3),
"FUNASSYI": (libs.bg_collabs.funassyi.Background, 'background/collab/funassyi', 5),
"DAN": (libs.bg_collabs.dan.Background, 'background/collab/dan', 1), "DAN": (libs.bg_collabs.dan.Background, 'background/collab/dan', 1),
"PRACTICE": (libs.bg_collabs.practice.Background, 'background/collab/practice', 1) "PRACTICE": (libs.bg_collabs.practice.Background, 'background/collab/practice', 1)
} }
@@ -130,10 +131,17 @@ class Background:
current_milestone = min(self.max_dancers - 1, int(gauge_1p.gauge_length / (clear_threshold / self.max_dancers))) current_milestone = min(self.max_dancers - 1, int(gauge_1p.gauge_length / (clear_threshold / self.max_dancers)))
else: else:
current_milestone = self.max_dancers current_milestone = self.max_dancers
if current_milestone > self.last_milestone and current_milestone <= self.max_dancers: if current_milestone > self.last_milestone and current_milestone <= self.max_dancers:
self.dancer.add_dancer() self.dancer.add_dancer()
self.last_milestone = current_milestone self.last_milestone = current_milestone
logger.info(f"Dancer milestone reached: {current_milestone}/{self.max_dancers}") logger.info(f"Dancer milestone reached: {current_milestone}/{self.max_dancers}")
elif current_milestone < self.last_milestone:
dancers_to_remove = self.last_milestone - current_milestone
for _ in range(dancers_to_remove):
self.dancer.remove_dancer()
self.last_milestone = current_milestone
logger.info(f"Dancer milestones lost: {current_milestone}/{self.max_dancers} (removed {dancers_to_remove})")
if self.bg_fever is not None and gauge_1p is not None: if self.bg_fever is not None and gauge_1p is not None:
if not self.is_clear and gauge_1p.is_clear: if not self.is_clear and gauge_1p.is_clear:
self.bg_fever.start() self.bg_fever.start()

View File

@@ -6,3 +6,4 @@ from . import imas
from . import dan from . import dan
from . import imas_sidem from . import imas_sidem
from . import practice from . import practice
from . import funassyi

View File

@@ -1,4 +1,5 @@
import random import random
from libs.animation import Animation from libs.animation import Animation
from libs.bg_objects.bg_fever import BGFever4 from libs.bg_objects.bg_fever import BGFever4
from libs.bg_objects.bg_normal import BGNormal2 from libs.bg_objects.bg_normal import BGNormal2
@@ -9,6 +10,7 @@ from libs.bg_objects.renda import RendaController
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
class Background: class Background:
def __init__(self, tex: TextureWrapper, player_num: PlayerNum, bpm: float, path: str, max_dancers: int): def __init__(self, tex: TextureWrapper, player_num: PlayerNum, bpm: float, path: str, max_dancers: int):
self.tex_wrapper = tex self.tex_wrapper = tex

View File

@@ -1,14 +1,15 @@
import random import random
from libs.bg_objects.bg_fever import BGFeverBase from libs.bg_objects.bg_fever import BGFeverBase
from libs.bg_objects.bg_normal import BGNormalBase from libs.bg_objects.bg_normal import BGNormalBase
from libs.bg_objects.chibi import ChibiController from libs.bg_objects.chibi import ChibiController
from libs.bg_objects.dancer import BaseDancer, BaseDancerGroup from libs.bg_objects.dancer import BaseDancer, BaseDancerGroup
from libs.bg_objects.don_bg import DonBG4
from libs.bg_objects.fever import Fever3 from libs.bg_objects.fever import Fever3
from libs.bg_objects.footer import Footer from libs.bg_objects.footer import Footer
from libs.bg_objects.renda import RendaController from libs.bg_objects.renda import RendaController
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
from libs.bg_objects.don_bg import DonBG4
class Background: class Background:

View File

@@ -1,13 +1,14 @@
import random import random
from libs.bg_objects.bg_fever import BGFeverBase from libs.bg_objects.bg_fever import BGFeverBase
from libs.bg_objects.bg_normal import BGNormalBase from libs.bg_objects.bg_normal import BGNormalBase
from libs.bg_objects.chibi import ChibiController from libs.bg_objects.chibi import ChibiController
from libs.bg_objects.dancer import BaseDancer, BaseDancerGroup from libs.bg_objects.dancer import BaseDancer, BaseDancerGroup
from libs.bg_objects.don_bg import DonBG4
from libs.bg_objects.fever import Fever3 from libs.bg_objects.fever import Fever3
from libs.bg_objects.renda import RendaController from libs.bg_objects.renda import RendaController
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
from libs.bg_objects.don_bg import DonBG4
class Background: class Background:

View File

@@ -6,6 +6,7 @@ from libs.bg_objects.footer import Footer
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
class Background: class Background:
def __init__(self, tex: TextureWrapper, player_num: PlayerNum, bpm: float, path: str, max_dancers: int): def __init__(self, tex: TextureWrapper, player_num: PlayerNum, bpm: float, path: str, max_dancers: int):
self.tex_wrapper = tex self.tex_wrapper = tex

View File

@@ -0,0 +1,66 @@
from libs.bg_objects.bg_fever import BGFeverBase
from libs.bg_objects.bg_normal import BGNormalBase
from libs.bg_objects.chibi import ChibiController
from libs.bg_objects.dancer import BaseDancer, BaseDancerGroup
from libs.bg_objects.don_bg import DonBG4
from libs.bg_objects.renda import RendaController
from libs.bg_objects.fever import Fever0
from libs.bg_objects.footer import Footer
from libs.global_data import PlayerNum
from libs.texture import TextureWrapper
class Background:
def __init__(self, tex: TextureWrapper, player_num: PlayerNum, bpm: float, path: str, max_dancers: int):
self.tex_wrapper = tex
self.max_dancers = max_dancers
self.don_bg = DonBG4(tex, 0, player_num, path)
self.bg_normal = BGNormalBase(self.tex_wrapper, 0, path)
self.bg_fever = BGFever(self.tex_wrapper, 0, path)
self.footer = Footer(self.tex_wrapper, 2)
self.fever = Fever0(self.tex_wrapper, 0, bpm)
self.dancer = DancerGroup(self.tex_wrapper, 0, bpm, max_dancers, path)
self.renda = RendaController(self.tex_wrapper, 0)
self.chibi = ChibiController(self.tex_wrapper, 0, bpm, path)
class DancerGroup(BaseDancerGroup):
def __init__(self, tex: TextureWrapper, index: int, bpm: float, max_dancers: int, path: str):
self.name = 'dancer_' + str(index)
self.active_count = 0
tex.load_zip(path, f'dancer/{self.name}')
self.spawn_positions = [2, 1, 3, 0, 4]
self.active_dancers = [None] * max_dancers
self.dancers = [BaseDancer(self.name, 0, bpm, tex),
BaseDancer(self.name, 1, bpm, tex),
BaseDancer(self.name, 2, bpm, tex),
BaseDancer(self.name, 3, bpm, tex),
BaseDancer(self.name, 4, bpm, tex)]
self.add_dancer()
class BGFever(BGFeverBase):
def __init__(self, tex: TextureWrapper, index: int, path: str):
super().__init__(tex, index, path)
self.horizontal_move = tex.get_animation(16)
self.bg_texture_move_down = tex.get_animation(17)
self.bg_texture_move_up = tex.get_animation(18)
def start(self):
self.bg_texture_move_down.start()
self.bg_texture_move_up.start()
def update(self, current_time_ms: float):
self.bg_texture_move_down.update(current_time_ms)
self.bg_texture_move_up.update(current_time_ms)
if self.bg_texture_move_up.is_finished and not self.transitioned:
self.transitioned = True
self.horizontal_move.restart()
if self.transitioned:
self.horizontal_move.update(current_time_ms)
def draw(self, tex: TextureWrapper):
y = self.bg_texture_move_down.attribute - self.bg_texture_move_up.attribute
tex.draw_texture(self.name, 'background', y=y)
tex.draw_texture(self.name, 'overlay', x=-self.horizontal_move.attribute, y=y)
tex.draw_texture(self.name, 'overlay', x=tex.textures[self.name]['overlay'].width - self.horizontal_move.attribute, y=y)

View File

@@ -1,16 +1,16 @@
import pyray as ray
from libs.animation import Animation from libs.animation import Animation
from libs.bg_objects.bg_fever import BGFeverBase from libs.bg_objects.bg_fever import BGFeverBase
from libs.bg_objects.bg_normal import BGNormalBase from libs.bg_objects.bg_normal import BGNormalBase
from libs.bg_objects.chibi import ChibiController from libs.bg_objects.chibi import ChibiController
from libs.bg_objects.dancer import BaseDancerGroup from libs.bg_objects.dancer import BaseDancerGroup
from libs.bg_objects.don_bg import DonBGBase
from libs.bg_objects.fever import BaseFever from libs.bg_objects.fever import BaseFever
from libs.bg_objects.footer import Footer from libs.bg_objects.footer import Footer
from libs.bg_objects.renda import RendaController from libs.bg_objects.renda import RendaController
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
from libs.bg_objects.don_bg import DonBGBase
import pyray as ray
class Background: class Background:

View File

@@ -4,11 +4,11 @@ from libs.bg_objects.bg_fever import BGFeverBase
from libs.bg_objects.bg_normal import BGNormalBase from libs.bg_objects.bg_normal import BGNormalBase
from libs.bg_objects.chibi import ChibiController from libs.bg_objects.chibi import ChibiController
from libs.bg_objects.dancer import BaseDancerGroup from libs.bg_objects.dancer import BaseDancerGroup
from libs.bg_objects.don_bg import DonBGBase
from libs.bg_objects.footer import Footer from libs.bg_objects.footer import Footer
from libs.bg_objects.renda import RendaController from libs.bg_objects.renda import RendaController
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
from libs.bg_objects.don_bg import DonBGBase
class Background: class Background:

View File

@@ -1,15 +1,16 @@
from libs.animation import Animation from libs.animation import Animation
from libs.bg_objects.fever import Fever3
from libs.bg_objects.bg_fever import BGFeverBase from libs.bg_objects.bg_fever import BGFeverBase
from libs.bg_objects.bg_normal import BGNormalBase from libs.bg_objects.bg_normal import BGNormalBase
from libs.bg_objects.chibi import ChibiController from libs.bg_objects.chibi import ChibiController
from libs.bg_objects.dancer import BaseDancer, BaseDancerGroup from libs.bg_objects.dancer import BaseDancer, BaseDancerGroup
from libs.bg_objects.don_bg import DonBGBase from libs.bg_objects.don_bg import DonBGBase
from libs.bg_objects.fever import Fever3
from libs.bg_objects.footer import Footer from libs.bg_objects.footer import Footer
from libs.bg_objects.renda import RendaController from libs.bg_objects.renda import RendaController
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
class Background: class Background:
def __init__(self, tex: TextureWrapper, player_num: PlayerNum, bpm: float, path: str, max_dancers: int): def __init__(self, tex: TextureWrapper, player_num: PlayerNum, bpm: float, path: str, max_dancers: int):
self.tex_wrapper = tex self.tex_wrapper = tex

View File

@@ -1,8 +1,10 @@
import random import random
import pyray as ray
from libs.animation import Animation from libs.animation import Animation
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
import pyray as ray
class Chibi: class Chibi:

View File

@@ -3,6 +3,7 @@ import random
from libs.animation import Animation from libs.animation import Animation
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
class Dancer: class Dancer:
@staticmethod @staticmethod
@@ -161,6 +162,12 @@ class BaseDancerGroup():
dancer.start() dancer.start()
self.active_count += 1 self.active_count += 1
def remove_dancer(self):
if self.active_count > 1:
self.active_count -= 1
position = self.spawn_positions[self.active_count]
self.active_dancers[position] = None
def update(self, current_time_ms: float, bpm: float): def update(self, current_time_ms: float, bpm: float):
for dancer in self.dancers: for dancer in self.dancers:
dancer.update(current_time_ms, bpm) dancer.update(current_time_ms, bpm)

View File

@@ -1,3 +1,4 @@
from libs.animation import Animation
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
@@ -14,7 +15,8 @@ class DonBGBase:
def __init__(self, tex: TextureWrapper, index: int, player_num: PlayerNum, path: str): def __init__(self, tex: TextureWrapper, index: int, player_num: PlayerNum, path: str):
self.name = f'{index}_{player_num}' self.name = f'{index}_{player_num}'
tex.load_zip(path, f'donbg/{self.name}') tex.load_zip(path, f'donbg/{self.name}')
self.move = tex.get_animation(0) self.move = Animation.create_move(3000, total_distance=-tex.textures[self.name]['background'].width, loop=True)
self.move.start()
self.is_clear = False self.is_clear = False
self.clear_fade = tex.get_animation(1) self.clear_fade = tex.get_animation(1)
@@ -97,7 +99,7 @@ class DonBG4(DonBGBase):
self.overlay_move.update(current_time_ms) self.overlay_move.update(current_time_ms)
def _draw_textures(self, tex: TextureWrapper, fade: float, y: float): def _draw_textures(self, tex: TextureWrapper, fade: float, y: float):
for i in range(int(5 * tex.screen_scale)): for i in range(5):
tex.draw_texture(self.name, 'background', frame=self.is_clear, fade=fade, x=(i*tex.textures[self.name]['background'].width)+self.move.attribute, y=y) tex.draw_texture(self.name, 'background', frame=self.is_clear, fade=fade, x=(i*tex.textures[self.name]['background'].width)+self.move.attribute, y=y)
tex.draw_texture(self.name, 'overlay', frame=self.is_clear, fade=fade, x=(i*tex.textures[self.name]['overlay'].width)+self.move.attribute, y=self.overlay_move.attribute+y) tex.draw_texture(self.name, 'overlay', frame=self.is_clear, fade=fade, x=(i*tex.textures[self.name]['overlay'].width)+self.move.attribute, y=self.overlay_move.attribute+y)

View File

@@ -1,6 +1,7 @@
from libs.animation import Animation from libs.animation import Animation
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
class Fever: class Fever:
@staticmethod @staticmethod

View File

@@ -1,5 +1,6 @@
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
class Footer: class Footer:
def __init__(self, tex: TextureWrapper, index: int, path: str = 'background'): def __init__(self, tex: TextureWrapper, index: int, path: str = 'background'):
self.index = index self.index = index

View File

@@ -1,8 +1,10 @@
import random import random
import pyray as ray
from libs.animation import Animation from libs.animation import Animation
from libs.texture import TextureWrapper from libs.texture import TextureWrapper
import pyray as ray
class Renda: class Renda:

View File

@@ -1,4 +1,5 @@
import logging import logging
from libs.animation import Animation from libs.animation import Animation
from libs.utils import global_tex from libs.utils import global_tex
@@ -106,7 +107,7 @@ class Chara2D:
self.current_anim = self.past_anim self.current_anim = self.past_anim
self.anims[self.current_anim].restart() self.anims[self.current_anim].restart()
def draw(self, x: float = 0, y: float = 0, mirror=False): def draw(self, x: float = 0, y: float = 0, mirror=False, scale=1.0):
""" """
Draw the character on the screen. Draw the character on the screen.
@@ -116,9 +117,9 @@ class Chara2D:
mirror (bool): Whether to mirror the character horizontally. mirror (bool): Whether to mirror the character horizontally.
""" """
if self.is_rainbow and self.current_anim not in {'soul_in', 'balloon_pop', 'balloon_popping'}: if self.is_rainbow and self.current_anim not in {'soul_in', 'balloon_pop', 'balloon_popping'}:
self.tex.draw_texture(self.name, self.current_anim + '_max', frame=self.anims[self.current_anim].attribute, x=x, y=y) self.tex.draw_texture(self.name, self.current_anim + '_max', frame=self.anims[self.current_anim].attribute, x=x, y=y, scale=scale)
else: else:
if mirror: if mirror:
self.tex.draw_texture(self.name, self.current_anim, frame=self.anims[self.current_anim].attribute, x=x, y=y, mirror='horizontal') self.tex.draw_texture(self.name, self.current_anim, frame=self.anims[self.current_anim].attribute, x=x, y=y, mirror='horizontal', scale=scale)
else: else:
self.tex.draw_texture(self.name, self.current_anim, frame=self.anims[self.current_anim].attribute, x=x, y=y) self.tex.draw_texture(self.name, self.current_anim, frame=self.anims[self.current_anim].attribute, x=x, y=y, scale=scale)

View File

@@ -1,9 +1,10 @@
from pathlib import Path
import tomlkit
import json import json
from pathlib import Path
from typing import TypedDict from typing import TypedDict
import pyray as ray import pyray as ray
import tomlkit
class GeneralConfig(TypedDict): class GeneralConfig(TypedDict):
fps_counter: bool fps_counter: bool
@@ -30,8 +31,7 @@ class NameplateConfig(TypedDict):
class PathsConfig(TypedDict): class PathsConfig(TypedDict):
tja_path: list[Path] tja_path: list[Path]
video_path: list[Path] skin: Path
graphics_path: Path
class KeysConfig(TypedDict): class KeysConfig(TypedDict):
exit_key: int exit_key: int

View File

@@ -1,21 +1,23 @@
from dataclasses import dataclass
from enum import IntEnum
import json import json
import logging import logging
from pathlib import Path
import random import random
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import IntEnum
from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
from raylib import SHADER_UNIFORM_VEC3
from libs.audio import audio
from libs.animation import Animation, MoveAnimation
from libs.global_data import Crown, Difficulty, ScoreMethod
from libs.tja import TJAParser, test_encodings
from libs.texture import tex
from libs.utils import OutlinedText, get_current_ms, global_data
from datetime import datetime, timedelta
import sqlite3
import pyray as ray import pyray as ray
from raylib import SHADER_UNIFORM_VEC3
from libs.animation import Animation, MoveAnimation
from libs.audio import audio
from libs.global_data import Crown, Difficulty, ScoreMethod
from libs.parsers.osz import OsuParser
from libs.texture import tex
from libs.parsers.tja import TJAParser, test_encodings
from libs.utils import OutlinedText, get_current_ms, global_data
BOX_CENTER = 594 * tex.screen_scale BOX_CENTER = 594 * tex.screen_scale
@@ -109,18 +111,22 @@ class BaseBox():
else: else:
self.fore_color = ray.Color(101, 0, 82, 255) self.fore_color = ray.Color(101, 0, 82, 255)
self.position = float('inf') self.position = float('inf')
self.start_position = -1.0 self.start_position = float('inf')
self.target_position = -1.0 self.target_position = float('inf')
self.open_anim = Animation.create_move(233, total_distance=150*tex.screen_scale, delay=50) self.open_anim = Animation.create_move(233, total_distance=150*tex.screen_scale, delay=50)
self.open_fade = Animation.create_fade(200, initial_opacity=0, final_opacity=1.0) self.open_fade = Animation.create_fade(200, initial_opacity=0, final_opacity=1.0)
self.move = None self.move = Animation.create_move(133, total_distance=100 * tex.screen_scale, ease_out='cubic')
self.move.start()
self.shader = None self.shader = None
self.is_open = False self.is_open = False
self.text_loaded = False self.text_loaded = False
self.wait = 0 self.wait = 0
def load_text(self): def load_text(self):
self.name = OutlinedText(self.text_name, tex.skin_config["song_box_name"].font_size, ray.WHITE, outline_thickness=5, vertical=True) font_size = tex.skin_config["song_box_name"].font_size
if len(self.text_name) >= 30:
font_size -= int(10 * tex.screen_scale)
self.name = OutlinedText(self.text_name, font_size, ray.WHITE, outline_thickness=5, vertical=True)
if self.back_color is not None: if self.back_color is not None:
self.shader = ray.load_shader('shader/dummy.vs', 'shader/colortransform.fs') self.shader = ray.load_shader('shader/dummy.vs', 'shader/colortransform.fs')
source_rgb = (142, 212, 30) source_rgb = (142, 212, 30)
@@ -132,30 +138,22 @@ class BaseBox():
ray.set_shader_value(self.shader, source_loc, source_color, SHADER_UNIFORM_VEC3) ray.set_shader_value(self.shader, source_loc, source_color, SHADER_UNIFORM_VEC3)
ray.set_shader_value(self.shader, target_loc, target_color, SHADER_UNIFORM_VEC3) ray.set_shader_value(self.shader, target_loc, target_color, SHADER_UNIFORM_VEC3)
def move_box(self, current_time: float): def move_box(self, direction: int):
if self.position != self.target_position and self.move is None: if self.position != self.target_position:
if self.position < self.target_position: distance = abs(self.target_position - self.position)
direction = 1 self.move = Animation.create_move(133, total_distance=distance * tex.screen_scale * direction, ease_out='cubic')
else:
direction = -1
if abs(self.target_position - self.position) > 250 * tex.screen_scale:
direction *= -1
self.move = Animation.create_move(133, total_distance=100 * direction * tex.screen_scale, ease_out='cubic')
self.move.start()
if self.is_open or self.target_position == BOX_CENTER:
self.move.total_distance = int(250 * direction * tex.screen_scale)
self.start_position = self.position self.start_position = self.position
if self.move is not None: self.move.start()
self.move.update(current_time)
self.position = self.start_position + int(self.move.attribute)
if self.move.is_finished:
self.position = self.target_position
self.move = None
def update(self, current_time: float, is_diff_select: bool): def update(self, current_time: float, is_diff_select: bool):
self.is_diff_select = is_diff_select self.is_diff_select = is_diff_select
self.open_anim.update(current_time) self.open_anim.update(current_time)
self.open_fade.update(current_time) self.open_fade.update(current_time)
self.move.update(current_time)
if not self.move.is_finished:
self.position = self.start_position + int(self.move.attribute)
else:
self.position = self.target_position
def _draw_closed(self, x: float, y: float, outer_fade_override: float): def _draw_closed(self, x: float, y: float, outer_fade_override: float):
if self.shader is not None and self.texture_index == TextureIndex.BLANK: if self.shader is not None and self.texture_index == TextureIndex.BLANK:
@@ -192,7 +190,6 @@ class BackBox(BaseBox):
def update(self, current_time: float, is_diff_select: bool): def update(self, current_time: float, is_diff_select: bool):
super().update(current_time, is_diff_select) super().update(current_time, is_diff_select)
is_open_prev = self.is_open is_open_prev = self.is_open
self.move_box(current_time)
self.is_open = self.position == BOX_CENTER self.is_open = self.position == BOX_CENTER
if self.yellow_box is not None: if self.yellow_box is not None:
@@ -212,13 +209,13 @@ class BackBox(BaseBox):
self.yellow_box.draw(self, fade_override, is_ura, self.name) self.yellow_box.draw(self, fade_override, is_ura, self.name)
class SongBox(BaseBox): class SongBox(BaseBox):
def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, tja: TJAParser): def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, tja: TJAParser | OsuParser):
super().__init__(name, back_color, fore_color, texture_index) super().__init__(name, back_color, fore_color, texture_index)
self.scores = dict() self.scores = dict()
self.hash = dict() self.hash = dict()
self.score_history = None self.score_history = None
self.history_wait = 0 self.history_wait = 0
self.tja = tja self.parser = tja
self.is_favorite = False self.is_favorite = False
self.yellow_box = None self.yellow_box = None
@@ -230,8 +227,8 @@ class SongBox(BaseBox):
with sqlite3.connect(global_data.score_db) as con: with sqlite3.connect(global_data.score_db) as con:
cursor = con.cursor() cursor = con.cursor()
# Batch database query for all diffs at once # Batch database query for all diffs at once
if self.tja.metadata.course_data: if self.parser.metadata.course_data:
hash_values = [self.hash[diff] for diff in self.tja.metadata.course_data if diff in self.hash] hash_values = [self.hash[diff] for diff in self.parser.metadata.course_data if diff in self.hash]
placeholders = ','.join('?' * len(hash_values)) placeholders = ','.join('?' * len(hash_values))
batch_query = f""" batch_query = f"""
@@ -243,7 +240,7 @@ class SongBox(BaseBox):
hash_to_score = {row[0]: row[1:] for row in cursor.fetchall()} hash_to_score = {row[0]: row[1:] for row in cursor.fetchall()}
for diff in self.tja.metadata.course_data: for diff in self.parser.metadata.course_data:
if diff not in self.hash: if diff not in self.hash:
continue continue
diff_hash = self.hash[diff] diff_hash = self.hash[diff]
@@ -253,7 +250,6 @@ class SongBox(BaseBox):
def update(self, current_time: float, is_diff_select: bool): def update(self, current_time: float, is_diff_select: bool):
super().update(current_time, is_diff_select) super().update(current_time, is_diff_select)
is_open_prev = self.is_open is_open_prev = self.is_open
self.move_box(current_time)
self.is_open = self.position == BOX_CENTER self.is_open = self.position == BOX_CENTER
if self.yellow_box is not None: if self.yellow_box is not None:
@@ -266,7 +262,7 @@ class SongBox(BaseBox):
self.score_history = ScoreHistory(self.scores, current_time) self.score_history = ScoreHistory(self.scores, current_time)
if not is_open_prev and self.is_open: if not is_open_prev and self.is_open:
self.yellow_box = YellowBox(False, tja=self.tja) self.yellow_box = YellowBox(False, tja=self.parser)
self.yellow_box.create_anim() self.yellow_box.create_anim()
self.wait = current_time self.wait = current_time
if current_time >= self.history_wait + 3000: if current_time >= self.history_wait + 3000:
@@ -280,7 +276,7 @@ class SongBox(BaseBox):
self.name.draw(outline_color=self.fore_color, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+tex.skin_config["song_box_name"].y, y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override) self.name.draw(outline_color=self.fore_color, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+tex.skin_config["song_box_name"].y, y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override)
if self.tja.ex_data.new: if self.parser.ex_data.new:
tex.draw_texture('yellow_box', 'ex_data_new_song_balloon', x=x, y=y, fade=outer_fade_override) tex.draw_texture('yellow_box', 'ex_data_new_song_balloon', x=x, y=y, fade=outer_fade_override)
valid_scores = {k: v for k, v in self.scores.items() if v is not None} valid_scores = {k: v for k, v in self.scores.items() if v is not None}
if valid_scores: if valid_scores:
@@ -302,6 +298,46 @@ class SongBox(BaseBox):
if self.score_history is not None and get_current_ms() >= self.history_wait + 3000: if self.score_history is not None and get_current_ms() >= self.history_wait + 3000:
self.score_history.draw() self.score_history.draw()
class SongBoxOsu(SongBox):
def update(self, current_time: float, is_diff_select: bool):
super().update(current_time, is_diff_select)
is_open_prev = self.is_open
self.is_open = self.position == BOX_CENTER
if self.yellow_box is not None:
self.yellow_box.update(is_diff_select)
if self.history_wait == 0:
self.history_wait = current_time
if self.score_history is None and {k: v for k, v in self.scores.items() if v is not None}:
self.score_history = ScoreHistory(self.scores, current_time)
if not is_open_prev and self.is_open:
self.yellow_box = YellowBox(False)
self.yellow_box.create_anim()
self.wait = current_time
if current_time >= self.history_wait + 3000:
self.history_wait = current_time
if self.score_history is not None:
self.score_history.update(current_time)
def _draw_closed(self, x: float, y: float, outer_fade_override: float):
super()._draw_closed(x, y, outer_fade_override)
self.name.draw(outline_color=self.fore_color, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+tex.skin_config["song_box_name"].y, y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override)
valid_scores = {k: v for k, v in self.scores.items() if v is not None}
if valid_scores:
highest_key = max(valid_scores.keys())
score = self.scores[highest_key]
if score and score[5] == Crown.DFC:
tex.draw_texture('yellow_box', 'crown_dfc', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override)
elif score and score[5] == Crown.FC:
tex.draw_texture('yellow_box', 'crown_fc', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override)
elif score and score[5] >= Crown.CLEAR:
tex.draw_texture('yellow_box', 'crown_clear', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override)
class FolderBox(BaseBox): class FolderBox(BaseBox):
def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, genre_index: GenreIndex, tja_count: int = 0, box_texture: Optional[str] = None): def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, genre_index: GenreIndex, tja_count: int = 0, box_texture: Optional[str] = None):
super().__init__(name, back_color, fore_color, texture_index) super().__init__(name, back_color, fore_color, texture_index)
@@ -324,7 +360,6 @@ class FolderBox(BaseBox):
def update(self, current_time: float, is_diff_select: bool): def update(self, current_time: float, is_diff_select: bool):
super().update(current_time, is_diff_select) super().update(current_time, is_diff_select)
is_open_prev = self.is_open is_open_prev = self.is_open
self.move_box(current_time)
self.is_open = self.position == BOX_CENTER self.is_open = self.position == BOX_CENTER
if not is_open_prev and self.is_open: if not is_open_prev and self.is_open:
@@ -393,12 +428,20 @@ class FolderBox(BaseBox):
tex.draw_texture('yellow_box', 'song_count_songs', color=color) tex.draw_texture('yellow_box', 'song_count_songs', color=color)
dest_width = min(tex.skin_config["song_tja_count"].width, self.tja_count_text.texture.width) dest_width = min(tex.skin_config["song_tja_count"].width, self.tja_count_text.texture.width)
self.tja_count_text.draw(outline_color=ray.BLACK, x=tex.skin_config["song_tja_count"].x - (dest_width//2), y=tex.skin_config["song_tja_count"].y, x2=dest_width-self.tja_count_text.texture.width, color=color) self.tja_count_text.draw(outline_color=ray.BLACK, x=tex.skin_config["song_tja_count"].x - (dest_width//2), y=tex.skin_config["song_tja_count"].y, x2=dest_width-self.tja_count_text.texture.width, color=color)
if self.texture_index != TextureIndex.DEFAULT: if self.texture_index != TextureIndex.DEFAULT and self.box_texture is None:
tex.draw_texture('box', 'folder_graphic', color=color, frame=self.genre_index) tex.draw_texture('box', 'folder_graphic', color=color, frame=self.genre_index)
tex.draw_texture('box', 'folder_text', color=color, frame=self.genre_index) tex.draw_texture('box', 'folder_text', color=color, frame=self.genre_index)
elif self.box_texture is not None: elif self.box_texture is not None:
scaled_width = self.box_texture.width * tex.screen_scale scaled_width = self.box_texture.width * tex.screen_scale
scaled_height = self.box_texture.height * tex.screen_scale scaled_height = self.box_texture.height * tex.screen_scale
max_width = 344 * tex.screen_scale
max_height = 424 * tex.screen_scale
if scaled_width > max_width or scaled_height > max_height:
width_scale = max_width / scaled_width
height_scale = max_height / scaled_height
scale_factor = min(width_scale, height_scale)
scaled_width *= scale_factor
scaled_height *= scale_factor
x = int((x + tex.skin_config["box_texture"].x) - (scaled_width // 2)) x = int((x + tex.skin_config["box_texture"].x) - (scaled_width // 2))
y = int((y + tex.skin_config["box_texture"].y) - (scaled_height // 2)) y = int((y + tex.skin_config["box_texture"].y) - (scaled_height // 2))
src = ray.Rectangle(0, 0, self.box_texture.width, self.box_texture.height) src = ray.Rectangle(0, 0, self.box_texture.width, self.box_texture.height)
@@ -407,7 +450,7 @@ class FolderBox(BaseBox):
class YellowBox: class YellowBox:
"""A song box when it is opened.""" """A song box when it is opened."""
def __init__(self, is_back: bool, tja: Optional[TJAParser] = None, is_dan: bool = False): def __init__(self, is_back: bool, tja: Optional[TJAParser | OsuParser] = None, is_dan: bool = False):
self.is_diff_select = False self.is_diff_select = False
self.is_back = is_back self.is_back = is_back
self.tja = tja self.tja = tja
@@ -652,7 +695,6 @@ class DanBox(BaseBox):
def update(self, current_time: float, is_diff_select: bool): def update(self, current_time: float, is_diff_select: bool):
super().update(current_time, is_diff_select) super().update(current_time, is_diff_select)
is_open_prev = self.is_open is_open_prev = self.is_open
self.move_box(current_time)
self.is_open = self.position == BOX_CENTER self.is_open = self.position == BOX_CENTER
if not is_open_prev and self.is_open: if not is_open_prev and self.is_open:
self.yellow_box = YellowBox(False, is_dan=True) self.yellow_box = YellowBox(False, is_dan=True)
@@ -781,7 +823,8 @@ class GenreBG:
if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK: if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK:
ray.begin_shader_mode(self.shader) ray.begin_shader_mode(self.shader)
offset = (tex.skin_config["genre_bg_offset"].x * -1) if self.start_box.is_open else 0 offset = (tex.skin_config["genre_bg_offset"].x * -1) if self.start_box.is_open else 0
if (344 * tex.screen_scale < self.start_box.position < 594 * tex.screen_scale):
offset = -self.start_position + 444 * tex.screen_scale
tex.draw_texture('box', 'folder_background_edge', frame=self.end_box.texture_index, x=self.start_position+offset, y=y, mirror="horizontal", fade=self.fade_in.attribute) tex.draw_texture('box', 'folder_background_edge', frame=self.end_box.texture_index, x=self.start_position+offset, y=y, mirror="horizontal", fade=self.fade_in.attribute)
@@ -803,6 +846,8 @@ class GenreBG:
tex.draw_texture('box', 'folder_background', x=tex.skin_config["genre_bg_folder_background"].x, y=y, x2=x2, frame=self.end_box.texture_index) tex.draw_texture('box', 'folder_background', x=tex.skin_config["genre_bg_folder_background"].x, y=y, x2=x2, frame=self.end_box.texture_index)
if (594 * tex.screen_scale < self.end_box.position < 844 * tex.screen_scale):
offset = -self.end_position + 674 * tex.screen_scale
offset = tex.skin_config["genre_bg_offset"].x if self.end_box.is_open else 0 offset = tex.skin_config["genre_bg_offset"].x if self.end_box.is_open else 0
tex.draw_texture('box', 'folder_background_edge', x=self.end_position+tex.skin_config["genre_bg_folder_edge"].x+offset, y=y, fade=self.fade_in.attribute, frame=self.end_box.texture_index) tex.draw_texture('box', 'folder_background_edge', x=self.end_position+tex.skin_config["genre_bg_folder_edge"].x+offset, y=y, fade=self.fade_in.attribute, frame=self.end_box.texture_index)
@@ -861,13 +906,11 @@ class ScoreHistory:
tex.draw_texture('leaderboard', 'shinuchi_ura', index=self.long) tex.draw_texture('leaderboard', 'shinuchi_ura', index=self.long)
else: else:
tex.draw_texture('leaderboard', 'shinuchi', index=self.long) tex.draw_texture('leaderboard', 'shinuchi', index=self.long)
case ScoreMethod.GEN3:
if self.curr_difficulty == Difficulty.URA:
tex.draw_texture('leaderboard', 'normal', index=self.long)
else:
tex.draw_texture('leaderboard', 'normal', index=self.long)
tex.draw_texture('leaderboard', 'pts', color=ray.WHITE, index=self.long) tex.draw_texture('leaderboard', 'pts', color=ray.WHITE, index=self.long)
case ScoreMethod.GEN3:
tex.draw_texture('leaderboard', 'normal', index=self.long)
tex.draw_texture('leaderboard', 'pts', color=ray.BLACK, index=self.long)
tex.draw_texture('leaderboard', 'difficulty', frame=self.curr_difficulty, index=self.long) tex.draw_texture('leaderboard', 'difficulty', frame=self.curr_difficulty, index=self.long)
for i in range(4): for i in range(4):
@@ -887,7 +930,11 @@ class ScoreHistory:
margin = tex.skin_config["score_info_counter_margin"].x margin = tex.skin_config["score_info_counter_margin"].x
for i in range(len(counter)): for i in range(len(counter)):
if j == 0: if j == 0:
match global_data.config["general"]["score_method"]:
case ScoreMethod.SHINUCHI:
tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-((len(counter) * tex.skin_config["score_info_counter_margin"].width) // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=ray.WHITE, index=self.long) tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-((len(counter) * tex.skin_config["score_info_counter_margin"].width) // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=ray.WHITE, index=self.long)
case ScoreMethod.GEN3:
tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-((len(counter) * tex.skin_config["score_info_counter_margin"].width) // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=ray.BLACK, index=self.long)
else: else:
tex.draw_texture('leaderboard', 'judge_num', frame=int(counter[i]), x=-(len(counter) - i) * margin, y=j*tex.skin_config["score_info_bg_offset"].y) tex.draw_texture('leaderboard', 'judge_num', frame=int(counter[i]), x=-(len(counter) - i) * margin, y=j*tex.skin_config["score_info_bg_offset"].y)
@@ -1040,7 +1087,8 @@ class Directory(FileSystemItem):
'RECENT', 'RECENT',
'FAVORITE', 'FAVORITE',
'DIFFICULTY', 'DIFFICULTY',
'RECOMMENDED' 'RECOMMENDED',
'SEARCH'
] ]
def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, genre_index: GenreIndex, has_box_def=False, to_root=False, back=False, tja_count=0, box_texture=None, collection=None): def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, genre_index: GenreIndex, has_box_def=False, to_root=False, back=False, tja_count=0, box_texture=None, collection=None):
super().__init__(path, name) super().__init__(path, name)
@@ -1062,12 +1110,23 @@ class SongFile(FileSystemItem):
def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex): def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex):
super().__init__(path, name) super().__init__(path, name)
self.is_recent = (datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)) <= timedelta(days=7) self.is_recent = (datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)) <= timedelta(days=7)
self.tja = TJAParser(path) self.parser = TJAParser(path)
if self.is_recent: if self.is_recent:
self.tja.ex_data.new = True self.parser.ex_data.new = True
title = self.tja.metadata.title.get(global_data.config['general']['language'].lower(), self.tja.metadata.title['en']) title = self.parser.metadata.title.get(global_data.config['general']['language'].lower(), self.parser.metadata.title['en'])
self.hash = global_data.song_paths[path] self.hash = global_data.song_paths[path]
self.box = SongBox(title, back_color, fore_color, texture_index, self.tja) self.box = SongBox(title, back_color, fore_color, texture_index, self.parser)
self.box.hash = global_data.song_hashes[self.hash][0]["diff_hashes"]
self.box.get_scores()
class SongFileOsu(FileSystemItem):
def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex):
super().__init__(path, name)
self.is_recent = (datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)) <= timedelta(days=7)
self.parser = OsuParser(path)
title = self.parser.osu_metadata["Version"]
self.hash = global_data.song_paths[path]
self.box = SongBoxOsu(title, back_color, fore_color, texture_index, self.parser)
self.box.hash = global_data.song_hashes[self.hash][0]["diff_hashes"] self.box.hash = global_data.song_hashes[self.hash][0]["diff_hashes"]
self.box.get_scores() self.box.get_scores()
@@ -1123,7 +1182,7 @@ class FileNavigator:
# Pre-generated objects storage # Pre-generated objects storage
self.all_directories: dict[str, Directory] = {} # path -> Directory self.all_directories: dict[str, Directory] = {} # path -> Directory
self.all_song_files: dict[str, Union[SongFile, DanCourse]] = {} # path -> SongFile self.all_song_files: dict[str, Union[SongFile, DanCourse, SongFileOsu]] = {} # path -> SongFile
self.directory_contents: dict[str, list[Union[Directory, SongFile]]] = {} # path -> list of items self.directory_contents: dict[str, list[Union[Directory, SongFile]]] = {} # path -> list of items
# OPTION 2: Lazy crown calculation with caching # OPTION 2: Lazy crown calculation with caching
@@ -1145,6 +1204,7 @@ class FileNavigator:
self.genre_bg = None self.genre_bg = None
self.song_count = 0 self.song_count = 0
self.in_dan_select = False self.in_dan_select = False
self.current_search = ''
logger.info("FileNavigator initialized") logger.info("FileNavigator initialized")
def initialize(self, root_dirs: list[Path]): def initialize(self, root_dirs: list[Path]):
@@ -1262,6 +1322,10 @@ class FileNavigator:
child_dirs = [] child_dirs = []
for item_path in dir_path.iterdir(): for item_path in dir_path.iterdir():
if item_path.is_dir(): if item_path.is_dir():
child_has_osu = any(item_path.glob("*.osu"))
if child_has_osu:
child_dirs.append(item_path)
self.process_osz(item_path)
child_has_box_def = (item_path / "box.def").exists() child_has_box_def = (item_path / "box.def").exists()
if child_has_box_def: if child_has_box_def:
child_dirs.append(item_path) child_dirs.append(item_path)
@@ -1289,8 +1353,8 @@ class FileNavigator:
elif song_key not in self.all_song_files and tja_path in global_data.song_paths: elif song_key not in self.all_song_files and tja_path in global_data.song_paths:
song_obj = SongFile(tja_path, tja_path.name, back_color, fore_color, texture_index) song_obj = SongFile(tja_path, tja_path.name, back_color, fore_color, texture_index)
song_obj.box.get_scores() song_obj.box.get_scores()
for course in song_obj.tja.metadata.course_data: for course in song_obj.parser.metadata.course_data:
level = song_obj.tja.metadata.course_data[course].level level = song_obj.parser.metadata.course_data[course].level
scores = song_obj.box.scores.get(course) scores = song_obj.box.scores.get(course)
if scores is not None: if scores is not None:
@@ -1342,10 +1406,149 @@ class FileNavigator:
logger.error(f"Error creating SongFile for {tja_path}: {e}") logger.error(f"Error creating SongFile for {tja_path}: {e}")
continue continue
def process_osz(self, dir_path: Path):
dir_key = str(dir_path)
if dir_path.iterdir():
name = dir_path.name
for file in dir_path.iterdir():
if file.name.endswith('.osu'):
with open(file, 'r', encoding='utf-8') as f:
content = f.readlines()
for line in content:
if line.startswith('TitleUnicode:'):
title_unicode = line.split(':', 1)[1].strip()
name = title_unicode
break
else:
name = dir_path.name if dir_path.name else str(dir_path)
box_texture = None
collection = None
back_color = None
fore_color = None
texture_index = TextureIndex.DEFAULT
genre_index = GenreIndex.DEFAULT
for file in dir_path.iterdir():
if file.name.endswith('.jpg') or file.name.endswith('.png'):
box_texture = str(file)
# Create Directory object
file_count = len([file for file in dir_path.glob("*.osu")])
directory_obj = Directory(
dir_path, name, back_color, fore_color, texture_index, genre_index,
tja_count=file_count,
box_texture=box_texture,
collection=collection,
)
self.all_directories[dir_key] = directory_obj
content_items = []
osu_files = [file for file in dir_path.glob("*.osu")]
# Create SongFile objects
for osu_path in sorted(osu_files):
song_key = str(osu_path)
if song_key not in self.all_song_files and osu_path in global_data.song_paths:
song_obj = SongFileOsu(osu_path, osu_path.name, back_color, fore_color, texture_index)
song_obj.box.get_scores()
self.song_count += 1
global_data.song_progress = self.song_count / global_data.total_songs
self.all_song_files[song_key] = song_obj
if song_key in self.all_song_files:
content_items.append(self.all_song_files[song_key])
self.directory_contents[dir_key] = content_items
def is_at_root(self) -> bool: def is_at_root(self) -> bool:
"""Check if currently at the virtual root""" """Check if currently at the virtual root"""
return self.current_dir == Path() return self.current_dir == Path()
def load_new_items(self, selected_item, dir_key: str):
return self.new_items
def load_recent_items(self, selected_item, dir_key: str):
if self.recent_folder is None:
raise Exception("tried to enter recent folder without recents")
self._generate_objects_recursive(self.recent_folder.path)
if not isinstance(selected_item.box, BackBox):
selected_item.box.tja_count = self._count_tja_files(self.recent_folder.path)
return self.directory_contents[dir_key]
def load_favorite_items(self, selected_item, dir_key: str):
if self.favorite_folder is None:
raise Exception("tried to enter favorite folder without favorites")
self._generate_objects_recursive(self.favorite_folder.path)
tja_files = self._get_tja_files_for_directory(self.favorite_folder.path)
self._calculate_directory_crowns(dir_key, tja_files)
if not isinstance(selected_item.box, BackBox):
selected_item.box.tja_count = self._count_tja_files(self.favorite_folder.path)
self.in_favorites = True
return self.directory_contents[dir_key]
def load_diff_sort_items(self, selected_item, dir_key: str):
content_items = []
parent_dir = selected_item.path.parent
for sibling_path in parent_dir.iterdir():
if sibling_path.is_dir() and sibling_path != selected_item.path:
sibling_key = str(sibling_path)
if sibling_key in self.directory_contents:
for item in self.directory_contents[sibling_key]:
if isinstance(item, SongFile) and item:
if self.diff_sort_diff in item.parser.metadata.course_data and item.parser.metadata.course_data[self.diff_sort_diff].level == self.diff_sort_level:
if item not in content_items:
content_items.append(item)
return content_items
def load_recommended_items(self, selected_item, dir_key: str):
parent_dir = selected_item.path.parent
temp_items = []
for sibling_path in parent_dir.iterdir():
if sibling_path.is_dir() and sibling_path != selected_item.path:
sibling_key = str(sibling_path)
if sibling_key in self.directory_contents:
for item in self.directory_contents[sibling_key]:
if not isinstance(item, Directory) and isinstance(item, SongFile):
temp_items.append(item)
return random.sample(temp_items, min(10, len(temp_items)))
def _levenshtein_distance(self, s1: str, s2: str):
# Create a matrix to store distances
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Initialize base cases
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
# Fill the matrix
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] # No operation needed
else:
dp[i][j] = 1 + min(
dp[i-1][j], # Deletion
dp[i][j-1], # Insertion
dp[i-1][j-1] # Substitution
)
return dp[m][n]
def search_song(self, search_name: str):
items = []
for path, song in self.all_song_files.items():
if self._levenshtein_distance(song.name[:-4].lower(), search_name.lower()) < 2:
items.append(song)
if isinstance(song, SongFile):
if self._levenshtein_distance(song.parser.metadata.subtitle["en"].lower(), search_name.lower()) < 2:
items.append(song)
return items
def load_current_directory(self, selected_item: Optional[Directory] = None): def load_current_directory(self, selected_item: Optional[Directory] = None):
"""Load pre-generated items for the current directory (unified for root and subdirs)""" """Load pre-generated items for the current directory (unified for root and subdirs)"""
dir_key = str(self.current_dir) dir_key = str(self.current_dir)
@@ -1383,47 +1586,17 @@ class FileNavigator:
# Handle special collections (same logic as before) # Handle special collections (same logic as before)
if isinstance(selected_item, Directory): if isinstance(selected_item, Directory):
if selected_item.collection == Directory.COLLECTIONS[0]: if selected_item.collection == Directory.COLLECTIONS[0]:
content_items = self.new_items content_items = self.load_new_items(selected_item, dir_key)
elif selected_item.collection == Directory.COLLECTIONS[1]: elif selected_item.collection == Directory.COLLECTIONS[1]:
if self.recent_folder is None: content_items = self.load_recent_items(selected_item, dir_key)
raise Exception("tried to enter recent folder without recents")
self._generate_objects_recursive(self.recent_folder.path)
if not isinstance(selected_item.box, BackBox):
selected_item.box.tja_count = self._count_tja_files(self.recent_folder.path)
content_items = self.directory_contents[dir_key]
elif selected_item.collection == Directory.COLLECTIONS[2]: elif selected_item.collection == Directory.COLLECTIONS[2]:
if self.favorite_folder is None: content_items = self.load_favorite_items(selected_item, dir_key)
raise Exception("tried to enter favorite folder without favorites")
self._generate_objects_recursive(self.favorite_folder.path)
tja_files = self._get_tja_files_for_directory(self.favorite_folder.path)
self._calculate_directory_crowns(dir_key, tja_files)
if not isinstance(selected_item.box, BackBox):
selected_item.box.tja_count = self._count_tja_files(self.favorite_folder.path)
content_items = self.directory_contents[dir_key]
self.in_favorites = True
elif selected_item.collection == Directory.COLLECTIONS[3]: elif selected_item.collection == Directory.COLLECTIONS[3]:
content_items = [] content_items = self.load_diff_sort_items(selected_item, dir_key)
parent_dir = selected_item.path.parent
for sibling_path in parent_dir.iterdir():
if sibling_path.is_dir() and sibling_path != selected_item.path:
sibling_key = str(sibling_path)
if sibling_key in self.directory_contents:
for item in self.directory_contents[sibling_key]:
if isinstance(item, SongFile) and item:
if self.diff_sort_diff in item.tja.metadata.course_data and item.tja.metadata.course_data[self.diff_sort_diff].level == self.diff_sort_level:
if item not in content_items:
content_items.append(item)
elif selected_item.collection == Directory.COLLECTIONS[4]: elif selected_item.collection == Directory.COLLECTIONS[4]:
parent_dir = selected_item.path.parent content_items = self.load_recommended_items(selected_item, dir_key)
temp_items = [] elif selected_item.collection == Directory.COLLECTIONS[5]:
for sibling_path in parent_dir.iterdir(): content_items = self.search_song(self.current_search)
if sibling_path.is_dir() and sibling_path != selected_item.path:
sibling_key = str(sibling_path)
if sibling_key in self.directory_contents:
for item in self.directory_contents[sibling_key]:
if not isinstance(item, Directory) and isinstance(item, SongFile):
temp_items.append(item)
content_items = random.sample(temp_items, min(10, len(temp_items)))
if content_items == []: if content_items == []:
self.go_back() self.go_back()
@@ -1494,7 +1667,7 @@ class FileNavigator:
# Save current state to history # Save current state to history
self.history.append((self.current_dir, self.selected_index)) self.history.append((self.current_dir, self.selected_index))
self.current_dir = selected_item.path self.current_dir = selected_item.path
logger.info(f"Entered Directory {selected_item.path}") logger.info(f"Entered Directory {selected_item.path} at index {self.selected_index}")
self.load_current_directory(selected_item=selected_item) self.load_current_directory(selected_item=selected_item)
@@ -1730,7 +1903,7 @@ class FileNavigator:
else: else:
box.draw(box.position + int(move_away_attribute), tex.skin_config["boxes"].y, is_ura, inner_fade_override=diff_fade_out_attribute, outer_fade_override=fade) box.draw(box.position + int(move_away_attribute), tex.skin_config["boxes"].y, is_ura, inner_fade_override=diff_fade_out_attribute, outer_fade_override=fade)
def mark_crowns_dirty_for_song(self, song_file: SongFile): def mark_crowns_dirty_for_song(self, song_file: SongFile | SongFileOsu):
"""Mark directories as needing crown recalculation when a song's score changes""" """Mark directories as needing crown recalculation when a song's score changes"""
song_path = song_file.path song_path = song_file.path
@@ -1744,19 +1917,19 @@ class FileNavigator:
def navigate_left(self): def navigate_left(self):
"""Move selection left with wrap-around""" """Move selection left with wrap-around"""
if self.items: if self.items:
if self.items[0].box.move is not None and not self.items[0].box.move.is_finished:
return
self.selected_index = (self.selected_index - 1) % len(self.items) self.selected_index = (self.selected_index - 1) % len(self.items)
self.calculate_box_positions() self.calculate_box_positions()
for item in self.items:
item.box.move_box(1)
logger.info(f"Moved Left to {self.items[self.selected_index].path}") logger.info(f"Moved Left to {self.items[self.selected_index].path}")
def navigate_right(self): def navigate_right(self):
"""Move selection right with wrap-around""" """Move selection right with wrap-around"""
if self.items: if self.items:
if self.items[0].box.move is not None and not self.items[0].box.move.is_finished:
return
self.selected_index = (self.selected_index + 1) % len(self.items) self.selected_index = (self.selected_index + 1) % len(self.items)
self.calculate_box_positions() self.calculate_box_positions()
for item in self.items:
item.box.move_box(-1)
logger.info(f"Moved Right to {self.items[self.selected_index].path}") logger.info(f"Moved Right to {self.items[self.selected_index].path}")
def skip_left(self): def skip_left(self):
@@ -1793,7 +1966,7 @@ class FileNavigator:
return return
recents_path = self.recent_folder.path / 'song_list.txt' recents_path = self.recent_folder.path / 'song_list.txt'
new_entry = f'{song.hash}|{song.tja.metadata.title["en"]}|{song.tja.metadata.subtitle["en"]}\n' new_entry = f'{song.hash}|{song.parser.metadata.title["en"]}|{song.parser.metadata.subtitle["en"]}\n'
existing_entries = [] existing_entries = []
if recents_path.exists(): if recents_path.exists():
with open(recents_path, 'r', encoding='utf-8-sig') as song_list: with open(recents_path, 'r', encoding='utf-8-sig') as song_list:
@@ -1804,7 +1977,7 @@ class FileNavigator:
with open(recents_path, 'w', encoding='utf-8-sig') as song_list: with open(recents_path, 'w', encoding='utf-8-sig') as song_list:
song_list.writelines(recent_entries) song_list.writelines(recent_entries)
logger.info(f"Added Recent: {song.hash} {song.tja.metadata.title['en']} {song.tja.metadata.subtitle['en']}") logger.info(f"Added Recent: {song.hash} {song.parser.metadata.title['en']} {song.parser.metadata.subtitle['en']}")
def add_favorite(self) -> bool: def add_favorite(self) -> bool:
"""Add the current song to the favorites list""" """Add the current song to the favorites list"""
@@ -1823,7 +1996,7 @@ class FileNavigator:
if not line: # Skip empty lines if not line: # Skip empty lines
continue continue
hash, title, subtitle = line.split('|') hash, title, subtitle = line.split('|')
if song.hash == hash or (song.tja.metadata.title['en'] == title and song.tja.metadata.subtitle['en'] == subtitle): if song.hash == hash or (song.parser.metadata.title['en'] == title and song.parser.metadata.subtitle['en'] == subtitle):
if not self.in_favorites: if not self.in_favorites:
return False return False
else: else:
@@ -1832,11 +2005,11 @@ class FileNavigator:
with open(favorites_path, 'w', encoding='utf-8-sig') as song_list: with open(favorites_path, 'w', encoding='utf-8-sig') as song_list:
for line in lines: for line in lines:
song_list.write(line + '\n') song_list.write(line + '\n')
logger.info(f"Removed Favorite: {song.hash} {song.tja.metadata.title['en']} {song.tja.metadata.subtitle['en']}") logger.info(f"Removed Favorite: {song.hash} {song.parser.metadata.title['en']} {song.parser.metadata.subtitle['en']}")
else: else:
with open(favorites_path, 'a', encoding='utf-8-sig') as song_list: with open(favorites_path, 'a', encoding='utf-8-sig') as song_list:
song_list.write(f'{song.hash}|{song.tja.metadata.title["en"]}|{song.tja.metadata.subtitle["en"]}\n') song_list.write(f'{song.hash}|{song.parser.metadata.title["en"]}|{song.parser.metadata.subtitle["en"]}\n')
logger.info(f"Added Favorite: {song.hash} {song.tja.metadata.title['en']} {song.tja.metadata.subtitle['en']}") logger.info(f"Added Favorite: {song.hash} {song.parser.metadata.title['en']} {song.parser.metadata.subtitle['en']}")
return True return True
navigator = FileNavigator() navigator = FileNavigator()

View File

@@ -7,12 +7,14 @@ import pyray as ray
from libs.config import Config from libs.config import Config
class PlayerNum(IntEnum): class PlayerNum(IntEnum):
ALL = 0 ALL = 0
P1 = 1 P1 = 1
P2 = 2 P2 = 2
TWO_PLAYER = 3 TWO_PLAYER = 3
DAN = 4 DAN = 4
AI = 5
class ScoreMethod(): class ScoreMethod():
GEN3 = "gen3" GEN3 = "gen3"
@@ -43,6 +45,7 @@ class Modifiers:
display: bool = False display: bool = False
inverse: bool = False inverse: bool = False
random: int = 0 random: int = 0
subdiff: int = 0
@dataclass @dataclass
class DanResultSong: class DanResultSong:

View File

@@ -1,11 +1,12 @@
from enum import Enum from enum import Enum
from typing import Callable from typing import Callable
import pyray as ray import pyray as ray
from libs.global_data import PlayerNum
from libs.utils import OutlinedText, global_tex
from libs.config import get_config
from libs.audio import audio from libs.audio import audio
from libs.config import get_config
from libs.global_data import PlayerNum, global_data
from libs.utils import OutlinedText, global_tex
class Nameplate: class Nameplate:
@@ -57,6 +58,9 @@ class Nameplate:
""" """
tex = global_tex tex = global_tex
tex.draw_texture('nameplate', 'shadow', x=x, y=y, fade=min(0.5, fade)) tex.draw_texture('nameplate', 'shadow', x=x, y=y, fade=min(0.5, fade))
if self.player_num == PlayerNum.AI:
tex.draw_texture('nameplate', 'ai', x=x, y=y)
return
if self.player_num == 0: if self.player_num == 0:
frame = 2 frame = 2
title_offset = 0 title_offset = 0
@@ -98,6 +102,7 @@ class Indicator:
self.don_fade = global_tex.get_animation(6) self.don_fade = global_tex.get_animation(6)
self.blue_arrow_move = global_tex.get_animation(7) self.blue_arrow_move = global_tex.get_animation(7)
self.blue_arrow_fade = global_tex.get_animation(8) self.blue_arrow_fade = global_tex.get_animation(8)
self.select_text = OutlinedText(global_tex.skin_config["indicator_text"].text[global_data.config["general"]["language"]], global_tex.skin_config["indicator_text"].font_size, ray.WHITE, spacing=-3)
def update(self, current_time_ms: float): def update(self, current_time_ms: float):
"""Update the indicator's animations.""" """Update the indicator's animations."""
@@ -109,7 +114,8 @@ class Indicator:
"""Draw the indicator at the given position with the given fade.""" """Draw the indicator at the given position with the given fade."""
tex = global_tex tex = global_tex
tex.draw_texture('indicator', 'background', x=x, y=y, fade=fade) tex.draw_texture('indicator', 'background', x=x, y=y, fade=fade)
tex.draw_texture('indicator', 'text', frame=self.state.value, x=x, y=y, fade=fade) tex.draw_texture('indicator', 'text', frame=self.state.value, x=x, y=y, fade=fade, color=ray.BLACK)
self.select_text.draw(ray.BLANK, x=x+global_tex.skin_config["indicator_text"].x, y=y, fade=fade)
tex.draw_texture('indicator', 'drum_face', index=self.state.value, x=x, y=y, fade=fade) tex.draw_texture('indicator', 'drum_face', index=self.state.value, x=x, y=y, fade=fade)
if self.state == Indicator.State.SELECT: if self.state == Indicator.State.SELECT:
tex.draw_texture('indicator', 'drum_kat', fade=min(fade, self.don_fade.attribute), x=x, y=y) tex.draw_texture('indicator', 'drum_kat', fade=min(fade, self.don_fade.attribute), x=x, y=y)
@@ -127,15 +133,14 @@ class CoinOverlay:
"""Coin overlay for the game.""" """Coin overlay for the game."""
def __init__(self): def __init__(self):
"""Initialize the coin overlay.""" """Initialize the coin overlay."""
pass self.free_play = OutlinedText(global_tex.skin_config["free_play"].text[global_data.config["general"]["language"]], global_tex.skin_config["free_play"].font_size, ray.WHITE, spacing=5, outline_thickness=4)
def update(self, current_time_ms: float): def update(self, current_time_ms: float):
"""Update the coin overlay. Unimplemented""" """Update the coin overlay. Unimplemented"""
pass pass
def draw(self, x: int = 0, y: int = 0): def draw(self, x: int = 0, y: int = 0):
"""Draw the coin overlay. """Draw the coin overlay.
Only draws free play for now.""" Only draws free play for now."""
tex = global_tex self.free_play.draw(ray.BLACK, x=global_tex.screen_width//2 - self.free_play.texture.width//2, y=global_tex.skin_config["free_play"].y)
tex.draw_texture('overlay', 'free_play', x=x, y=y)
class AllNetIcon: class AllNetIcon:
"""All.Net status icon for the game.""" """All.Net status icon for the game."""

311
libs/parsers/osz.py Normal file
View File

@@ -0,0 +1,311 @@
import hashlib
import math
from pathlib import Path
from libs.parsers.tja import CourseData, Note, NoteType, Drumroll, Balloon, NoteList, TJAEXData, TJAMetadata, TimelineObject
import re
class OsuParser:
general: dict[str, str]
editor: dict[str, str]
osu_metadata: dict[str, str]
difficulty: dict[str, str]
events: list[list[float]]
timing_points: list[list[float]]
hit_objects: list[list[float]]
bpm: list[float]
def __init__(self, osu_file: Path):
self.general = self.read_osu_data_dict(osu_file, target_header="General")
self.editor = self.read_osu_data_dict(osu_file, target_header="Editor")
self.osu_metadata = self.read_osu_data_dict(osu_file, target_header="Metadata")
self.difficulty = self.read_osu_data_dict(osu_file, target_header="Difficulty")
self.events = self.read_osu_data_list(osu_file, target_header="Events")
self.timing_points = self.read_osu_data_list(osu_file, target_header="TimingPoints")
#self.general = self.read_osu_data(osu_file, target_header="Colours", is_dict=True)
self.hit_objects = self.read_osu_data_list(osu_file, target_header="HitObjects")
self.slider_multiplier = float(self.difficulty["SliderMultiplier"])
self.metadata = TJAMetadata()
self.metadata.wave = osu_file.parent / self.general["AudioFilename"]
self.metadata.demostart = float(self.general["PreviewTime"]) / 1000
self.metadata.offset = -30/1000
self.metadata.title["en"] = self.osu_metadata["Version"]
self.metadata.subtitle["en"] = self.osu_metadata["Creator"]
match = re.search(r'\[Events\][\s\S]*?^[ \t]*(\d+),(\d+),"([^"]+)"', osu_file.read_text(encoding='utf-8'), re.MULTILINE)
if match:
self.metadata.bgmovie = osu_file.parent / Path(match.group(3))
self.metadata.course_data[0] = CourseData()
self.ex_data = TJAEXData()
self.bpm = []
for points in self.timing_points:
self.bpm.append(math.floor(1 / points[1] * 1000 * 60))
self.osu_NoteList = self.note_data_to_NoteList(self.hit_objects)
for points in self.timing_points:
if 0 < points[1] < 60000:
obj = TimelineObject()
obj.hit_ms = points[0]
obj.bpm = math.floor(1 / points[1] * 1000 * 60)
self.osu_NoteList[0].timeline.append(obj)
def read_osu_data_list(self, file_path: Path, target_header="HitObjects") -> list[list[float]]:
data = []
current_header = None
with file_path.open(mode='r', encoding='utf-8') as f:
for line in f:
line = line.rstrip("\n")
if re.match(r"\[\w*\]", line): # header pattern
current_header = line[1:-1]
if current_header == target_header:
if re.match(r"[-+]?\d*\.?\d+" , line): # Events, TimingPoints, HitObjects
string_array = re.findall(r"[-+]?\d*\.?\d+" , line) # search for floats
int_array = [float(num_str) for num_str in string_array]
data.append(int_array)
else:
continue
return data
def read_osu_data_dict(self, file_path: Path, target_header="HitObjects") -> dict[str, str]:
data = dict()
current_header = None
with file_path.open(mode='r', encoding='utf-8') as f:
for line in f:
line = line.rstrip("\n")
if re.match(r"\[\w*\]", line): # header pattern
current_header = line[1:-1]
if current_header == target_header:
if ':' in line and not line.startswith('['):
key, value = line.split(':', 1)
data[key.strip()] = value.strip()
else:
continue
return data
def get_scroll_multiplier(self, ms: float) -> float:
base_scroll = (1.0 if 1.37 <= self.slider_multiplier <= 1.47
else self.slider_multiplier / 1.40)
current_scroll = 1.0
for tp in self.timing_points:
time = tp[0]
beat_length = tp[1] # positive for BPM, negative for scroll
if time > ms:
break
if beat_length < 0: # This is an inherited (green) timing point
current_scroll = -100.0 / beat_length
return current_scroll * base_scroll
def note_data_to_NoteList(self, note_data) -> tuple[NoteList, list[NoteList], list[NoteList], list[NoteList]]:
osu_NoteList = NoteList()
counter = 0
for line in note_data:
note_time = line[2]
scroll = self.get_scroll_multiplier(note_time)
if (line[3] == 1 or line[3] == 4 or line[3] == 5 or line[3] == 6) and line[4] == 0: # DON
don = Note()
don.type = NoteType(1)
don.hit_ms = line[2]
don.bpm = self.bpm[0]
don.scroll_x = scroll
don.scroll_y = 0
don.display = True
don.index = counter
counter = counter + 1
don.moji = 1
osu_NoteList.play_notes.append(don)
if (line[3] == 1 or line[3] == 4 or line[3] == 5 or line[3] == 6) and (line[4] == 2 or line[4] == 8): # KAT
kat = Note()
kat.type = NoteType(2)
kat.hit_ms = line[2]
kat.bpm = self.bpm[0]
kat.scroll_x = scroll
kat.scroll_y = 0
kat.display = True
kat.index = counter
counter = counter + 1
kat.moji = 4
osu_NoteList.play_notes.append(kat)
if (line[3] == 1 or line[3] == 4 or line[3] == 5 or line[3] == 6) and line[4] == 4: # L-DON
don = Note()
don.type = NoteType(3)
don.hit_ms = line[2]
don.bpm = self.bpm[0]
don.scroll_x = scroll
don.scroll_y = 0
don.display = True
don.index = counter
counter = counter + 1
don.moji = 5
osu_NoteList.play_notes.append(don)
if (line[3] == 1 or line[3] == 4 or line[3] == 5 or line[3] == 6) and (line[4] == 6 or line[4] == 12): # L-KAT
kat = Note()
kat.type = NoteType(4)
kat.hit_ms = line[2]
kat.bpm = self.bpm[0]
kat.scroll_x = scroll
kat.scroll_y = 0
kat.display = True
kat.index = counter
counter = counter + 1
kat.moji = 6
osu_NoteList.play_notes.append(kat)
if (line[3] == 2) and (line[4] == 0): # Drum Roll
if len(line) >= 9:
slider_time = line[8] / (float(self.difficulty["SliderMultiplier"]) * 100) * self.timing_points[0][1]
else:
slider_time = line[6] / (float(self.difficulty["SliderMultiplier"]) * 100) * self.timing_points[0][1]
source = Note()
source.type = NoteType(8)
source.hit_ms = line[2] + slider_time
source.bpm = self.bpm[0]
source.scroll_x = scroll
source.scroll_y = 0
source.display = True
# this is where the index would be if it wasn't a tail note
source.moji = 7
slider = Drumroll(source)
slider.color = 255
slider.type = NoteType(5)
slider.hit_ms = line[2]
slider.bpm = self.bpm[0]
slider.scroll_x = scroll
slider.scroll_y = 0
slider.display = True
slider.index = counter
slider.moji = 10
counter = counter + 1
source.index = counter
counter = counter + 1
osu_NoteList.play_notes.append(slider)
osu_NoteList.play_notes.append(source)
if (line[3] == 2) and (line[4] == 4): # L-Drum Roll
if len(line) >= 9:
slider_time = line[8] / (float(self.difficulty["SliderMultiplier"]) * 100) * self.timing_points[0][1]
else:
slider_time = line[6] / (float(self.difficulty["SliderMultiplier"]) * 100) * self.timing_points[0][1]
source = Note()
source.type = NoteType(8)
source.hit_ms = line[2] + slider_time
source.bpm = self.bpm[0]
source.scroll_x = scroll
source.scroll_y = 0
source.display = True
# this is where the index would be if it wasn't a tail note
source.moji = 8
slider = Drumroll(source)
slider.color = 255
slider.type = NoteType(6)
slider.hit_ms = line[2]
slider.bpm = self.bpm[0]
slider.scroll_x = scroll
slider.scroll_y = 0
slider.display = True
slider.index = counter
counter = counter + 1
source.index = counter
counter = counter + 1
osu_NoteList.play_notes.append(slider)
osu_NoteList.play_notes.append(source)
if (line[3] == 8): # Balloon
source = Note()
source.type = NoteType(8)
source.hit_ms = line[5]
source.bpm = self.bpm[0]
source.scroll_x = scroll
source.scroll_y = 0
source.display = True
#source.index = counter
#counter = counter + 1
source.moji = 9
balloon = Balloon(source)
balloon.type = NoteType(7)
balloon.hit_ms = line[2]
balloon.bpm = self.bpm[0]
balloon.scroll_x = scroll
balloon.scroll_y = 0
balloon.display = True
balloon.index = counter
counter = counter + 1
balloon.moji = 10
'''
od = int(self.difficulty["OverallDifficulty"])
# thank you https://github.com/IepIweidieng/osu2tja/blob/dev-iid/osu2tja/osu2tja.py
hit_multiplier = (5 - 2 * (5 - od) / 5 if od < 5
else 5 + 2.5 * (od - 5) / 5 if od > 5
else 5) * 1.65
'''
balloon.count = 20#int(max(1, (ret[-1][1] - ret[-2][1]) / 1000 * hit_multiplier))
# end of 'stolen' code
source.index = counter
counter = counter + 1
osu_NoteList.play_notes.append(balloon)
osu_NoteList.play_notes.append(source)
osu_NoteList.draw_notes = osu_NoteList.play_notes.copy()
return osu_NoteList, [], [], []
def notes_to_position(self, difficulty):
return self.osu_NoteList
def hash_note_data(self, notes: NoteList):
"""Hashes the note data for the given NoteList."""
n = hashlib.sha256()
list1 = notes.play_notes
list2 = notes.bars
merged: list[Note | Drumroll | Balloon] = []
i = 0
j = 0
while i < len(list1) and j < len(list2):
if list1[i] <= list2[j]:
merged.append(list1[i])
i += 1
else:
merged.append(list2[j])
j += 1
merged.extend(list1[i:])
merged.extend(list2[j:])
for item in merged:
n.update(item.get_hash().encode('utf-8'))
return n.hexdigest()

View File

@@ -1,10 +1,9 @@
from enum import IntEnum
import hashlib import hashlib
import math
import logging import logging
import math
import random import random
from collections import deque
from dataclasses import dataclass, field, fields from dataclasses import dataclass, field, fields
from enum import IntEnum
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -1148,7 +1147,6 @@ def modifier_inverse(notes: NoteList):
def modifier_random(notes: NoteList, value: int): def modifier_random(notes: NoteList, value: int):
"""Randomly modifies the type of the notes in the given NoteList. """Randomly modifies the type of the notes in the given NoteList.
value: 1 == kimagure, 2 == detarame""" value: 1 == kimagure, 2 == detarame"""
#value: 1 == kimagure, 2 == detarame
modded_notes = notes.play_notes.copy() modded_notes = notes.play_notes.copy()
percentage = int(len(modded_notes) / 5) * value percentage = int(len(modded_notes) / 5) * value
selected_notes = random.sample(range(len(modded_notes)), percentage) selected_notes = random.sample(range(len(modded_notes)), percentage)
@@ -1166,4 +1164,393 @@ def apply_modifiers(notes: NoteList, modifiers: Modifiers):
play_notes = modifier_inverse(notes) play_notes = modifier_inverse(notes)
play_notes = modifier_random(notes, modifiers.random) play_notes = modifier_random(notes, modifiers.random)
draw_notes, bars = modifier_speed(notes, modifiers.speed) draw_notes, bars = modifier_speed(notes, modifiers.speed)
return deque(play_notes), deque(draw_notes), deque(bars) play_notes = modifier_difficulty(notes, modifiers.subdiff)
draw_notes = modifier_difficulty(notes, modifiers.subdiff)
return play_notes, draw_notes, bars
class Interval(IntEnum):
UNKNOWN = 0
QUARTER = 1
EIGHTH = 2
TWELFTH = 3
SIXTEENTH = 4
TWENTYFOURTH = 6
THIRTYSECOND = 8
def modifier_difficulty(notes: NoteList, level: int):
"""Modifies notes based on difficulty level according to the difficulty table.
Args:
notes: The NoteList to modify
level: The numerical difficulty level (1-13)
Returns:
Modified list of notes
"""
# Levels with no changes: Easy (1), Normal (2-5), Hard (9), Oni (13)
if level in [0, 1, 2, 3, 4, 5, 9, 13]:
return notes.play_notes
modded_notes = notes.play_notes.copy()
# Helper function to calculate note interval category
def get_note_interval_type(interval_ms: float, bpm: float, time_sig: float = 4.0) -> Interval:
"""Classify note interval as 1/8, 1/16, 1/12, or 1/24 note."""
if bpm == 0:
return Interval.UNKNOWN
ms_per_measure = get_ms_per_measure(bpm, time_sig) / time_sig
tolerance = 15 # ms tolerance for timing classification
eighth_note = ms_per_measure / 8
sixteenth_note = ms_per_measure / 16
twelfth_note = ms_per_measure / 12
twentyfourth_note = ms_per_measure / 24
thirtysecond_note = ms_per_measure / 32
quarter_note = ms_per_measure / 4
if abs(interval_ms - eighth_note) < tolerance:
return Interval.EIGHTH
elif abs(interval_ms - sixteenth_note) < tolerance:
return Interval.SIXTEENTH
elif abs(interval_ms - twelfth_note) < tolerance:
return Interval.TWELFTH
elif abs(interval_ms - twentyfourth_note) < tolerance:
return Interval.TWENTYFOURTH
elif abs(interval_ms - thirtysecond_note) < tolerance:
return Interval.THIRTYSECOND
elif abs(interval_ms - quarter_note) < tolerance:
return Interval.QUARTER
return Interval.UNKNOWN
# Helper function to make notes single-color
def make_single_color(note_indices: list[int]):
"""Convert notes to single color (auto-detects majority color if not specified)."""
don_count = 0
kat_count = 0
for idx in note_indices:
if idx < len(modded_notes):
note_type = modded_notes[idx].type
if note_type in [NoteType.DON, NoteType.DON_L]:
don_count += 1
elif note_type in [NoteType.KAT, NoteType.KAT_L]:
kat_count += 1
# Use majority color, defaulting to DON if tied or no valid notes
color = NoteType.DON if don_count >= kat_count else NoteType.KAT
# Convert all notes to the determined color
for idx in note_indices:
if idx < len(modded_notes):
if modded_notes[idx].type in [NoteType.DON, NoteType.KAT]:
modded_notes[idx].type = color
elif modded_notes[idx].type in [NoteType.DON_L, NoteType.KAT_L]:
modded_notes[idx].type = NoteType.DON_L if color == NoteType.DON else NoteType.KAT_L
# Helper function to find note streams
def find_streams(interval_type: Interval) -> list[tuple[int, int]]:
"""Find consecutive notes with the given interval type.
Returns list of (start_index, length) tuples."""
streams = []
i = 0
while i < len(modded_notes) - 1:
if isinstance(modded_notes[i], (Drumroll, Balloon)):
i += 1
continue
stream_start = i
stream_length = 1
while i < len(modded_notes) - 1:
if isinstance(modded_notes[i + 1], (Drumroll, Balloon)):
break
interval = modded_notes[i + 1].hit_ms - modded_notes[i].hit_ms
note_type = get_note_interval_type(interval, modded_notes[i].bpm)
if note_type == interval_type:
stream_length += 1
i += 1
else:
break
if stream_length >= 2: # At least 2 notes to form a stream
streams.append((stream_start, stream_length))
i += 1
return streams
def find_2plus2_patterns(interval_type: Interval) -> list[int]:
"""Find 2+2 patterns with the given interval type.
A 2+2 pattern consists of:
- 2 notes with the specified interval between them
- A gap (size of the interval)
- 2 more notes with the specified interval between them
- A gap after (at least the size of the interval)
Returns list of starting indices for 2+2 patterns."""
patterns = []
i = 0
while i < len(modded_notes) - 3:
if isinstance(modded_notes[i], (Drumroll, Balloon)):
i += 1
continue
# Check if we have at least 4 notes ahead
valid_notes_ahead = 0
for j in range(i, min(i + 4, len(modded_notes))):
if not isinstance(modded_notes[j], (Drumroll, Balloon)):
valid_notes_ahead += 1
if valid_notes_ahead < 4:
i += 1
continue
# Get the next 3 valid note indices (total 4 notes including current)
note_indices = [i]
j = i + 1
while len(note_indices) < 4 and j < len(modded_notes):
if not isinstance(modded_notes[j], (Drumroll, Balloon)):
note_indices.append(j)
j += 1
if len(note_indices) < 4:
i += 1
continue
# Check intervals between the 4 notes
interval1 = modded_notes[note_indices[1]].hit_ms - modded_notes[note_indices[0]].hit_ms
interval2 = modded_notes[note_indices[2]].hit_ms - modded_notes[note_indices[1]].hit_ms
interval3 = modded_notes[note_indices[3]].hit_ms - modded_notes[note_indices[2]].hit_ms
type1 = get_note_interval_type(interval1, modded_notes[note_indices[0]].bpm)
type3 = get_note_interval_type(interval3, modded_notes[note_indices[2]].bpm)
# Check for 2+2 pattern:
# - First interval matches our target type (between notes 0 and 1)
# - Second interval is ~2x the target type (the gap, between notes 1 and 2)
# - Third interval matches our target type (between notes 2 and 3)
# - After the last note, there should be a gap (check next note)
if type1 == interval_type and type3 == interval_type:
# Check if middle interval is approximately 2x the note interval (represents the gap)
ms_per_measure = get_ms_per_measure(modded_notes[note_indices[0]].bpm, 4.0) / 4.0
target_interval = 0
if interval_type == Interval.SIXTEENTH:
target_interval = ms_per_measure / 16
elif interval_type == Interval.EIGHTH:
target_interval = ms_per_measure / 8
elif interval_type == Interval.TWELFTH:
target_interval = ms_per_measure / 12
elif interval_type == Interval.TWENTYFOURTH:
target_interval = ms_per_measure / 24
# The gap should be approximately 2x the note interval (with tolerance)
expected_gap = target_interval * 2
tolerance = 20 # ms tolerance for gap detection
if abs(interval2 - expected_gap) < tolerance:
# Check if there's a gap after the 4th note
if note_indices[3] + 1 < len(modded_notes):
if not isinstance(modded_notes[note_indices[3] + 1], (Drumroll, Balloon)):
interval_after = modded_notes[note_indices[3] + 1].hit_ms - modded_notes[note_indices[3]].hit_ms
type_after = get_note_interval_type(interval_after, modded_notes[note_indices[3]].bpm)
# Gap after should be at least the size of the interval
if interval_after >= target_interval * 1.5 or type_after != interval_type:
patterns.append(i)
else:
# End of notes, so pattern is valid
patterns.append(i)
i += 1
return patterns
# Level 6 (Hard): 1/8 note streams become single-color; 1/8 note triplets become 1/4 notes
if level == 6:
streams = find_streams(Interval.EIGHTH)
for start, length in streams:
if length == 3:
modded_notes[start + 1].type = NoteType.NONE
elif length > 3:
make_single_color(list(range(start, start + length)))
# Level 7 (Hard): 1/8 note 5-hit streams become 3-1 pattern; 7+ hits repeat 3-1-1 pattern
elif level == 7:
streams = find_streams(Interval.EIGHTH)
for start, length in streams:
if length == 5:
modded_notes[start + 3].type = NoteType.NONE
elif length >= 7:
idx = start
while idx < start + length:
idx += 3
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 1
# Level 8 (Hard): 1/16 note triplets become 1/8 notes; 1/16 note 5-hit streams become 3+1 or 2+2
elif level == 8:
streams = find_streams(Interval.SIXTEENTH)
for start, length in streams:
if length == 3:
modded_notes[start + 1].type = NoteType.NONE
elif length == 5:
#3+1 if start with don, 2+2 if start with kat
if modded_notes[start].type in [NoteType.DON, NoteType.DON_L]:
modded_notes[start + 3].type = NoteType.NONE
else:
modded_notes[start + 2].type = NoteType.NONE
# Level 10 (Oni):
# 1/16 note 5-hit streams become 3+1
# 1/16 note doubles become single-color
# 2+2 hits become 2+1 hits (annoying)
# 1/16 4+ hits become 8th doubles
# 1/24ths are removed
# 1/16th streams become triplet followed by interval below
elif level == 10:
streams = find_streams(Interval.THIRTYSECOND)
for start, length in streams:
idx = start + 1
while idx < start + length:
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 2
streams = find_streams(Interval.TWENTYFOURTH)
for start, length in streams:
idx = start + 1
while idx < start + length - 1:
if idx < len(modded_notes) and idx + 1 < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
modded_notes[idx + 1].type = NoteType.NONE
idx += 3
streams = find_streams(Interval.SIXTEENTH)
for start, length in streams:
if length == 2:
modded_notes[start].type = modded_notes[start + 1].type
if length == 3:
modded_notes[start + 1].type = NoteType.NONE
if length == 4 or length == 5:
modded_notes[start + 3].type = NoteType.NONE
make_single_color(list(range(start, start + length)))
elif length > 5:
modded_notes[start + 3].type = NoteType.NONE
idx = start + 5
while idx < start + length:
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 2
streams_2_2 = find_2plus2_patterns(Interval.SIXTEENTH)
for index in streams_2_2:
modded_notes[index + 2].type = NoteType.NONE
# Level 11 (Oni):
# Level 10 variation
elif level == 11:
streams = find_streams(Interval.THIRTYSECOND)
for start, length in streams:
idx = start + 1
while idx < start + length:
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 2
streams = find_streams(Interval.TWENTYFOURTH)
for start, length in streams:
idx = start + 1
while idx < start + length - 1:
if idx < len(modded_notes) and idx + 1 < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
modded_notes[idx + 1].type = NoteType.NONE
idx += 3
streams = find_streams(Interval.TWELFTH)
for start, length in streams:
idx = start + 1
while idx < start + length - 1:
if idx < len(modded_notes) and idx + 1 < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 3
streams = find_streams(Interval.SIXTEENTH)
for start, length in streams:
if length == 2:
modded_notes[start].type = modded_notes[start + 1].type
if length == 3:
modded_notes[start + 1].type = NoteType.NONE
if length == 4 or length == 5:
modded_notes[start + 3].type = NoteType.NONE
make_single_color(list(range(start, start + length)))
elif length > 5:
idx = start
while idx < start + length:
triplet_end = min(idx + 3, start + length)
if triplet_end - idx >= 2:
make_single_color(list(range(idx, triplet_end)))
idx += 3
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 2
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 1
# Level 12 (Oni):
# Level 10 variation
elif level == 12:
streams = find_streams(Interval.THIRTYSECOND)
for start, length in streams:
idx = start + 1
while idx < start + length:
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 2
streams = find_streams(Interval.TWENTYFOURTH)
for start, length in streams:
idx = start + 1
while idx < start + length - 1:
if idx < len(modded_notes) and idx + 1 < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
modded_notes[idx + 1].type = NoteType.NONE
idx += 3
streams = find_streams(Interval.TWELFTH)
for start, length in streams:
if length <= 4:
make_single_color(list(range(start, start + length)))
else:
idx = start + 1
while idx < start + length - 1:
if idx < len(modded_notes) and idx + 1 < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 3
streams = find_streams(Interval.SIXTEENTH)
for start, length in streams:
if length == 3:
make_single_color(list(range(start, start + length)))
if length == 4 or length == 5:
modded_notes[start + 3].type = NoteType.NONE
make_single_color(list(range(start, start + length)))
elif length > 5:
idx = start
while idx < start + length:
triplet_end = min(idx + 3, start + length)
if triplet_end - idx >= 2:
make_single_color(list(range(idx, triplet_end)))
idx += 3
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 2
if idx < start + length and idx < len(modded_notes):
modded_notes[idx].type = NoteType.NONE
idx += 1
filtered_notes = [note for note in modded_notes if note.type != NoteType.NONE]
return filtered_notes

View File

@@ -1,5 +1,6 @@
import logging import logging
from typing import Any from typing import Any
from libs.audio import audio from libs.audio import audio
from libs.texture import tex from libs.texture import tex

View File

@@ -1,15 +1,17 @@
import configparser import configparser
import logging import csv
import json import json
import logging
import sqlite3 import sqlite3
import time import time
import csv
from pathlib import Path from pathlib import Path
import zipfile
from libs.global_data import Crown
from libs.tja import NoteList, TJAParser, test_encodings
from libs.utils import global_data
from libs.config import get_config from libs.config import get_config
from libs.global_data import Crown
from libs.parsers.osz import OsuParser
from libs.parsers.tja import NoteList, TJAParser, test_encodings
from libs.utils import global_data
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DB_VERSION = 1 DB_VERSION = 1
@@ -105,12 +107,24 @@ def build_song_hashes(output_dir=Path("cache")):
for root_dir in tja_paths: for root_dir in tja_paths:
root_path = Path(root_dir) root_path = Path(root_dir)
found_tja_files = root_path.rglob("*.tja", recurse_symlinks=True) found_tja_files = root_path.rglob("*.tja", recurse_symlinks=True)
found_osz_files = root_path.rglob("*.osz", recurse_symlinks=True)
found_osu_files = root_path.rglob("*.osu", recurse_symlinks=True)
all_tja_files.extend(found_tja_files) all_tja_files.extend(found_tja_files)
all_tja_files.extend(found_osz_files)
all_tja_files.extend(found_osu_files)
global_data.total_songs = len(all_tja_files) global_data.total_songs = len(all_tja_files)
files_to_process = [] files_to_process = []
for tja_path in all_tja_files: for tja_path in all_tja_files:
if tja_path.suffix == '.osz':
with zipfile.ZipFile(tja_path, 'r') as zip_file:
zip_file.extractall(tja_path.with_suffix(''))
zip_path = Path(tja_path.with_suffix(''))
tja_path.unlink()
for file in zip_path.glob('*.osu'):
files_to_process.append(file)
continue
tja_path_str = str(tja_path) tja_path_str = str(tja_path)
current_modified = tja_path.stat().st_mtime current_modified = tja_path.stat().st_mtime
if current_modified <= saved_timestamp: if current_modified <= saved_timestamp:
@@ -133,16 +147,24 @@ def build_song_hashes(output_dir=Path("cache")):
global_data.total_songs = total_songs global_data.total_songs = total_songs
for tja_path in files_to_process: for tja_path in files_to_process:
try: if tja_path.suffix == '.osu':
tja_path_str = str(tja_path) parser = OsuParser(tja_path)
path_str = str(tja_path)
current_modified = tja_path.stat().st_mtime current_modified = tja_path.stat().st_mtime
tja = TJAParser(tja_path) diff_hashes = dict()
all_notes = parser.notes_to_position(0)[0]
diff_hashes[0] = parser.hash_note_data(all_notes)
else:
try:
path_str = str(tja_path)
current_modified = tja_path.stat().st_mtime
parser = TJAParser(tja_path)
all_notes = NoteList() all_notes = NoteList()
diff_hashes = dict() diff_hashes = dict()
for diff in tja.metadata.course_data: for diff in parser.metadata.course_data:
diff_notes, branch_m, branch_e, branch_n = TJAParser.notes_to_position(TJAParser(tja.file_path), diff) diff_notes, branch_m, branch_e, branch_n = TJAParser.notes_to_position(TJAParser(parser.file_path), diff)
diff_hashes[diff] = tja.hash_note_data(diff_notes) diff_hashes[diff] = parser.hash_note_data(diff_notes)
all_notes.play_notes.extend(diff_notes.play_notes) all_notes.play_notes.extend(diff_notes.play_notes)
if branch_m: if branch_m:
for branch in branch_m: for branch in branch_m:
@@ -164,25 +186,25 @@ def build_song_hashes(output_dir=Path("cache")):
if all_notes == NoteList(): if all_notes == NoteList():
continue continue
hash_val = tja.hash_note_data(all_notes) hash_val = parser.hash_note_data(all_notes)
if hash_val not in song_hashes: if hash_val not in song_hashes:
song_hashes[hash_val] = [] song_hashes[hash_val] = []
song_hashes[hash_val].append({ song_hashes[hash_val].append({
"file_path": tja_path_str, "file_path": path_str,
"last_modified": current_modified, "last_modified": current_modified,
"title": tja.metadata.title, "title": parser.metadata.title,
"subtitle": tja.metadata.subtitle, "subtitle": parser.metadata.subtitle,
"diff_hashes": diff_hashes "diff_hashes": diff_hashes
}) })
# Update both indexes # Update both indexes
path_to_hash[tja_path_str] = hash_val path_to_hash[path_str] = hash_val
global_data.song_paths[tja_path] = hash_val global_data.song_paths[tja_path] = hash_val
# Prepare database updates for each difficulty # Prepare database updates for each difficulty
en_name = tja.metadata.title.get('en', '') if isinstance(tja.metadata.title, dict) else str(tja.metadata.title) en_name = parser.metadata.title.get('en', '') if isinstance(parser.metadata.title, dict) else str(parser.metadata.title)
jp_name = tja.metadata.title.get('ja', '') if isinstance(tja.metadata.title, dict) else '' jp_name = parser.metadata.title.get('ja', '') if isinstance(parser.metadata.title, dict) else ''
score_ini_path = tja_path.with_suffix('.tja.score.ini') score_ini_path = tja_path.with_suffix('.tja.score.ini')
if score_ini_path.exists(): if score_ini_path.exists():

View File

@@ -1,28 +1,25 @@
import copy
import json import json
import os
import logging import logging
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import raylib as ray import raylib as ray
from pyray import Vector2, Rectangle, Color from pyray import Color, Rectangle, Vector2
from libs.animation import BaseAnimation, parse_animations from libs.animation import BaseAnimation, parse_animations
from libs.config import get_config from libs.config import get_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SkinInfo: class SkinInfo:
def __init__(self, x: float, y: float, font_size: int, width: float, height: float): def __init__(self, x: float, y: float, font_size: int, width: float, height: float, text: dict[str, str]):
self.x = x self.x = x
self.y = y self.y = y
self.width = width self.width = width
self.height = height self.height = height
self.font_size = font_size self.font_size = font_size
self.text = text
def __repr__(self): def __repr__(self):
return f"{self.__dict__}" return f"{self.__dict__}"
@@ -73,24 +70,31 @@ class TextureWrapper:
self.textures: dict[str, dict[str, Texture | FramedTexture]] = dict() self.textures: dict[str, dict[str, Texture | FramedTexture]] = dict()
self.animations: dict[int, BaseAnimation] = dict() self.animations: dict[int, BaseAnimation] = dict()
self.skin_config: dict[str, SkinInfo] = dict() self.skin_config: dict[str, SkinInfo] = dict()
self.graphics_path = Path(get_config()['paths']['graphics_path']) self.graphics_path = Path(f'Skins/{get_config()['paths']['skin']}/Graphics')
self.parent_graphics_path = Path(get_config()['paths']['graphics_path']) if not self.graphics_path.exists():
logger.error("No skin has been configured")
self.screen_width = 1280
self.screen_height = 720
self.screen_scale = 1.0
self.skin_config = dict()
return
self.parent_graphics_path = Path(f'Skins/{get_config()['paths']['skin']}/Graphics')
if not (self.graphics_path / "skin_config.json").exists(): if not (self.graphics_path / "skin_config.json").exists():
raise Exception("skin is missing a skin_config.json") raise Exception("skin is missing a skin_config.json")
data = json.loads((self.graphics_path / "skin_config.json").read_text()) data = json.loads((self.graphics_path / "skin_config.json").read_text(encoding='utf-8'))
self.skin_config: dict[str, SkinInfo] = { self.skin_config: dict[str, SkinInfo] = {
k: SkinInfo(v.get('x', 0), v.get('y', 0), v.get('font_size', 0), v.get('width', 0), v.get('height', 0)) for k, v in data.items() k: SkinInfo(v.get('x', 0), v.get('y', 0), v.get('font_size', 0), v.get('width', 0), v.get('height', 0), v.get('text', dict())) for k, v in data.items()
} }
self.screen_width = int(self.skin_config["screen"].width) self.screen_width = int(self.skin_config["screen"].width)
self.screen_height = int(self.skin_config["screen"].height) self.screen_height = int(self.skin_config["screen"].height)
self.screen_scale = self.screen_width / 1280 self.screen_scale = self.screen_width / 1280
if "parent" in data["screen"]: if "parent" in data["screen"]:
parent = data["screen"]["parent"] parent = data["screen"]["parent"]
self.parent_graphics_path = Path("Graphics") / parent self.parent_graphics_path = Path("Skins") / parent
parent_data = json.loads((self.parent_graphics_path / "skin_config.json").read_text()) parent_data = json.loads((self.parent_graphics_path / "skin_config.json").read_text(encoding='utf-8'))
for k, v in parent_data.items(): for k, v in parent_data.items():
self.skin_config[k] = SkinInfo(v.get('x', 0) * self.screen_scale, v.get('y', 0) * self.screen_scale, v.get('font_size', 0) * self.screen_scale, v.get('width', 0) * self.screen_scale, v.get('height', 0) * self.screen_scale) self.skin_config[k] = SkinInfo(v.get('x', 0) * self.screen_scale, v.get('y', 0) * self.screen_scale, v.get('font_size', 0) * self.screen_scale, v.get('width', 0) * self.screen_scale, v.get('height', 0) * self.screen_scale, v.get('text', dict()))
def unload_textures(self): def unload_textures(self):
"""Unload all textures and animations.""" """Unload all textures and animations."""
@@ -123,7 +127,7 @@ class TextureWrapper:
if index not in self.animations: if index not in self.animations:
raise Exception(f"Unable to find id {index} in loaded animations") raise Exception(f"Unable to find id {index} in loaded animations")
if is_copy: if is_copy:
new_anim = copy.deepcopy(self.animations[index]) new_anim = self.animations[index].copy()
if self.animations[index].loop: if self.animations[index].loop:
new_anim.start() new_anim.start()
return new_anim return new_anim
@@ -189,7 +193,7 @@ class TextureWrapper:
if screen_name in self.textures and subset in self.textures[screen_name]: if screen_name in self.textures and subset in self.textures[screen_name]:
return return
try: try:
if not os.path.isfile(folder / 'texture.json'): if not (folder / 'texture.json').exists():
raise Exception(f"texture.json file missing from {folder}") raise Exception(f"texture.json file missing from {folder}")
with open(folder / 'texture.json') as json_file: with open(folder / 'texture.json') as json_file:
@@ -205,7 +209,7 @@ class TextureWrapper:
if tex_dir.is_dir(): if tex_dir.is_dir():
frames = [ray.LoadTexture(str(frame).encode(encoding)) for frame in sorted(tex_dir.iterdir(), frames = [ray.LoadTexture(str(frame).encode(encoding)) for frame in sorted(tex_dir.iterdir(),
key=lambda x: int(x.stem)) if frame.is_file()] key=lambda x: int(x.stem)) if frame.is_file()]
self.textures[folder.stem][tex_name] = Texture(tex_name, frames, tex_mapping) self.textures[folder.stem][tex_name] = FramedTexture(tex_name, frames, tex_mapping)
self._read_tex_obj_data(tex_mapping, self.textures[folder.stem][tex_name]) self._read_tex_obj_data(tex_mapping, self.textures[folder.stem][tex_name])
elif tex_file.is_file(): elif tex_file.is_file():
tex = ray.LoadTexture(str(tex_file).encode(encoding)) tex = ray.LoadTexture(str(tex_file).encode(encoding))
@@ -230,7 +234,7 @@ class TextureWrapper:
# Load zip files from child screen path only # Load zip files from child screen path only
for zip_file in screen_path.iterdir(): for zip_file in screen_path.iterdir():
if zip_file.is_file() and zip_file.suffix == ".zip": if zip_file.is_dir():
self.load_zip(screen_name, zip_file.stem) self.load_zip(screen_name, zip_file.stem)
logger.info(f"Screen textures loaded for: {screen_name}") logger.info(f"Screen textures loaded for: {screen_name}")
@@ -292,7 +296,7 @@ class TextureWrapper:
else: else:
ray.DrawTexturePro(tex_object.texture, source_rect, dest_rect, origin, rotation, final_color) ray.DrawTexturePro(tex_object.texture, source_rect, dest_rect, origin, rotation, final_color)
if tex_object.controllable[index] or controllable: if tex_object.controllable[index] or controllable:
self.control(tex_object) self.control(tex_object, index)
def draw_texture(self, subset: str, texture: str, color: Color = Color(255, 255, 255, 255), frame: int = 0, scale: float = 1.0, center: bool = False, def draw_texture(self, subset: str, texture: str, color: Color = Color(255, 255, 255, 255), frame: int = 0, scale: float = 1.0, center: bool = False,
mirror: str = '', x: float = 0, y: float = 0, x2: float = 0, y2: float = 0, mirror: str = '', x: float = 0, y: float = 0, x2: float = 0, y2: float = 0,

View File

@@ -1,3 +1,4 @@
import string
import ctypes import ctypes
import hashlib import hashlib
import sys import sys
@@ -39,9 +40,9 @@ def rounded(num: float) -> int:
result += 1 result += 1
return sign * result return sign * result
def get_current_ms() -> int: def get_current_ms() -> float:
"""Get the current time in milliseconds""" """Get the current time in milliseconds"""
return rounded(time.time() * 1000) return time.time() * 1000
def strip_comments(code: str) -> str: def strip_comments(code: str) -> str:
"""Strip comments from a string of code""" """Strip comments from a string of code"""
@@ -135,7 +136,7 @@ for file in Path('cache/image').iterdir():
class OutlinedText: class OutlinedText:
"""Create an outlined text object.""" """Create an outlined text object."""
def __init__(self, text: str, font_size: int, color: ray.Color, outline_thickness=5.0, vertical=False): def __init__(self, text: str, font_size: int, color: ray.Color, outline_thickness=5.0, vertical=False, spacing=1):
""" """
Create an outlined text object. Create an outlined text object.
@@ -158,7 +159,7 @@ class OutlinedText:
if vertical: if vertical:
self.texture = self._create_text_vertical(text, font_size, color, ray.BLANK, self.font) self.texture = self._create_text_vertical(text, font_size, color, ray.BLANK, self.font)
else: else:
self.texture = self._create_text_horizontal(text, font_size, color, ray.BLANK, self.font) self.texture = self._create_text_horizontal(text, font_size, color, ray.BLANK, self.font, spacing=spacing)
ray.gen_texture_mipmaps(self.texture) ray.gen_texture_mipmaps(self.texture)
ray.set_texture_filter(self.texture, ray.TextureFilter.TEXTURE_FILTER_TRILINEAR) ray.set_texture_filter(self.texture, ray.TextureFilter.TEXTURE_FILTER_TRILINEAR)
outline_size = ray.ffi.new('float*', self.outline_thickness) outline_size = ray.ffi.new('float*', self.outline_thickness)
@@ -174,6 +175,12 @@ class OutlinedText:
self.default_src = ray.Rectangle(0, 0, self.texture.width, self.texture.height) self.default_src = ray.Rectangle(0, 0, self.texture.width, self.texture.height)
self._last_outline_color = None
self._last_color = None
self._last_fade = None
self._outline_color_alloc = None
self._alpha_value = None
def _hash_text(self, text: str, font_size: int, color: ray.Color, vertical: bool): def _hash_text(self, text: str, font_size: int, color: ray.Color, vertical: bool):
n = hashlib.sha256() n = hashlib.sha256()
n.update(text.encode('utf-8')) n.update(text.encode('utf-8'))
@@ -200,7 +207,7 @@ class OutlinedText:
if reload_font: if reload_font:
codepoint_count = ray.ffi.new('int *', 0) codepoint_count = ray.ffi.new('int *', 0)
codepoints = ray.load_codepoints(''.join(global_data.font_codepoints), codepoint_count) codepoints = ray.load_codepoints(''.join(global_data.font_codepoints), codepoint_count)
global_data.font = ray.load_font_ex(str(Path('Graphics/Modified-DFPKanteiryu-XB.ttf')), 40, codepoints, len(global_data.font_codepoints)) global_data.font = ray.load_font_ex(str(Path(f'Skins/{global_data.config["paths"]["skin"]}/Graphics/Modified-DFPKanteiryu-XB.ttf')), 40, codepoints, len(global_data.font_codepoints))
logger.info(f"Reloaded font with {len(global_data.font_codepoints)} codepoints") logger.info(f"Reloaded font with {len(global_data.font_codepoints)} codepoints")
return global_data.font return global_data.font
@@ -358,19 +365,25 @@ class OutlinedText:
ray.unload_image(image) ray.unload_image(image)
return texture return texture
def _create_text_horizontal(self, text: str, font_size: int, color: ray.Color, bg_color: ray.Color, font: Optional[ray.Font]=None, padding: int=10): def _create_text_horizontal(self, text: str, font_size: int, color: ray.Color, bg_color: ray.Color, font: Optional[ray.Font]=None, padding: int=10, spacing: int=1):
if font: if font:
text_size = ray.measure_text_ex(font, text, font_size, 0) text_size = ray.measure_text_ex(font, text, font_size, spacing)
for char in text:
if char in string.whitespace:
text_size.x += 2
total_width = text_size.x + (padding * 2) total_width = text_size.x + (padding * 2)
total_height = text_size.y + (padding * 2) total_height = text_size.y + (padding * 2)
else: else:
total_width = ray.measure_text(text, font_size) + (padding * 2) total_width = ray.measure_text(text, font_size) + (padding * 2)
total_height = font_size + (padding * 2) total_height = font_size + (padding * 2)
image = ray.gen_image_color(int(total_width), int(total_height), bg_color) image = ray.gen_image_color(int(total_width), int(total_height), bg_color)
if font: if font:
text_image = ray.image_text_ex(font, text, font_size, 0, color) text_image = ray.image_text_ex(font, text, font_size, spacing, color)
else: else:
text_image = ray.image_text(text, font_size, color) text_image = ray.image_text(text, font_size, color)
text_x = padding text_x = padding
text_y = padding text_y = padding
ray.image_draw(image, text_image, ray.image_draw(image, text_image,
@@ -378,7 +391,6 @@ class OutlinedText:
ray.Rectangle(text_x, text_y, text_image.width, text_image.height), ray.Rectangle(text_x, text_y, text_image.width, text_image.height),
ray.WHITE) ray.WHITE)
ray.unload_image(text_image) ray.unload_image(text_image)
ray.export_image(image, f'cache/image/{self.hash}.png') ray.export_image(image, f'cache/image/{self.hash}.png')
texture = ray.load_texture_from_image(image) texture = ray.load_texture_from_image(image)
ray.unload_image(image) ray.unload_image(image)
@@ -400,39 +412,46 @@ class OutlinedText:
rotation (float): The rotation angle of the text. rotation (float): The rotation angle of the text.
fade (float): The fade factor to apply to the text. fade (float): The fade factor to apply to the text.
""" """
if self._last_outline_color != outline_color:
if isinstance(outline_color, tuple): if isinstance(outline_color, tuple):
outline_color_alloc = ray.ffi.new("float[4]", [ self._outline_color_alloc = ray.ffi.new("float[4]", [
outline_color[0] / 255.0, outline_color[0] / 255.0,
outline_color[1] / 255.0, outline_color[1] / 255.0,
outline_color[2] / 255.0, outline_color[2] / 255.0,
outline_color[3] / 255.0 outline_color[3] / 255.0
]) ])
else: else:
outline_color_alloc = ray.ffi.new("float[4]", [ self._outline_color_alloc = ray.ffi.new("float[4]", [
outline_color.r / 255.0, outline_color.r / 255.0,
outline_color.g / 255.0, outline_color.g / 255.0,
outline_color.b / 255.0, outline_color.b / 255.0,
outline_color.a / 255.0 outline_color.a / 255.0
]) ])
ray.set_shader_value(self.shader, self.outline_color_loc, outline_color_alloc, SHADER_UNIFORM_VEC4) ray.set_shader_value(self.shader, self.outline_color_loc, self._outline_color_alloc, SHADER_UNIFORM_VEC4)
self._last_outline_color = outline_color
if self._last_color != color or self._last_fade != fade:
if isinstance(color, tuple): if isinstance(color, tuple):
alpha_value = ray.ffi.new('float*', min(fade * 255, color[3]) / 255.0) self._alpha_value = ray.ffi.new('float*', min(fade * 255, color[3]) / 255.0)
else: else:
alpha_value = ray.ffi.new('float*', min(fade * 255, color.a) / 255.0) self._alpha_value = ray.ffi.new('float*', min(fade * 255, color.a) / 255.0)
ray.set_shader_value(self.shader, self.alpha_loc, self._alpha_value, SHADER_UNIFORM_FLOAT)
self._last_color = color
self._last_fade = fade
if fade != 1.1: if fade != 1.1:
final_color = ray.fade(color, fade) final_color = ray.fade(color, fade)
else: else:
final_color = color final_color = color
ray.set_shader_value(self.shader, self.alpha_loc, alpha_value, SHADER_UNIFORM_FLOAT)
if not self.vertical: if not self.vertical:
offset = (10 * global_tex.screen_scale)-10 offset = (10 * global_tex.screen_scale)-10
else: else:
offset = 0 offset = 0
dest_rect = ray.Rectangle(x, y+offset, self.texture.width+x2, self.texture.height+y2) dest_rect = ray.Rectangle(x, y+offset, self.texture.width+x2, self.texture.height+y2)
if self.outline_thickness > 0: if self.outline_thickness > 0 and self._last_color != ray.BLANK:
ray.begin_shader_mode(self.shader) ray.begin_shader_mode(self.shader)
ray.draw_texture_pro(self.texture, self.default_src, dest_rect, origin, rotation, final_color) ray.draw_texture_pro(self.texture, self.default_src, dest_rect, origin, rotation, final_color)
if self.outline_thickness > 0: if self.outline_thickness > 0 and self._last_color != ray.BLANK:
ray.end_shader_mode() ray.end_shader_mode()
def unload(self): def unload(self):

View File

@@ -1,12 +1,12 @@
from pathlib import Path
import logging import logging
from pathlib import Path
import raylib as ray
import av import av
import raylib as ray
from libs.audio import audio from libs.audio import audio
from libs.utils import get_current_ms
from libs.texture import tex from libs.texture import tex
from libs.utils import get_current_ms
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -14,6 +14,12 @@ class VideoPlayer:
def __init__(self, path: Path): def __init__(self, path: Path):
"""Initialize a video player instance""" """Initialize a video player instance"""
self.is_finished_list = [False, False] self.is_finished_list = [False, False]
self.is_static = False
if path.suffix == '.png' or path.suffix == '.jpg':
self.texture = ray.LoadTexture(str(path).encode('utf-8'))
self.is_static = True
return
self.container = av.open(str(path)) self.container = av.open(str(path))
self.video_stream = self.container.streams.video[0] self.video_stream = self.container.streams.video[0]
@@ -144,6 +150,8 @@ class VideoPlayer:
def start(self, current_ms: float) -> None: def start(self, current_ms: float) -> None:
"""Start video playback at call time""" """Start video playback at call time"""
if self.is_static:
return
self.start_ms = current_ms self.start_ms = current_ms
self._init_frame_generator() self._init_frame_generator()
self._load_frame(0) self._load_frame(0)
@@ -154,11 +162,15 @@ class VideoPlayer:
def set_volume(self, volume: float) -> None: def set_volume(self, volume: float) -> None:
"""Set video volume, takes float value from 0.0 to 1.0""" """Set video volume, takes float value from 0.0 to 1.0"""
if self.is_static:
return
if self.audio is not None: if self.audio is not None:
audio.set_music_volume(self.audio, volume) audio.set_music_volume(self.audio, volume)
def update(self): def update(self):
"""Updates video playback, advancing frames and audio""" """Updates video playback, advancing frames and audio"""
if self.is_static:
return
self._audio_manager() self._audio_manager()
if self.frame_index >= len(self.frame_timestamps): if self.frame_index >= len(self.frame_timestamps):
@@ -186,10 +198,26 @@ class VideoPlayer:
def draw(self): def draw(self):
"""Draw video frames to the raylib canvas""" """Draw video frames to the raylib canvas"""
if self.texture is not None: if self.texture is not None:
source = (0, 0, self.texture.width, self.texture.height)
texture_aspect = self.texture.width / self.texture.height
screen_aspect = tex.screen_width / tex.screen_height
if texture_aspect > screen_aspect:
dest_width = tex.screen_width
dest_height = tex.screen_width / texture_aspect
dest_x = 0
dest_y = (tex.screen_height - dest_height) / 2
else:
dest_height = tex.screen_height
dest_width = tex.screen_height * texture_aspect
dest_x = (tex.screen_width - dest_width) / 2
dest_y = 0
destination = (dest_x, dest_y, dest_width, dest_height)
ray.ClearBackground(ray.BLACK)
ray.DrawTexturePro( ray.DrawTexturePro(
self.texture, self.texture,
(0, 0, self.texture.width, self.texture.height), source,
(0, 0, tex.screen_width, tex.screen_height), destination,
(0, 0), (0, 0),
0, 0,
ray.WHITE ray.WHITE
@@ -197,6 +225,11 @@ class VideoPlayer:
def stop(self): def stop(self):
"""Stops the video, audio, and clears its buffer""" """Stops the video, audio, and clears its buffer"""
if self.is_static:
if self.texture is not None:
ray.UnloadTexture(self.texture)
self.texture = None
return
if self.container: if self.container:
self.container.close() self.container.close()

View File

@@ -1,16 +1,35 @@
[project] [project]
name = "pytaiko" name = "pytaiko"
version = "1.0" version = "1.1"
description = "Taiko no Tatsujin simulator written in python and raylib" description = "Taiko no Tatsujin simulator written in python and raylib"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"av>=16.0.1", "av>=16.0.1",
"pyinstrument>=5.1.1",
"pypresence>=4.6.1", "pypresence>=4.6.1",
"pytest>=9.0.2",
"raylib-sdl>=5.5.0.2", "raylib-sdl>=5.5.0.2",
"tomlkit>=0.13.3", "tomlkit>=0.13.3",
] ]
[tool.pytest.ini_options]
testpaths = ["test"]
pythonpath = ["."]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts = [
"-v",
"--strict-markers",
"--tb=short",
"--color=yes",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
]
[tool.vulture] [tool.vulture]
exclude = ["*.git", ".github/", ".venv/", "cache/"] exclude = ["*.git", ".github/", ".venv/", "cache/"]
paths = ["."] paths = ["."]
@@ -18,4 +37,5 @@ paths = ["."]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"nuitka>=2.8.4", "nuitka>=2.8.4",
"pytest-cov>=6.0.0",
] ]

541
scenes/ai_battle/game.py Normal file
View File

@@ -0,0 +1,541 @@
import copy
import logging
import random
import time
from enum import Enum
from pathlib import Path
from typing import Optional
import pyray as ray
from libs.animation import Animation
from libs.audio import audio
from libs.background import Background
from libs.chara_2d import Chara2D
from libs.global_data import Difficulty, Modifiers, PlayerNum, global_data
from libs.global_objects import Nameplate
from libs.texture import tex
from libs.parsers.tja import TJAParser
from libs.utils import get_current_ms, global_tex
from scenes.game import (
DrumType,
GameScreen,
Gauge,
Player,
Side,
SongInfo,
)
logger = logging.getLogger(__name__)
class AIDifficulty(Enum):
LVL_1 = (0.90, 0.10)
LVL_2 = (0.92, 0.08)
LVL_3 = (0.94, 0.06)
LVL_4 = (0.96, 0.04)
LVL_5 = (0.98, 0.02)
def __iter__(self):
return iter(self.value)
class AIBattleGameScreen(GameScreen):
def on_screen_start(self):
super().on_screen_start()
session_data = global_data.session_data[global_data.player_num]
self.song_info = SongInfoAI(session_data.song_title, session_data.genre_index)
self.background = AIBackground(session_data.selected_difficulty)
self.section_board = SectionBoard()
def global_keys(self):
if ray.is_key_pressed(global_data.config["keys"]["restart_key"]):
if self.song_music is not None:
audio.stop_music_stream(self.song_music)
self.init_tja(global_data.session_data[global_data.player_num].selected_song)
audio.play_sound('restart', 'sound')
self.song_started = False
if ray.is_key_pressed(global_data.config["keys"]["back_key"]):
if self.song_music is not None:
audio.stop_music_stream(self.song_music)
return self.on_screen_end('AI_SELECT')
if ray.is_key_pressed(global_data.config["keys"]["pause_key"]):
self.pause_song()
def load_hitsounds(self):
"""Load the hit sounds"""
sounds_dir = Path(f"Skins/{global_data.config["paths"]["skin"]}/Sounds")
# Load hitsounds for 1P
if global_data.hit_sound[global_data.player_num] == -1:
audio.load_sound(Path('none.wav'), 'hitsound_don_1p')
audio.load_sound(Path('none.wav'), 'hitsound_kat_1p')
logger.info("Loaded default (none) hit sounds for 1P")
elif global_data.hit_sound[global_data.player_num] == 0:
audio.load_sound(sounds_dir / "hit_sounds" / str(global_data.hit_sound[global_data.player_num]) / "don.wav", 'hitsound_don_1p')
audio.load_sound(sounds_dir / "hit_sounds" / str(global_data.hit_sound[global_data.player_num]) / "ka.wav", 'hitsound_kat_1p')
logger.info("Loaded wav hit sounds for 1P")
else:
audio.load_sound(sounds_dir / "hit_sounds" / str(global_data.hit_sound[global_data.player_num]) / "don.ogg", 'hitsound_don_1p')
audio.load_sound(sounds_dir / "hit_sounds" / str(global_data.hit_sound[global_data.player_num]) / "ka.ogg", 'hitsound_kat_1p')
logger.info("Loaded ogg hit sounds for 1P")
audio.set_sound_pan('hitsound_don_1p', 0.0)
audio.set_sound_pan('hitsound_kat_1p', 0.0)
# Load hitsounds for 2P
if global_data.hit_sound[global_data.player_num] == -1:
audio.load_sound(Path('none.wav'), 'hitsound_don_5p')
audio.load_sound(Path('none.wav'), 'hitsound_kat_5p')
logger.info("Loaded default (none) hit sounds for 5P")
elif global_data.hit_sound[global_data.player_num] == 0:
audio.load_sound(sounds_dir / "hit_sounds" / str(global_data.hit_sound[global_data.player_num]) / "don_2p.wav", 'hitsound_don_5p')
audio.load_sound(sounds_dir / "hit_sounds" / str(global_data.hit_sound[global_data.player_num]) / "ka_2p.wav", 'hitsound_kat_5p')
logger.info("Loaded wav hit sounds for 5P")
else:
audio.load_sound(sounds_dir / "hit_sounds" / str(global_data.hit_sound[global_data.player_num]) / "don.ogg", 'hitsound_don_5p')
audio.load_sound(sounds_dir / "hit_sounds" / str(global_data.hit_sound[global_data.player_num]) / "ka.ogg", 'hitsound_kat_5p')
logger.info("Loaded ogg hit sounds for 5P")
audio.set_sound_pan('hitsound_don_5p', 1.0)
audio.set_sound_pan('hitsound_kat_5p', 1.0)
def init_tja(self, song: Path):
"""Initialize the TJA file"""
self.parser = TJAParser(song, start_delay=self.start_delay)
self.movie = None
session_data = global_data.session_data[global_data.player_num]
session_data.song_title = self.parser.metadata.title.get(global_data.config['general']['language'].lower(), self.parser.metadata.title['en'])
if self.parser.metadata.wave.exists() and self.parser.metadata.wave.is_file() and self.song_music is None:
self.song_music = audio.load_music_stream(self.parser.metadata.wave, 'song')
tja_copy = copy.deepcopy(self.parser)
self.player_1 = PlayerNoChara(self.parser, global_data.player_num, session_data.selected_difficulty, False, global_data.modifiers[global_data.player_num])
self.player_1.gauge = AIGauge(self.player_1.player_num, self.player_1.difficulty, self.parser.metadata.course_data[self.player_1.difficulty].level, self.player_1.total_notes, self.player_1.is_2p)
ai_modifiers = copy.deepcopy(global_data.modifiers[global_data.player_num])
ai_modifiers.auto = True
self.player_2 = AIPlayer(tja_copy, PlayerNum.AI, session_data.selected_difficulty, True, ai_modifiers, AIDifficulty.LVL_2)
self.start_ms = (get_current_ms() - self.parser.metadata.offset*1000)
self.precise_start = time.time() - self.parser.metadata.offset
self.total_notes = len(self.player_1.don_notes) + len(self.player_1.kat_notes)
logger.info(f"TJA initialized for two-player song: {song}")
def update_scoreboards(self):
section_notes = (self.total_notes // 5) if self.section_board.num < 3 else (self.total_notes // 5) + (self.total_notes % 5) - 1
if self.player_1.good_count + self.player_1.ok_count + self.player_1.bad_count == section_notes:
self.player_2.good_percentage = self.player_1.good_count / section_notes
self.player_2.ok_percentage = self.player_1.ok_count / section_notes
logger.info(f"AI Good Percentage: {self.player_2.good_percentage}, AI OK Percentage: {self.player_2.ok_percentage}")
self.player_1.good_count, self.player_1.ok_count, self.player_1.bad_count = 0, 0, 0
self.player_2.good_count, self.player_2.ok_count, self.player_2.bad_count = 0, 0, 0
if self.background.contest_point >= 10:
self.section_board.wins[self.section_board.num] = True
else:
self.section_board.wins[self.section_board.num] = False
self.section_board.num += 1
def update(self):
super(GameScreen, self).update()
current_time = get_current_ms()
self.transition.update(current_time)
self.current_ms = current_time - self.start_ms
if self.transition.is_finished:
self.start_song(self.current_ms)
else:
self.start_ms = current_time - self.parser.metadata.offset*1000
self.update_background(current_time)
self.update_audio(self.current_ms)
self.player_1.update(self.current_ms, current_time, None)
self.player_2.update(self.current_ms, current_time, None)
self.update_scoreboards()
self.section_board.update(current_time, self.player_1.good_count + self.player_1.ok_count + self.player_1.bad_count, self.total_notes)
self.song_info.update(current_time)
self.result_transition.update(current_time)
if self.result_transition.is_finished and not audio.is_sound_playing('result_transition'):
return self.on_screen_end('AI_SELECT')
elif self.current_ms >= self.player_1.end_time:
session_data = global_data.session_data[PlayerNum.P1]
session_data.result_data.score, session_data.result_data.good, session_data.result_data.ok, session_data.result_data.bad, session_data.result_data.max_combo, session_data.result_data.total_drumroll = self.player_1.get_result_score()
session_data.result_data.gauge_length = int(self.player_1.gauge.gauge_length)
if self.end_ms != 0:
if current_time >= self.end_ms + 1000:
if self.player_1.ending_anim is None:
self.spawn_ending_anims()
if self.player_1.modifiers.subdiff in [0, 1, 2, 3, 4, 5, 9, 13]:
self.write_score()
if current_time >= self.end_ms + 8533.34:
if not self.result_transition.is_started:
self.result_transition.start()
audio.play_sound('result_transition', 'voice')
else:
self.end_ms = current_time
return self.global_keys()
def update_background(self, current_time):
if self.player_1.don_notes == self.player_2.don_notes and self.player_1.kat_notes == self.player_2.kat_notes:
self.background.update_values((self.player_1.good_count, self.player_1.ok_count), (self.player_2.good_count, self.player_2.ok_count))
self.background.update(current_time)
def draw(self):
if self.movie is not None:
self.movie.draw()
elif self.background is not None:
self.background.draw(self.player_1.chara, self.player_2.chara)
self.section_board.draw()
self.player_1.draw(self.current_ms, self.start_ms, self.mask_shader)
self.player_2.draw(self.current_ms, self.start_ms, self.mask_shader)
self.draw_overlay()
class SectionBoard:
def __init__(self):
self.num = 0
self.wins: list[Optional[bool]] = [None] * 5
self.current_progress = 0
self.progress_bar_flash = Animation.create_fade(133, loop=True, reverse_delay=0)
self.section_highlight_flash = Animation.create_fade(350, loop=True, reverse_delay=0)
self.section_highlight_flash.start()
self.progress_bar_flash.start()
def update(self, current_time, player_notes, total_notes):
self.current_progress = player_notes / (total_notes // 5) if self.num < 3 else player_notes / ((total_notes // 5) + (total_notes % 5))
self.progress_bar_flash.update(current_time)
self.section_highlight_flash.update(current_time)
def draw(self):
if self.current_progress < 0.75:
color = ray.GREEN
fade = 1.0
else:
color = ray.YELLOW
fade = self.progress_bar_flash.attribute
ray.draw_rectangle(int(177 * tex.screen_scale), int(160 * tex.screen_scale), int(148 * tex.screen_scale), int(20 * tex.screen_scale), ray.GRAY)
ray.draw_rectangle(int(177 * tex.screen_scale), int(160 * tex.screen_scale), int(self.current_progress * (148 * tex.screen_scale)), int(20 * tex.screen_scale), ray.fade(color, fade))
tex.draw_texture('ai_battle', 'progress_bar')
if self.num < len(self.wins):
tex.draw_texture('ai_battle', 'section_text', index=0, frame=self.num)
if self.num < len(self.wins) - 1:
tex.draw_texture('ai_battle', 'section_text', index=1, frame=self.num+1)
tex.draw_texture('ai_battle', 'sections')
if self.num < len(self.wins):
tex.draw_texture('ai_battle', 'section_highlight_green', index=self.num)
tex.draw_texture('ai_battle', 'section_highlight_white', index=self.num, fade=self.section_highlight_flash.attribute)
for i in range(len(self.wins)):
if self.wins[i] is not None:
if self.wins[i]:
tex.draw_texture('ai_battle', 'section_win', index=i)
else:
tex.draw_texture('ai_battle', 'section_lose', index=i)
class PlayerNoChara(Player):
def __init__(self, tja: TJAParser, player_num: PlayerNum, difficulty: int, is_2p: bool, modifiers: Modifiers):
super().__init__(tja, player_num, difficulty, is_2p, modifiers)
self.stretch_animation = [tex.get_animation(5, is_copy=True) for _ in range(4)]
def update(self, ms_from_start: float, current_time: float, background: Optional[Background]):
good_count, ok_count, bad_count, total_drumroll = self.good_count, self.ok_count, self.bad_count, self.total_drumroll
super().update(ms_from_start, current_time, background)
for ani in self.stretch_animation:
ani.update(current_time)
if good_count != self.good_count:
self.stretch_animation[0].start()
if ok_count != self.ok_count:
self.stretch_animation[1].start()
if bad_count != self.bad_count:
self.stretch_animation[2].start()
if total_drumroll != self.total_drumroll:
self.stretch_animation[3].start()
def draw_overlays(self, mask_shader: ray.Shader):
tex.draw_texture('lane', f'{self.player_num}p_lane_cover', index=self.is_2p)
tex.draw_texture('lane', 'drum', index=self.is_2p)
if self.ending_anim is not None:
self.ending_anim.draw()
for anim in self.draw_drum_hit_list:
anim.draw()
for anim in self.draw_arc_list:
anim.draw(mask_shader)
# Group 6: UI overlays
self.combo_display.draw()
tex.draw_texture('lane', 'lane_score_cover', index=self.is_2p)
tex.draw_texture('lane', f'{self.player_num}p_icon', index=self.is_2p)
tex.draw_texture('lane', 'lane_difficulty', frame=self.difficulty, index=self.is_2p)
# Group 7: Player-specific elements
if self.modifiers.auto:
tex.draw_texture('lane', 'auto_icon', index=self.is_2p)
else:
if self.is_2p:
self.nameplate.draw(tex.skin_config["game_nameplate_1p"].x, tex.skin_config["game_nameplate_1p"].y)
else:
self.nameplate.draw(tex.skin_config["game_nameplate_2p"].x, tex.skin_config["game_nameplate_2p"].y)
self.draw_modifiers()
tex.draw_texture('ai_battle', 'scoreboard')
for j, counter in enumerate([self.good_count, self.ok_count, self.bad_count, self.total_drumroll]):
margin = tex.textures["ai_battle"]["scoreboard_num"].width//2
total_width = len(str(counter)) * margin
for i, digit in enumerate(str(counter)):
tex.draw_texture('ai_battle', 'scoreboard_num', frame=int(digit), x=-(total_width // 2) + (i * margin), y=-self.stretch_animation[j].attribute, y2=self.stretch_animation[j].attribute, index=j)
# Group 8: Special animations and counters
if self.drumroll_counter is not None:
self.drumroll_counter.draw()
if self.balloon_anim is not None:
self.balloon_anim.draw()
if self.kusudama_anim is not None:
self.kusudama_anim.draw()
self.score_counter.draw()
for anim in self.base_score_list:
anim.draw()
class AIPlayer(Player):
def __init__(self, tja: TJAParser, player_num: PlayerNum, difficulty: int, is_2p: bool, modifiers: Modifiers, ai_difficulty: AIDifficulty):
super().__init__(tja, player_num, difficulty, is_2p, modifiers)
self.stretch_animation = [tex.get_animation(5, is_copy=True) for _ in range(4)]
self.chara = Chara2D(player_num - 1, self.bpm)
self.judge_counter = None
self.gauge = None
self.gauge_hit_effect = []
plate_info = global_data.config[f'nameplate_{self.is_2p+1}p']
self.nameplate = Nameplate(plate_info['name'], plate_info['title'], PlayerNum.AI, plate_info['dan'], plate_info['gold'], plate_info['rainbow'], plate_info['title_bg'])
self.good_percentage, self.ok_percentage = ai_difficulty
def update(self, ms_from_start: float, current_time: float, background: Optional[Background]):
good_count, ok_count, bad_count, total_drumroll = self.good_count, self.ok_count, self.bad_count, self.total_drumroll
super().update(ms_from_start, current_time, background)
for ani in self.stretch_animation:
ani.update(current_time)
if good_count != self.good_count:
self.stretch_animation[0].start()
if ok_count != self.ok_count:
self.stretch_animation[1].start()
if bad_count != self.bad_count:
self.stretch_animation[2].start()
if total_drumroll != self.total_drumroll:
self.stretch_animation[3].start()
def autoplay_manager(self, ms_from_start: float, current_time: float, background: Optional[Background]):
"""Manages autoplay behavior with randomized accuracy"""
if not self.modifiers.auto:
return
if self.is_drumroll or self.is_balloon:
if self.bpm == 0:
subdivision_in_ms = 0
else:
subdivision_in_ms = ms_from_start // ((60000 * 4 / self.bpm) / 24)
if subdivision_in_ms > self.last_subdivision:
self.last_subdivision = subdivision_in_ms
hit_type = DrumType.DON
self.autoplay_hit_side = Side.RIGHT if self.autoplay_hit_side == Side.LEFT else Side.LEFT
self.spawn_hit_effects(hit_type, self.autoplay_hit_side)
audio.play_sound(f'hitsound_don_{self.player_num}p', 'hitsound')
self.check_note(ms_from_start, hit_type, current_time, background)
else:
if self.difficulty < Difficulty.NORMAL:
good_window_ms = Player.TIMING_GOOD_EASY
ok_window_ms = Player.TIMING_OK_EASY
bad_window_ms = Player.TIMING_BAD_EASY
else:
good_window_ms = Player.TIMING_GOOD
ok_window_ms = Player.TIMING_OK
bad_window_ms = Player.TIMING_BAD
self._adjust_timing(self.don_notes, DrumType.DON, 'don',
ms_from_start, current_time, background,
good_window_ms, ok_window_ms, bad_window_ms)
self._adjust_timing(self.kat_notes, DrumType.KAT, 'kat',
ms_from_start, current_time, background,
good_window_ms, ok_window_ms, bad_window_ms)
def _adjust_timing(self, notes, hit_type, sound_type, ms_from_start,
current_time, background, good_window_ms, ok_window_ms, bad_window_ms):
"""Process autoplay for a specific note type"""
while notes and ms_from_start >= notes[0].hit_ms:
note = notes[0]
rand = random.random()
if rand < (self.good_percentage):
timing_offset = random.uniform(-good_window_ms * 0.5, good_window_ms * 0.5)
elif rand < (self.good_percentage + self.ok_percentage):
timing_offset = random.choice([
random.uniform(-ok_window_ms, -good_window_ms),
random.uniform(good_window_ms, ok_window_ms)
])
else:
timing_offset = random.choice([
random.uniform(-bad_window_ms * 1.5, -bad_window_ms),
random.uniform(bad_window_ms, bad_window_ms * 1.5)
])
adjusted_ms = note.hit_ms + timing_offset
self.autoplay_hit_side = Side.RIGHT if self.autoplay_hit_side == Side.LEFT else Side.LEFT
self.spawn_hit_effects(hit_type, self.autoplay_hit_side)
audio.play_sound(f'hitsound_{sound_type}_{self.player_num}p', 'hitsound')
self.check_note(adjusted_ms, hit_type, current_time, background)
def draw_overlays(self, mask_shader: ray.Shader):
# Group 4: Lane covers and UI elements (batch similar textures)
tex.draw_texture('lane', 'ai_lane_cover')
tex.draw_texture('lane', 'drum', index=self.is_2p)
if self.ending_anim is not None:
self.ending_anim.draw()
# Group 5: Hit effects and animations
for anim in self.draw_drum_hit_list:
anim.draw()
for anim in self.draw_arc_list:
anim.draw(mask_shader)
# Group 6: UI overlays
self.combo_display.draw()
if self.judge_counter is not None:
self.judge_counter.draw()
# Group 7: Player-specific elements
if self.is_2p:
self.nameplate.draw(tex.skin_config["game_nameplate_1p"].x, tex.skin_config["game_nameplate_1p"].y)
else:
self.nameplate.draw(tex.skin_config["game_nameplate_2p"].x, tex.skin_config["game_nameplate_2p"].y)
tex.draw_texture('ai_battle', 'scoreboard_ai')
for j, counter in enumerate([self.good_count, self.ok_count, self.bad_count, self.total_drumroll]):
margin = tex.textures["ai_battle"]["scoreboard_num"].width//2
total_width = len(str(counter)) * margin
for i, digit in enumerate(str(counter)):
tex.draw_texture('ai_battle', 'scoreboard_num', frame=int(digit), x=-(total_width // 2) + (i * margin), y=-self.stretch_animation[j].attribute, y2=self.stretch_animation[j].attribute, index=j+4)
# Group 8: Special animations and counters
if self.drumroll_counter is not None:
self.drumroll_counter.draw()
if self.balloon_anim is not None:
self.balloon_anim.draw()
if self.kusudama_anim is not None:
self.kusudama_anim.draw()
class AIGauge(Gauge):
def draw(self):
scale = 0.5
x, y = 10 * tex.screen_scale, 15 * tex.screen_scale
tex.draw_texture('gauge_ai', f'{self.player_num}p_unfilled' + self.string_diff, scale=scale, x=x, y=y)
gauge_length = int(self.gauge_length)
clear_point = self.clear_start[self.difficulty]
bar_width = tex.textures["gauge_ai"][f"{self.player_num}p_bar"].width * scale
tex.draw_texture('gauge_ai', f'{self.player_num}p_bar', x2=min(gauge_length*bar_width, (clear_point - 1)*bar_width)-bar_width, scale=scale, x=x, y=y)
if gauge_length >= clear_point - 1:
tex.draw_texture('gauge_ai', 'bar_clear_transition', x=((clear_point - 1)*bar_width)+x, scale=scale, y=y)
if gauge_length > clear_point:
tex.draw_texture('gauge_ai', 'bar_clear_top', x=((clear_point) * bar_width)+x, x2=(gauge_length-clear_point)*bar_width, scale=scale, y=y)
tex.draw_texture('gauge_ai', 'bar_clear_bottom', x=((clear_point) * bar_width)+x, x2=(gauge_length-clear_point)*bar_width, scale=scale, y=y)
# Rainbow effect for full gauge
if gauge_length == self.gauge_max and self.rainbow_fade_in is not None:
if 0 < self.rainbow_animation.attribute < 8:
tex.draw_texture('gauge_ai', 'rainbow' + self.string_diff, frame=self.rainbow_animation.attribute-1, fade=self.rainbow_fade_in.attribute, scale=scale, x=x, y=y)
tex.draw_texture('gauge_ai', 'rainbow' + self.string_diff, frame=self.rainbow_animation.attribute, fade=self.rainbow_fade_in.attribute, scale=scale, x=x, y=y)
if self.gauge_update_anim is not None and gauge_length <= self.gauge_max and gauge_length > self.previous_length:
if gauge_length == self.clear_start[self.difficulty]:
tex.draw_texture('gauge_ai', 'bar_clear_transition_fade', x=(gauge_length*bar_width)+x, fade=self.gauge_update_anim.attribute, scale=scale, y=y)
elif gauge_length > self.clear_start[self.difficulty]:
tex.draw_texture('gauge_ai', 'bar_clear_fade', x=(gauge_length*bar_width)+x, fade=self.gauge_update_anim.attribute, scale=scale, y=y)
else:
tex.draw_texture('gauge_ai', f'{self.player_num}p_bar_fade', x=(gauge_length*bar_width)+x, fade=self.gauge_update_anim.attribute, scale=scale, y=y)
tex.draw_texture('gauge_ai', 'overlay' + self.string_diff, fade=0.15, scale=scale, x=x, y=y)
# Draw clear status indicators
tex.draw_texture('gauge_ai', 'footer', scale=scale, x=x, y=y)
if gauge_length >= clear_point-1:
tex.draw_texture('gauge_ai', 'clear', index=min(2, self.difficulty), scale=scale, x=x, y=y)
if self.is_rainbow:
tex.draw_texture('gauge_ai', 'tamashii_fire', scale=0.75 * scale, center=True, frame=self.tamashii_fire_change.attribute, index=self.is_2p)
tex.draw_texture('gauge_ai', 'tamashii', scale=scale, x=x, y=y)
if self.is_rainbow and self.tamashii_fire_change.attribute in (0, 1, 4, 5):
tex.draw_texture('gauge_ai', 'tamashii_overlay', fade=0.5, scale=scale, x=x, y=y)
else:
tex.draw_texture('gauge_ai', 'clear_dark', index=min(2, self.difficulty), scale=scale, x=x, y=y)
tex.draw_texture('gauge_ai', 'tamashii_dark', scale=scale, x=x, y=y)
class SongInfoAI(SongInfo):
"""Displays the song name and genre"""
def draw(self):
y = 600 * tex.screen_scale
tex.draw_texture('song_info', 'song_num', fade=self.fade.attribute, frame=global_data.songs_played % 4, y=y)
text_x = tex.skin_config["song_info"].x - self.song_title.texture.width
text_y = tex.skin_config["song_info"].y - self.song_title.texture.height//2
self.song_title.draw(outline_color=ray.BLACK, x=text_x, y=text_y+y, color=ray.fade(ray.WHITE, 1 - self.fade.attribute))
if self.genre < 9:
tex.draw_texture('song_info', 'genre', fade=1 - self.fade.attribute, frame=self.genre, y=y)
class AIBackground:
def __init__(self, difficulty: int):
self.contest_point = 10
self.total_tiles = 19
self.difference = 0
self.difficulty = min(difficulty, 3)
self.multipliers = [
[5, 3],
[5, 3],
[3, 2],
[3, 1]
]
self.contest_point_fade = Animation.create_fade(166, initial_opacity=0.0, final_opacity=1.0, reverse_delay=166, delay=166, loop=True)
self.triangles_down = Animation.create_move(8500, total_distance=1152, loop=True)
self.contest_point_fade.start()
self.triangles_down.start()
def update(self, current_ms: float):
self.contest_point_fade.update(current_ms)
self.triangles_down.update(current_ms)
def update_values(self, player_judge: tuple[int, int], ai_judge: tuple[int, int]):
player_total = (player_judge[0] * self.multipliers[self.difficulty][0]) + (player_judge[1] * self.multipliers[self.difficulty][1])
ai_total = (ai_judge[0] * self.multipliers[self.difficulty][0]) + (ai_judge[1] * self.multipliers[self.difficulty][1])
self.contest_point = ((player_total - ai_total) // 2) + 10
self.contest_point = min(max(1, self.contest_point), self.total_tiles - 1)
def unload(self):
pass
def draw_lower(self):
tex.draw_texture('ai_battle', 'bg_lower')
tile_width = tex.textures['ai_battle']['red_tile_lower'].width
for i in range(self.contest_point):
tex.draw_texture('ai_battle', 'red_tile_lower', frame=i, x=(i*tile_width))
for i in range(self.total_tiles - self.contest_point):
tex.draw_texture('ai_battle', 'blue_tile_lower', frame=i, x=(((self.total_tiles - 1) - i)*tile_width))
tex.draw_texture('ai_battle', 'lower_triangles_1', y=self.triangles_down.attribute, fade=0.5)
tex.draw_texture('ai_battle', 'lower_triangles_2', y=self.triangles_down.attribute, fade=0.5)
tex.draw_texture('ai_battle', 'highlight_tile_lower', x=self.contest_point * tile_width, fade=self.contest_point_fade.attribute)
def draw_upper(self, chara_1: Chara2D, chara_2: Chara2D):
tex.draw_texture('ai_battle', 'bg_upper')
for i in range(self.contest_point):
tex.draw_texture('ai_battle', 'red_tile_upper', frame=i, index=i)
for i in range(self.total_tiles - self.contest_point):
tex.draw_texture('ai_battle', 'blue_tile_upper', frame=i, index=(self.total_tiles - 1) - i)
tex.draw_texture('ai_battle', 'bg_outline_upper')
if self.contest_point > 9:
frame = self.total_tiles - self.contest_point
mirror = 'horizontal'
else:
frame = self.contest_point - 1
mirror = ''
tex.draw_texture('ai_battle', 'highlight_tile_upper', frame=frame, index=self.contest_point-1, mirror=mirror, fade=self.contest_point_fade.attribute)
tile_width = tex.textures['ai_battle']['red_tile_lower'].width
offset = 60
chara_1.draw(x=tile_width*self.contest_point - (global_tex.textures['chara_0']['normal'].width//2) - offset, y=40, scale=0.5)
chara_2.draw(x=tile_width*self.contest_point - (global_tex.textures['chara_4']['normal'].width//2) + offset*1.3, y=40, scale=0.5, mirror=True)
def draw(self, chara_1: Chara2D, chara_2: Chara2D):
self.draw_lower()
self.draw_upper(chara_1, chara_2)

View File

@@ -0,0 +1,263 @@
import logging
import pyray as ray
from libs.audio import audio
from libs.chara_2d import Chara2D
from libs.file_navigator import SongFile
from libs.global_data import Difficulty, PlayerNum, global_data
from libs.texture import tex
from libs.utils import (
is_l_don_pressed,
is_l_kat_pressed,
is_r_don_pressed,
is_r_kat_pressed,
)
from scenes.song_select import (
ModifierSelector,
NeiroSelector,
SongSelectPlayer,
SongSelectScreen,
State,
)
logger = logging.getLogger(__name__)
class AISongSelectScreen(SongSelectScreen):
def on_screen_start(self):
super().on_screen_start()
self.player_1 = AISongSelectPlayer(global_data.player_num, self.text_fade_in)
global_data.modifiers[global_data.player_num].subdiff = 0
self.ai_chara = Chara2D(PlayerNum.AI-1)
def update_players(self, current_time) -> str:
self.player_1.update(current_time)
self.ai_chara.update(current_time)
if self.text_fade_out.is_finished:
self.player_1.selected_song = True
next_screen = "AI_GAME"
return next_screen
def draw_background(self):
tex.draw_texture('ai_battle', 'background')
tex.draw_texture('ai_battle', 'background_overlay')
tex.draw_texture('ai_battle', 'background_overlay_2')
def draw(self):
self.draw_background()
if self.navigator.genre_bg is not None and self.state == State.BROWSING:
self.navigator.genre_bg.draw(tex.skin_config["boxes"].y)
self.navigator.draw_boxes(self.move_away.attribute, self.player_1.is_ura, self.diff_fade_out.attribute)
if self.state == State.BROWSING:
tex.draw_texture('global', 'arrow', index=0, x=-(self.blue_arrow_move.attribute*2), fade=self.blue_arrow_fade.attribute)
tex.draw_texture('global', 'arrow', index=1, mirror='horizontal', x=self.blue_arrow_move.attribute*2, fade=self.blue_arrow_fade.attribute)
tex.draw_texture('global', 'footer')
self.ura_switch_animation.draw()
if self.diff_sort_selector is not None:
self.diff_sort_selector.draw()
if self.search_box is not None:
self.search_box.draw()
if (self.player_1.selected_song and self.state == State.SONG_SELECTED):
tex.draw_texture('global', 'difficulty_select', fade=self.text_fade_in.attribute)
elif self.state == State.DIFF_SORTING:
tex.draw_texture('global', 'difficulty_select', fade=self.text_fade_in.attribute)
else:
tex.draw_texture('global', 'song_select', fade=self.text_fade_out.attribute)
self.draw_players()
self.ai_chara.draw(x=tex.skin_config["song_select_chara_2p"].x, y=tex.skin_config["song_select_chara_2p"].y, mirror=True)
if self.state == State.BROWSING and self.navigator.items != []:
curr_item = self.navigator.get_current_item()
if isinstance(curr_item, SongFile):
curr_item.box.draw_score_history()
if self.player_1.subdiff_selector is not None and self.player_1.subdiff_selector.is_selected:
ray.draw_rectangle(0, 0, tex.screen_width, tex.screen_height, ray.fade(ray.BLACK, 0.5))
self.player_1.subdiff_selector.draw()
self.draw_overlay()
class AISongSelectPlayer(SongSelectPlayer):
def __init__(self, player_num: PlayerNum, text_fade_in):
super().__init__(player_num, text_fade_in)
self.subdiff_selector = None
def update(self, current_time):
super().update(current_time)
if self.subdiff_selector is not None:
self.subdiff_selector.update(current_time, self.selected_difficulty)
def on_song_selected(self, selected_song: SongFile):
"""Called when a song is selected"""
super().on_song_selected(selected_song)
self.subdiff_selector = SubdiffSelector(self.player_num, min(selected_song.parser.metadata.course_data))
def handle_input_selected(self, current_item):
"""Handle input for selecting difficulty. Returns 'cancel', 'confirm', or None"""
if self.neiro_selector is not None:
if is_l_kat_pressed(self.player_num):
self.neiro_selector.move_left()
elif is_r_kat_pressed(self.player_num):
self.neiro_selector.move_right()
if is_l_don_pressed(self.player_num) or is_r_don_pressed(self.player_num):
audio.play_sound('don', 'sound')
self.neiro_selector.confirm()
return None
if self.modifier_selector is not None:
if is_l_kat_pressed(self.player_num):
audio.play_sound('kat', 'sound')
self.modifier_selector.left()
elif is_r_kat_pressed(self.player_num):
audio.play_sound('kat', 'sound')
self.modifier_selector.right()
if is_l_don_pressed(self.player_num) or is_r_don_pressed(self.player_num):
audio.play_sound('don', 'sound')
self.modifier_selector.confirm()
return None
if self.subdiff_selector is not None and self.subdiff_selector.is_selected:
if is_l_kat_pressed(self.player_num):
audio.play_sound('kat', 'sound')
self.subdiff_selector.move_left(self.selected_difficulty)
elif is_r_kat_pressed(self.player_num):
audio.play_sound('kat', 'sound')
self.subdiff_selector.move_right(self.selected_difficulty)
if is_l_don_pressed(self.player_num) or is_r_don_pressed(self.player_num):
audio.play_sound('don', 'sound')
self.subdiff_selector.confirm()
self.is_ready = True
return "confirm"
return None
if is_l_don_pressed(self.player_num) or is_r_don_pressed(self.player_num):
if self.selected_difficulty == -3:
self.subdiff_selector = None
return "cancel"
elif self.selected_difficulty == -2:
audio.play_sound('don', 'sound')
self.modifier_selector = ModifierSelector(self.player_num)
return None
elif self.selected_difficulty == -1:
audio.play_sound('don', 'sound')
self.neiro_selector = NeiroSelector(self.player_num)
return None
else:
audio.play_sound('don', 'sound')
if self.subdiff_selector is not None:
self.subdiff_selector.is_selected = True
if is_l_kat_pressed(self.player_num) or is_r_kat_pressed(self.player_num):
audio.play_sound('kat', 'sound')
selected_song = current_item
diffs = sorted(selected_song.parser.metadata.course_data)
prev_diff = self.selected_difficulty
ret_val = None
if is_l_kat_pressed(self.player_num):
ret_val = self._navigate_difficulty_left(diffs)
elif is_r_kat_pressed(self.player_num):
ret_val = self._navigate_difficulty_right(diffs)
if Difficulty.EASY <= self.selected_difficulty <= Difficulty.URA and self.selected_difficulty != prev_diff:
self.selected_diff_bounce.start()
self.selected_diff_fadein.start()
return ret_val
if (ray.is_key_pressed(ray.KeyboardKey.KEY_TAB) and
self.selected_difficulty in [Difficulty.ONI, Difficulty.URA]):
return self._toggle_ura_mode()
return None
def draw(self, state: int, is_half: bool = False):
if (self.selected_song and state == State.SONG_SELECTED):
self.draw_selector(is_half)
offset = 0
if self.subdiff_selector is not None:
offset = -self.subdiff_selector.move.attribute*1.05
self.nameplate.draw(tex.skin_config["song_select_nameplate_1p"].x, tex.skin_config["song_select_nameplate_1p"].y)
self.chara.draw(x=tex.skin_config["song_select_chara_1p"].x, y=tex.skin_config["song_select_chara_1p"].y + (offset*0.6))
if self.subdiff_selector is not None:
self.subdiff_selector.draw()
if self.neiro_selector is not None:
self.neiro_selector.draw()
if self.modifier_selector is not None:
self.modifier_selector.draw()
class SubdiffSelector:
def __init__(self, player_num: PlayerNum, lowest_difficulty: int):
self.player_num = player_num
self.move = tex.get_animation(28, is_copy=True)
self.blue_arrow_fade = tex.get_animation(29, is_copy=True)
self.blue_arrow_move = tex.get_animation(30, is_copy=True)
self.move.start()
self.is_selected = False
self.selected_index = 0
subdiffs_easy = [('subdiff_easy', 0), ('subdiff_normal', 0), ('subdiff_normal', 1), ('subdiff_normal', 2)]
subdiffs_normal = [('subdiff_normal', 0), ('subdiff_normal', 1), ('subdiff_normal', 2), ('subdiff_normal', 3)]
subdiffs_hard = [('subdiff_hard', 0), ('subdiff_hard', 1), ('subdiff_hard', 2), ('subdiff_hard', 3)]
subdiffs_oni = [('subdiff_oni', 0), ('subdiff_oni', 1), ('subdiff_oni', 2), ('subdiff_oni', 3)]
self.levels = [
[1, 2, 3, 4],
[2, 3, 4, 5],
[6, 7, 8, 9],
[10, 11, 12, 13],
[10, 11, 12, 13]
]
self.selected_level = 1
self.diff_map = {
Difficulty.EASY: subdiffs_easy,
Difficulty.NORMAL: subdiffs_normal,
Difficulty.HARD: subdiffs_hard,
Difficulty.ONI: subdiffs_oni,
Difficulty.URA: subdiffs_oni
}
self.selected_subdiff = self.diff_map[Difficulty(lowest_difficulty)]
def update(self, current_ms: float, current_difficulty: int):
self.move.update(current_ms)
if current_difficulty in self.diff_map:
self.selected_subdiff = self.diff_map[Difficulty(current_difficulty)]
def move_left(self, difficulty: int):
self.selected_index = max(0, self.selected_index - 1)
self.selected_level = self.levels[difficulty][self.selected_index]
def move_right(self, difficulty: int):
self.selected_index = min(3, self.selected_index + 1)
self.selected_level = self.levels[difficulty][self.selected_index]
def confirm(self):
global_data.modifiers[self.player_num].subdiff = self.selected_level
def draw(self):
y = -self.move.attribute*1.05
if self.is_selected:
tex.draw_texture('ai_battle', 'box_bg_blur', y=y)
tex.draw_texture('ai_battle', 'box_2', y=y)
tex.draw_texture('ai_battle', 'subdiff_select_text', y=y)
else:
tex.draw_texture('ai_battle', 'box_1', y=y)
tex.draw_texture('ai_battle', 'subdiff_selector', x=self.selected_index*tex.textures['ai_battle']['subdiff_easy'].width, y=y)
for i, subdiff in enumerate(self.selected_subdiff):
name, frame = subdiff
tex.draw_texture('ai_battle', name, frame=frame, x=i*tex.textures['ai_battle'][name].width, y=y)
tex.draw_texture('ai_battle', 'bottom_text', y=y)

View File

@@ -1,10 +1,11 @@
import logging import logging
import pyray as ray import pyray as ray
from libs.animation import Animation from libs.animation import Animation
from libs.audio import audio
from libs.chara_2d import Chara2D from libs.chara_2d import Chara2D
from libs.global_data import PlayerNum, reset_session from libs.global_data import PlayerNum, reset_session
from libs.audio import audio
from libs.global_objects import AllNetIcon, CoinOverlay, Nameplate from libs.global_objects import AllNetIcon, CoinOverlay, Nameplate
from libs.screen import Screen from libs.screen import Screen
from libs.texture import tex from libs.texture import tex
@@ -13,7 +14,7 @@ from libs.utils import (
get_current_ms, get_current_ms,
global_data, global_data,
is_l_don_pressed, is_l_don_pressed,
is_r_don_pressed is_r_don_pressed,
) )
from scenes.game import Gauge from scenes.game import Gauge
from scenes.result import Background from scenes.result import Background

View File

@@ -1,16 +1,28 @@
import logging import logging
import pyray as ray import pyray as ray
from libs.audio import audio from libs.audio import audio
from libs.global_data import PlayerNum, global_data
from libs.texture import tex
from libs.chara_2d import Chara2D from libs.chara_2d import Chara2D
from libs.global_objects import AllNetIcon, CoinOverlay, Indicator, Nameplate, Timer
from libs.screen import Screen
from libs.file_navigator import BackBox, DanCourse, navigator from libs.file_navigator import BackBox, DanCourse, navigator
from libs.global_data import PlayerNum, global_data
from libs.global_objects import (
AllNetIcon,
CoinOverlay,
Indicator,
Nameplate,
Timer,
)
from libs.screen import Screen
from libs.texture import tex
from libs.transition import Transition from libs.transition import Transition
from libs.utils import get_current_ms, is_l_don_pressed, is_l_kat_pressed, is_r_don_pressed, is_r_kat_pressed from libs.utils import (
get_current_ms,
is_l_don_pressed,
is_l_kat_pressed,
is_r_don_pressed,
is_r_kat_pressed,
)
from scenes.song_select import State from scenes.song_select import State
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -1,18 +1,33 @@
import copy import copy
from typing import Optional, override
import pyray as ray
import logging import logging
from typing import Optional, override
import pyray as ray
from libs.animation import Animation from libs.animation import Animation
from libs.audio import audio from libs.audio import audio
from libs.background import Background from libs.background import Background
from libs.file_navigator import Exam from libs.file_navigator import Exam
from libs.global_data import DanResultExam, DanResultSong, PlayerNum, global_data from libs.global_data import (
DanResultExam,
DanResultSong,
PlayerNum,
global_data,
)
from libs.global_objects import AllNetIcon from libs.global_objects import AllNetIcon
from libs.tja import TJAParser from libs.texture import tex
from libs.parsers.tja import TJAParser
from libs.transition import Transition from libs.transition import Transition
from libs.utils import OutlinedText, get_current_ms from libs.utils import OutlinedText, get_current_ms
from libs.texture import tex from scenes.game import (
from scenes.game import ClearAnimation, FCAnimation, FailAnimation, GameScreen, Gauge, ResultTransition, SongInfo ClearAnimation,
FailAnimation,
FCAnimation,
GameScreen,
Gauge,
ResultTransition,
SongInfo,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -75,7 +90,7 @@ class DanGameScreen(GameScreen):
self.player_1.is_dan = True self.player_1.is_dan = True
self.player_1.gauge = DanGauge(global_data.player_num, self.total_notes) self.player_1.gauge = DanGauge(global_data.player_num, self.total_notes)
self.song_info = SongInfo(song.metadata.title.get(global_data.config["general"]["language"], "en"), genre_index) self.song_info = SongInfo(song.metadata.title.get(global_data.config["general"]["language"], "en"), genre_index)
self.bpm = self.tja.metadata.bpm self.bpm = self.parser.metadata.bpm
logger.info(f"TJA initialized for song: {song.file_path}") logger.info(f"TJA initialized for song: {song.file_path}")
@@ -88,19 +103,19 @@ class DanGameScreen(GameScreen):
song, genre_index, difficulty, level = songs[self.song_index] song, genre_index, difficulty, level = songs[self.song_index]
session_data.selected_difficulty = difficulty session_data.selected_difficulty = difficulty
self.player_1.difficulty = difficulty self.player_1.difficulty = difficulty
self.tja = TJAParser(song.file_path, start_delay=self.start_delay) self.parser = TJAParser(song.file_path, start_delay=self.start_delay)
if self.song_music is not None: if self.song_music is not None:
audio.unload_music_stream(self.song_music) audio.unload_music_stream(self.song_music)
self.song_music = None self.song_music = None
self.song_started = False self.song_started = False
if self.tja.metadata.wave.exists() and self.tja.metadata.wave.is_file() and self.song_music is None: if self.parser.metadata.wave.exists() and self.parser.metadata.wave.is_file() and self.song_music is None:
self.song_music = audio.load_music_stream(self.tja.metadata.wave, 'song') self.song_music = audio.load_music_stream(self.parser.metadata.wave, 'song')
self.player_1.tja = self.tja self.player_1.parser = self.parser
self.player_1.reset_chart() self.player_1.reset_chart()
self.dan_transition.start() self.dan_transition.start()
self.song_info = SongInfo(self.tja.metadata.title.get(global_data.config["general"]["language"], "en"), genre_index) self.song_info = SongInfo(self.parser.metadata.title.get(global_data.config["general"]["language"], "en"), genre_index)
self.start_ms = (get_current_ms() - self.tja.metadata.offset*1000) self.start_ms = (get_current_ms() - self.parser.metadata.offset*1000)
def _calculate_dan_info(self): def _calculate_dan_info(self):
"""Calculate all dan info data for drawing""" """Calculate all dan info data for drawing"""
@@ -190,7 +205,7 @@ class DanGameScreen(GameScreen):
if self.transition.is_finished and self.dan_transition.is_finished: if self.transition.is_finished and self.dan_transition.is_finished:
self.start_song(self.current_ms) self.start_song(self.current_ms)
else: else:
self.start_ms = current_time - self.tja.metadata.offset*1000 self.start_ms = current_time - self.parser.metadata.offset*1000
self.update_background(current_time) self.update_background(current_time)
if self.song_music is not None: if self.song_music is not None:

View File

@@ -1,13 +1,21 @@
import logging import logging
from typing import Optional from typing import Optional
import pyray as ray import pyray as ray
from libs.audio import audio from libs.audio import audio
from libs.chara_2d import Chara2D from libs.chara_2d import Chara2D
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.global_objects import AllNetIcon, CoinOverlay, Nameplate, Indicator, EntryOverlay, Timer from libs.global_objects import (
from libs.texture import tex AllNetIcon,
CoinOverlay,
EntryOverlay,
Indicator,
Nameplate,
Timer,
)
from libs.screen import Screen from libs.screen import Screen
from libs.texture import tex
from libs.utils import ( from libs.utils import (
OutlinedText, OutlinedText,
get_current_ms, get_current_ms,
@@ -48,6 +56,9 @@ class EntryScreen(Screen):
self.chara = Chara2D(0) self.chara = Chara2D(0)
self.announce_played = False self.announce_played = False
self.players: list[Optional[EntryPlayer]] = [None, None] self.players: list[Optional[EntryPlayer]] = [None, None]
self.text_cancel = OutlinedText(tex.skin_config["entry_cancel"].text[global_data.config["general"]["language"]], tex.skin_config["entry_cancel"].font_size, ray.WHITE, outline_thickness=4, spacing=-4)
self.text_question = OutlinedText(tex.skin_config["entry_question"].text[global_data.config["general"]["language"]], tex.skin_config["entry_question"].font_size, ray.WHITE, outline_thickness=4, spacing=-1)
audio.play_sound('bgm', 'music') audio.play_sound('bgm', 'music')
def on_screen_end(self, next_screen: str): def on_screen_end(self, next_screen: str):
@@ -156,7 +167,7 @@ class EntryScreen(Screen):
tex.draw_texture('side_select', 'box_right', fade=fade) tex.draw_texture('side_select', 'box_right', fade=fade)
tex.draw_texture('side_select', 'box_center', fade=fade) tex.draw_texture('side_select', 'box_center', fade=fade)
tex.draw_texture('side_select', 'question', fade=fade) self.text_question.draw(outline_color=ray.BLACK, x=tex.skin_config["entry_question"].x-self.text_question.texture.width//2, y=tex.skin_config["entry_question"].y, fade=fade)
self.chara.draw(tex.skin_config["chara_entry"].x, tex.skin_config["chara_entry"].y) self.chara.draw(tex.skin_config["chara_entry"].x, tex.skin_config["chara_entry"].y)
@@ -172,7 +183,7 @@ class EntryScreen(Screen):
else: else:
tex.draw_texture('side_select', '2P_highlight', fade=fade) tex.draw_texture('side_select', '2P_highlight', fade=fade)
tex.draw_texture('side_select', '1P2P_outline', index=1, fade=fade) tex.draw_texture('side_select', '1P2P_outline', index=1, fade=fade)
tex.draw_texture('side_select', 'cancel_text', fade=fade) self.text_cancel.draw(outline_color=ray.BLACK, x=tex.skin_config["entry_cancel"].x-self.text_cancel.texture.width//2, y=tex.skin_config["entry_cancel"].y, fade=fade)
self.nameplate.draw(tex.skin_config["nameplate_entry"].x, tex.skin_config["nameplate_entry"].y) self.nameplate.draw(tex.skin_config["nameplate_entry"].x, tex.skin_config["nameplate_entry"].y)
def draw_player_drum(self): def draw_player_drum(self):
@@ -439,10 +450,11 @@ class BoxManager:
"""BoxManager class for the entry screen""" """BoxManager class for the entry screen"""
def __init__(self): def __init__(self):
self.box_titles: list[OutlinedText] = [ self.box_titles: list[OutlinedText] = [
OutlinedText('演奏ゲーム', tex.skin_config["entry_box_text"].font_size, ray.WHITE, outline_thickness=5, vertical=True), OutlinedText(tex.skin_config["entry_game"].text[global_data.config["general"]["language"]], tex.skin_config["entry_box_text"].font_size, ray.WHITE, outline_thickness=5, vertical=True),
OutlinedText('特訓モード', tex.skin_config["entry_box_text"].font_size, ray.WHITE, outline_thickness=5, vertical=True), OutlinedText(tex.skin_config["entry_practice"].text[global_data.config["general"]["language"]], tex.skin_config["entry_box_text"].font_size, ray.WHITE, outline_thickness=5, vertical=True),
OutlinedText('ゲーム設定', tex.skin_config["entry_box_text"].font_size, ray.WHITE, outline_thickness=5, vertical=True)] OutlinedText(tex.skin_config["entry_ai_battle"].text[global_data.config["general"]["language"]], tex.skin_config["entry_box_text"].font_size, ray.WHITE, outline_thickness=5, vertical=True),
self.box_locations = ["SONG_SELECT", "PRACTICE_SELECT", "SETTINGS"] OutlinedText(tex.skin_config["entry_settings"].text[global_data.config["general"]["language"]], tex.skin_config["entry_box_text"].font_size, ray.WHITE, outline_thickness=5, vertical=True),]
self.box_locations = ["SONG_SELECT", "PRACTICE_SELECT", "AI_SELECT", "SETTINGS"]
self.num_boxes = len(self.box_titles) self.num_boxes = len(self.box_titles)
self.boxes = [Box(self.box_titles[i], self.box_locations[i]) for i in range(len(self.box_titles))] self.boxes = [Box(self.box_titles[i], self.box_locations[i]) for i in range(len(self.box_titles))]
self.selected_box_index = 0 self.selected_box_index = 0
@@ -501,7 +513,7 @@ class BoxManager:
def update(self, current_time_ms: float, is_2p: bool): def update(self, current_time_ms: float, is_2p: bool):
self.is_2p = is_2p self.is_2p = is_2p
if self.is_2p: if self.is_2p:
self.box_locations = ["SONG_SELECT_2P", "PRACTICE_SELECT", "SETTINGS"] self.box_locations = ["SONG_SELECT_2P", "PRACTICE_SELECT", "AI_SELECT", "SETTINGS"]
for i, box in enumerate(self.boxes): for i, box in enumerate(self.boxes):
box.location = self.box_locations[i] box.location = self.box_locations[i]
self.fade_out.update(current_time_ms) self.fade_out.update(current_time_ms)

View File

@@ -1,12 +1,13 @@
import bisect import bisect
from enum import IntEnum
import math
import logging import logging
import math
import sqlite3 import sqlite3
import time
from collections import deque from collections import deque
from enum import IntEnum
from itertools import chain
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from itertools import chain
import pyray as ray import pyray as ray
@@ -14,18 +15,25 @@ from libs.animation import Animation
from libs.audio import audio from libs.audio import audio
from libs.background import Background from libs.background import Background
from libs.chara_2d import Chara2D from libs.chara_2d import Chara2D
from libs.global_data import Crown, Difficulty, Modifiers, PlayerNum, ScoreMethod from libs.global_data import (
Crown,
Difficulty,
Modifiers,
PlayerNum,
ScoreMethod,
)
from libs.global_objects import AllNetIcon, Nameplate from libs.global_objects import AllNetIcon, Nameplate
from libs.parsers.osz import OsuParser
from libs.screen import Screen from libs.screen import Screen
from libs.texture import tex from libs.texture import tex
from libs.tja import ( from libs.parsers.tja import (
Balloon, Balloon,
Drumroll, Drumroll,
Note, Note,
NoteList, NoteList,
NoteType, NoteType,
TJAParser,
TimelineObject, TimelineObject,
TJAParser,
apply_modifiers, apply_modifiers,
calculate_base_score, calculate_base_score,
) )
@@ -71,6 +79,7 @@ class GameScreen(Screen):
self.paused = False self.paused = False
self.pause_time = 0 self.pause_time = 0
self.audio_time = 0 self.audio_time = 0
self.last_resync = 0
self.movie = None self.movie = None
self.song_music = None self.song_music = None
if global_data.config["general"]["nijiiro_notes"]: if global_data.config["general"]["nijiiro_notes"]:
@@ -90,9 +99,9 @@ class GameScreen(Screen):
self.load_hitsounds() self.load_hitsounds()
self.song_info = SongInfo(session_data.song_title, session_data.genre_index) self.song_info = SongInfo(session_data.song_title, session_data.genre_index)
self.result_transition = ResultTransition(global_data.player_num) self.result_transition = ResultTransition(global_data.player_num)
subtitle = self.tja.metadata.subtitle.get(global_data.config['general']['language'].lower(), '') subtitle = self.parser.metadata.subtitle.get(global_data.config['general']['language'].lower(), '')
self.bpm = self.tja.metadata.bpm self.bpm = self.parser.metadata.bpm
scene_preset = self.tja.metadata.scene_preset scene_preset = self.parser.metadata.scene_preset
if self.movie is None: if self.movie is None:
self.background = Background(global_data.player_num, self.bpm, scene_preset=scene_preset) self.background = Background(global_data.player_num, self.bpm, scene_preset=scene_preset)
logger.info("Background initialized") logger.info("Background initialized")
@@ -116,7 +125,7 @@ class GameScreen(Screen):
def load_hitsounds(self): def load_hitsounds(self):
"""Load the hit sounds""" """Load the hit sounds"""
sounds_dir = Path("Sounds") sounds_dir = Path(f"Skins/{global_data.config["paths"]["skin"]}/Sounds")
if global_data.hit_sound == -1: if global_data.hit_sound == -1:
audio.load_sound(Path('none.wav'), 'hitsound_don_1p') audio.load_sound(Path('none.wav'), 'hitsound_don_1p')
audio.load_sound(Path('none.wav'), 'hitsound_kat_1p') audio.load_sound(Path('none.wav'), 'hitsound_kat_1p')
@@ -136,17 +145,22 @@ class GameScreen(Screen):
def init_tja(self, song: Path): def init_tja(self, song: Path):
"""Initialize the TJA file""" """Initialize the TJA file"""
self.tja = TJAParser(song, start_delay=self.start_delay) if song.suffix == '.osu':
if self.tja.metadata.bgmovie != Path() and self.tja.metadata.bgmovie.exists(): self.start_delay = 0
self.movie = VideoPlayer(self.tja.metadata.bgmovie) self.parser = OsuParser(song)
else:
self.parser = TJAParser(song, start_delay=self.start_delay)
if self.parser.metadata.bgmovie != Path() and self.parser.metadata.bgmovie.exists():
self.movie = VideoPlayer(self.parser.metadata.bgmovie)
else: else:
self.movie = None self.movie = None
global_data.session_data[global_data.player_num].song_title = self.tja.metadata.title.get(global_data.config['general']['language'].lower(), self.tja.metadata.title['en']) global_data.session_data[global_data.player_num].song_title = self.parser.metadata.title.get(global_data.config['general']['language'].lower(), self.parser.metadata.title['en'])
if self.tja.metadata.wave.exists() and self.tja.metadata.wave.is_file() and self.song_music is None: if self.parser.metadata.wave.exists() and self.parser.metadata.wave.is_file() and self.song_music is None:
self.song_music = audio.load_music_stream(self.tja.metadata.wave, 'song') self.song_music = audio.load_music_stream(self.parser.metadata.wave, 'song')
self.player_1 = Player(self.tja, global_data.player_num, global_data.session_data[global_data.player_num].selected_difficulty, False, global_data.modifiers[global_data.player_num]) self.player_1 = Player(self.parser, global_data.player_num, global_data.session_data[global_data.player_num].selected_difficulty, False, global_data.modifiers[global_data.player_num])
self.start_ms = get_current_ms() - self.tja.metadata.offset*1000 self.start_ms = get_current_ms() - self.parser.metadata.offset*1000
self.precise_start = time.time() - self.parser.metadata.offset
def write_score(self): def write_score(self):
"""Write the score to the database""" """Write the score to the database"""
@@ -162,7 +176,7 @@ class GameScreen(Screen):
existing_score = result[0] if result is not None else None existing_score = result[0] if result is not None else None
existing_crown = result[1] if result is not None and len(result) > 1 and result[1] is not None else 0 existing_crown = result[1] if result is not None and len(result) > 1 and result[1] is not None else 0
crown = Crown.NONE crown = Crown.NONE
if session_data.result_data.bad and session_data.result_data.ok == 0: if session_data.result_data.bad == 0 and session_data.result_data.ok == 0:
crown = Crown.DFC crown = Crown.DFC
elif session_data.result_data.bad == 0: elif session_data.result_data.bad == 0:
crown = Crown.FC crown = Crown.FC
@@ -174,21 +188,21 @@ class GameScreen(Screen):
INSERT OR REPLACE INTO Scores (hash, en_name, jp_name, diff, score, good, ok, bad, drumroll, combo, clear) INSERT OR REPLACE INTO Scores (hash, en_name, jp_name, diff, score, good, ok, bad, drumroll, combo, clear)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
''' '''
data = (hash, self.tja.metadata.title['en'], data = (hash, self.parser.metadata.title['en'],
self.tja.metadata.title.get('ja', ''), self.player_1.difficulty, self.parser.metadata.title.get('ja', ''), self.player_1.difficulty,
session_data.result_data.score, session_data.result_data.good, session_data.result_data.score, session_data.result_data.good,
session_data.result_data.ok, session_data.result_data.bad, session_data.result_data.ok, session_data.result_data.bad,
session_data.result_data.total_drumroll, session_data.result_data.max_combo, crown) session_data.result_data.total_drumroll, session_data.result_data.max_combo, crown)
cursor.execute(insert_query, data) cursor.execute(insert_query, data)
session_data.result_data.prev_score = existing_score if existing_score is not None else 0 session_data.result_data.prev_score = existing_score if existing_score is not None else 0
logger.info(f"Wrote score {session_data.result_data.score} for {self.tja.metadata.title['en']}") logger.info(f"Wrote score {session_data.result_data.score} for {self.parser.metadata.title['en']}")
con.commit() con.commit()
if result is None or (existing_crown is not None and crown > existing_crown): if result is None or (existing_crown is not None and crown > existing_crown):
cursor.execute("UPDATE Scores SET clear = ? WHERE hash = ?", (crown, hash)) cursor.execute("UPDATE Scores SET clear = ? WHERE hash = ?", (crown, hash))
con.commit() con.commit()
def start_song(self, ms_from_start): def start_song(self, ms_from_start):
if (ms_from_start >= self.tja.metadata.offset*1000 + self.start_delay - global_data.config["general"]["audio_offset"]) and not self.song_started: if (ms_from_start >= self.parser.metadata.offset*1000 + self.start_delay - global_data.config["general"]["audio_offset"]) and not self.song_started:
if self.song_music is not None: if self.song_music is not None:
audio.play_music_stream(self.song_music, 'music') audio.play_music_stream(self.song_music, 'music')
logger.info(f"Song started at {ms_from_start}") logger.info(f"Song started at {ms_from_start}")
@@ -243,6 +257,20 @@ class GameScreen(Screen):
if self.background is not None: if self.background is not None:
self.background.update(current_time, self.bpm, self.player_1.gauge, None) self.background.update(current_time, self.bpm, self.player_1.gauge, None)
def update_audio(self, ms_from_start: float):
if not self.song_started:
return
if self.song_music is not None:
audio.update_music_stream(self.song_music)
'''
raw_audio_time = audio.get_music_time_played(self.song_music)
current_time = time.time() - self.precise_start
if abs(raw_audio_time - current_time) >= 0.006 and current_time > self.last_resync + 1000:
audio.seek_music_stream(self.song_music, current_time)
logger.info(f"Resyncing due to difference: {raw_audio_time} - {current_time} = {raw_audio_time - current_time}")
self.last_resync = current_time
'''
def update(self): def update(self):
super().update() super().update()
current_time = get_current_ms() current_time = get_current_ms()
@@ -252,11 +280,10 @@ class GameScreen(Screen):
if self.transition.is_finished: if self.transition.is_finished:
self.start_song(self.current_ms) self.start_song(self.current_ms)
else: else:
self.start_ms = current_time - self.tja.metadata.offset*1000 self.start_ms = current_time - self.parser.metadata.offset*1000
self.update_background(current_time) self.update_background(current_time)
if self.song_music is not None: self.update_audio(self.current_ms)
audio.update_music_stream(self.song_music)
self.player_1.update(self.current_ms, current_time, self.background) self.player_1.update(self.current_ms, current_time, self.background)
self.song_info.update(current_time) self.song_info.update(current_time)
@@ -287,7 +314,9 @@ class GameScreen(Screen):
def draw_overlay(self): def draw_overlay(self):
self.song_info.draw() self.song_info.draw()
if not self.transition.is_finished:
self.transition.draw() self.transition.draw()
if self.result_transition.is_started:
self.result_transition.draw() self.result_transition.draw()
self.allnet_indicator.draw() self.allnet_indicator.draw()
@@ -308,7 +337,7 @@ class Player:
TIMING_OK_EASY = 108.441665649414 TIMING_OK_EASY = 108.441665649414
TIMING_BAD_EASY = 125.125 TIMING_BAD_EASY = 125.125
def __init__(self, tja: TJAParser, player_num: PlayerNum, difficulty: int, is_2p: bool, modifiers: Modifiers): def __init__(self, parser: TJAParser | OsuParser, player_num: PlayerNum, difficulty: int, is_2p: bool, modifiers: Modifiers):
self.is_2p = is_2p self.is_2p = is_2p
self.is_dan = False self.is_dan = False
self.player_num = player_num self.player_num = player_num
@@ -316,7 +345,7 @@ class Player:
self.visual_offset = global_data.config["general"]["visual_offset"] self.visual_offset = global_data.config["general"]["visual_offset"]
self.score_method = global_data.config["general"]["score_method"] self.score_method = global_data.config["general"]["score_method"]
self.modifiers = modifiers self.modifiers = modifiers
self.tja = tja self.parser = parser
self.reset_chart() self.reset_chart()
@@ -347,7 +376,10 @@ class Player:
self.delay_start: Optional[float] = None self.delay_start: Optional[float] = None
self.delay_end: Optional[float] = None self.delay_end: Optional[float] = None
self.combo_announce = ComboAnnounce(self.combo, 0, player_num, self.is_2p) self.combo_announce = ComboAnnounce(self.combo, 0, player_num, self.is_2p)
self.branch_indicator = BranchIndicator(self.is_2p) if tja and tja.metadata.course_data[self.difficulty].is_branching else None if not parser.metadata.course_data:
self.branch_indicator = None
else:
self.branch_indicator = BranchIndicator(self.is_2p) if parser and parser.metadata.course_data[self.difficulty].is_branching else None
self.ending_anim: Optional[FailAnimation | ClearAnimation | FCAnimation] = None self.ending_anim: Optional[FailAnimation | ClearAnimation | FCAnimation] = None
self.is_gogo_time = False self.is_gogo_time = False
plate_info = global_data.config[f'nameplate_{self.is_2p+1}p'] plate_info = global_data.config[f'nameplate_{self.is_2p+1}p']
@@ -359,7 +391,10 @@ class Player:
self.judge_counter = None self.judge_counter = None
self.input_log: dict[float, str] = dict() self.input_log: dict[float, str] = dict()
stars = tja.metadata.course_data[self.difficulty].level if not parser.metadata.course_data:
stars = 10
else:
stars = parser.metadata.course_data[self.difficulty].level
self.gauge = Gauge(self.player_num, self.difficulty, stars, self.total_notes, self.is_2p) self.gauge = Gauge(self.player_num, self.difficulty, stars, self.total_notes, self.is_2p)
self.gauge_hit_effect: list[GaugeHitEffect] = [] self.gauge_hit_effect: list[GaugeHitEffect] = []
@@ -392,10 +427,9 @@ class Player:
unload_offset = travel_distance / sudden_pixels_per_ms unload_offset = travel_distance / sudden_pixels_per_ms
note.unload_ms = note.hit_ms + unload_offset note.unload_ms = note.hit_ms + unload_offset
def reset_chart(self): def reset_chart(self):
notes, self.branch_m, self.branch_e, self.branch_n = self.tja.notes_to_position(self.difficulty) notes, self.branch_m, self.branch_e, self.branch_n = self.parser.notes_to_position(self.difficulty)
self.play_notes, self.draw_note_list, self.draw_bar_list = apply_modifiers(notes, self.modifiers) self.play_notes, self.draw_note_list, self.draw_bar_list = deque(apply_modifiers(notes, self.modifiers)[0]), deque(apply_modifiers(notes, self.modifiers)[1]), deque(apply_modifiers(notes, self.modifiers)[2])
self.don_notes = deque([note for note in self.play_notes if note.type in {NoteType.DON, NoteType.DON_L}]) self.don_notes = deque([note for note in self.play_notes if note.type in {NoteType.DON, NoteType.DON_L}])
self.kat_notes = deque([note for note in self.play_notes if note.type in {NoteType.KAT, NoteType.KAT_L}]) self.kat_notes = deque([note for note in self.play_notes if note.type in {NoteType.KAT, NoteType.KAT_L}])
@@ -412,12 +446,12 @@ class Player:
if self.score_method == ScoreMethod.SHINUCHI: if self.score_method == ScoreMethod.SHINUCHI:
self.base_score = calculate_base_score(total_notes) self.base_score = calculate_base_score(total_notes)
elif self.score_method == ScoreMethod.GEN3: elif self.score_method == ScoreMethod.GEN3:
self.score_diff = self.tja.metadata.course_data[self.difficulty].scorediff self.score_diff = self.parser.metadata.course_data[self.difficulty].scorediff
if self.score_diff <= 0: if self.score_diff <= 0:
logger.warning("Error: No scorediff specified or scorediff less than 0 | Using shinuchi scoring method instead") logger.warning("Error: No scorediff specified or scorediff less than 0 | Using shinuchi scoring method instead")
self.score_diff = 0 self.score_diff = 0
score_init_list = self.tja.metadata.course_data[self.difficulty].scoreinit score_init_list = self.parser.metadata.course_data[self.difficulty].scoreinit
if len(score_init_list) <= 0: if len(score_init_list) <= 0:
logger.warning("Error: No scoreinit specified or scoreinit less than 0 | Using shinuchi scoring method instead") logger.warning("Error: No scoreinit specified or scoreinit less than 0 | Using shinuchi scoring method instead")
self.score_init = calculate_base_score(total_notes) self.score_init = calculate_base_score(total_notes)
@@ -443,12 +477,12 @@ class Player:
self.bpm = 120 self.bpm = 120
if self.timeline and hasattr(self.timeline[self.timeline_index], 'bpm'): if self.timeline and hasattr(self.timeline[self.timeline_index], 'bpm'):
self.bpm = self.timeline[self.timeline_index].bpm self.bpm = self.timeline[self.timeline_index].bpm
last_note = self.draw_note_list[0] last_note = self.draw_note_list[0] if self.draw_note_list else self.branch_m[0].draw_notes[0]
for note in chain(self.draw_note_list, self.draw_bar_list): for note in chain(self.draw_note_list, self.draw_bar_list):
self.get_load_time(note) self.get_load_time(note)
if note.type == NoteType.TAIL: if note.type == NoteType.TAIL:
note.load_ms = last_note.load_ms note.load_ms = last_note.load_ms
note.unload_ms = last_note.unload_ms last_note.unload_ms = note.unload_ms
last_note = note last_note = note
self.draw_note_list = deque(sorted(self.draw_note_list, key=lambda n: n.load_ms)) self.draw_note_list = deque(sorted(self.draw_note_list, key=lambda n: n.load_ms))
@@ -484,6 +518,7 @@ class Player:
for branch in (self.branch_m, self.branch_e, self.branch_n): for branch in (self.branch_m, self.branch_e, self.branch_n):
if branch: if branch:
for section in branch: for section in branch:
section.play_notes, section.draw_notes, section.bars = apply_modifiers(section, self.modifiers)
if section.draw_notes: if section.draw_notes:
for note in section.draw_notes: for note in section.draw_notes:
self.get_load_time(note) self.get_load_time(note)
@@ -501,8 +536,8 @@ class Player:
self.draw_note_list.extend(branch_section.draw_notes) self.draw_note_list.extend(branch_section.draw_notes)
self.draw_bar_list.extend(branch_section.bars) self.draw_bar_list.extend(branch_section.bars)
self.play_notes = deque(sorted(self.play_notes)) self.play_notes = deque(sorted(self.play_notes))
self.draw_note_list = deque(sorted(self.draw_note_list, key=lambda x: x.hit_ms)) self.draw_note_list = deque(sorted(self.draw_note_list, key=lambda x: x.load_ms))
self.draw_bar_list = deque(sorted(self.draw_bar_list, key=lambda x: x.hit_ms)) self.draw_bar_list = deque(sorted(self.draw_bar_list, key=lambda x: x.load_ms))
total_don = [note for note in self.play_notes if note.type in {NoteType.DON, NoteType.DON_L}] total_don = [note for note in self.play_notes if note.type in {NoteType.DON, NoteType.DON_L}]
total_kat = [note for note in self.play_notes if note.type in {NoteType.KAT, NoteType.KAT_L}] total_kat = [note for note in self.play_notes if note.type in {NoteType.KAT, NoteType.KAT_L}]
total_other = [note for note in self.play_notes if note.type not in {NoteType.DON, NoteType.DON_L, NoteType.KAT, NoteType.KAT_L}] total_other = [note for note in self.play_notes if note.type not in {NoteType.DON, NoteType.DON_L, NoteType.KAT, NoteType.KAT_L}]
@@ -828,6 +863,7 @@ class Player:
if background is not None: if background is not None:
background.add_renda() background.add_renda()
self.score += 100 self.score += 100
if len(self.base_score_list) < 5:
self.base_score_list.append(ScoreCounterAnimation(self.player_num, 100, self.is_2p)) self.base_score_list.append(ScoreCounterAnimation(self.player_num, 100, self.is_2p))
if not self.current_notes_draw: if not self.current_notes_draw:
return return
@@ -847,6 +883,7 @@ class Player:
self.curr_balloon_count += 1 self.curr_balloon_count += 1
self.total_drumroll += 1 self.total_drumroll += 1
self.score += 100 self.score += 100
if len(self.base_score_list) < 5:
self.base_score_list.append(ScoreCounterAnimation(self.player_num, 100, self.is_2p)) self.base_score_list.append(ScoreCounterAnimation(self.player_num, 100, self.is_2p))
if self.curr_balloon_count == note.count: if self.curr_balloon_count == note.count:
self.is_balloon = False self.is_balloon = False
@@ -921,10 +958,12 @@ class Player:
big = curr_note.type == NoteType.DON_L or curr_note.type == NoteType.KAT_L big = curr_note.type == NoteType.DON_L or curr_note.type == NoteType.KAT_L
if (curr_note.hit_ms - good_window_ms) <= ms_from_start <= (curr_note.hit_ms + good_window_ms): if (curr_note.hit_ms - good_window_ms) <= ms_from_start <= (curr_note.hit_ms + good_window_ms):
if len(self.draw_judge_list) < 7:
self.draw_judge_list.append(Judgment(Judgments.GOOD, big, self.is_2p)) self.draw_judge_list.append(Judgment(Judgments.GOOD, big, self.is_2p))
self.lane_hit_effect = LaneHitEffect(Judgments.GOOD, self.is_2p) self.lane_hit_effect = LaneHitEffect(drum_type, Judgments.GOOD, self.is_2p)
self.good_count += 1 self.good_count += 1
self.score += self.base_score self.score += self.base_score
if len(self.base_score_list) < 5:
self.base_score_list.append(ScoreCounterAnimation(self.player_num, self.base_score, self.is_2p)) self.base_score_list.append(ScoreCounterAnimation(self.player_num, self.base_score, self.is_2p))
self.input_log[curr_note.index] = 'GOOD' self.input_log[curr_note.index] = 'GOOD'
self.note_correct(curr_note, current_time) self.note_correct(curr_note, current_time)
@@ -940,8 +979,10 @@ class Player:
elif (curr_note.hit_ms - ok_window_ms) <= ms_from_start <= (curr_note.hit_ms + ok_window_ms): elif (curr_note.hit_ms - ok_window_ms) <= ms_from_start <= (curr_note.hit_ms + ok_window_ms):
self.draw_judge_list.append(Judgment(Judgments.OK, big, self.is_2p)) self.draw_judge_list.append(Judgment(Judgments.OK, big, self.is_2p))
self.lane_hit_effect = LaneHitEffect(drum_type, Judgments.OK, self.is_2p)
self.ok_count += 1 self.ok_count += 1
self.score += 10 * math.floor(self.base_score / 2 / 10) self.score += 10 * math.floor(self.base_score / 2 / 10)
if len(self.base_score_list) < 5:
self.base_score_list.append(ScoreCounterAnimation(self.player_num, 10 * math.floor(self.base_score / 2 / 10), self.is_2p)) self.base_score_list.append(ScoreCounterAnimation(self.player_num, 10 * math.floor(self.base_score / 2 / 10), self.is_2p))
self.input_log[curr_note.index] = 'OK' self.input_log[curr_note.index] = 'OK'
self.note_correct(curr_note, current_time) self.note_correct(curr_note, current_time)
@@ -1003,7 +1044,8 @@ class Player:
self.kusudama_anim = None self.kusudama_anim = None
def spawn_hit_effects(self, drum_type: DrumType, side: Side): def spawn_hit_effects(self, drum_type: DrumType, side: Side):
self.lane_hit_effect = LaneHitEffect(drum_type, self.is_2p) self.lane_hit_effect = LaneHitEffect(drum_type, Judgments.BAD, self.is_2p) # Bad code detected...
if len(self.draw_drum_hit_list) < 4:
self.draw_drum_hit_list.append(DrumHitEffect(drum_type, side, self.is_2p)) self.draw_drum_hit_list.append(DrumHitEffect(drum_type, side, self.is_2p))
def handle_input(self, ms_from_start: float, current_time: float, background: Optional[Background]): def handle_input(self, ms_from_start: float, current_time: float, background: Optional[Background]):
@@ -1119,7 +1161,7 @@ class Player:
finished_arcs = [] finished_arcs = []
for i, anim in enumerate(self.draw_arc_list): for i, anim in enumerate(self.draw_arc_list):
anim.update(current_time) anim.update(current_time)
if anim.is_finished: if anim.is_finished and len(self.gauge_hit_effect) < 7:
self.gauge_hit_effect.append(GaugeHitEffect(anim.note_type, anim.is_big, self.is_2p)) self.gauge_hit_effect.append(GaugeHitEffect(anim.note_type, anim.is_big, self.is_2p))
finished_arcs.append(i) finished_arcs.append(i)
for i in reversed(finished_arcs): for i in reversed(finished_arcs):
@@ -1275,21 +1317,21 @@ class Player:
modifiers_to_draw.append('mod_shinuchi') modifiers_to_draw.append('mod_shinuchi')
# Speed modifiers # Speed modifiers
if global_data.modifiers[self.player_num].speed >= 4: if self.modifiers.speed >= 4:
modifiers_to_draw.append('mod_yonbai') modifiers_to_draw.append('mod_yonbai')
elif global_data.modifiers[self.player_num].speed >= 3: elif self.modifiers.speed >= 3:
modifiers_to_draw.append('mod_sanbai') modifiers_to_draw.append('mod_sanbai')
elif global_data.modifiers[self.player_num].speed > 1: elif self.modifiers.speed > 1:
modifiers_to_draw.append('mod_baisaku') modifiers_to_draw.append('mod_baisaku')
# Other modifiers # Other modifiers
if global_data.modifiers[self.player_num].display: if self.modifiers.display:
modifiers_to_draw.append('mod_doron') modifiers_to_draw.append('mod_doron')
if global_data.modifiers[self.player_num].inverse: if self.modifiers.inverse:
modifiers_to_draw.append('mod_abekobe') modifiers_to_draw.append('mod_abekobe')
if global_data.modifiers[self.player_num].random == 2: if self.modifiers.random == 2:
modifiers_to_draw.append('mod_detarame') modifiers_to_draw.append('mod_detarame')
elif global_data.modifiers[self.player_num].random == 1: elif self.modifiers.random == 1:
modifiers_to_draw.append('mod_kimagure') modifiers_to_draw.append('mod_kimagure')
# Draw all modifiers in one batch # Draw all modifiers in one batch
@@ -1329,13 +1371,13 @@ class Player:
self.judge_counter.draw() self.judge_counter.draw()
# Group 7: Player-specific elements # Group 7: Player-specific elements
if not self.modifiers.auto: if self.modifiers.auto:
tex.draw_texture('lane', 'auto_icon', index=self.is_2p)
else:
if self.is_2p: if self.is_2p:
self.nameplate.draw(tex.skin_config["game_nameplate_1p"].x, tex.skin_config["game_nameplate_1p"].y) self.nameplate.draw(tex.skin_config["game_nameplate_1p"].x, tex.skin_config["game_nameplate_1p"].y)
else: else:
self.nameplate.draw(tex.skin_config["game_nameplate_2p"].x, tex.skin_config["game_nameplate_2p"].y) self.nameplate.draw(tex.skin_config["game_nameplate_2p"].x, tex.skin_config["game_nameplate_2p"].y)
else:
tex.draw_texture('lane', 'auto_icon', index=self.is_2p)
self.draw_modifiers() self.draw_modifiers()
self.chara.draw(y=(self.is_2p*tex.skin_config["game_2p_offset"].y)) self.chara.draw(y=(self.is_2p*tex.skin_config["game_2p_offset"].y))
@@ -1353,6 +1395,8 @@ class Player:
def draw(self, ms_from_start: float, start_ms: float, mask_shader: ray.Shader, dan_transition = None): def draw(self, ms_from_start: float, start_ms: float, mask_shader: ray.Shader, dan_transition = None):
# Group 1: Background and lane elements # Group 1: Background and lane elements
tex.draw_texture('lane', 'lane_background', index=self.is_2p) tex.draw_texture('lane', 'lane_background', index=self.is_2p)
if self.player_num == PlayerNum.AI:
tex.draw_texture('lane', 'ai_lane_background')
if self.branch_indicator is not None: if self.branch_indicator is not None:
self.branch_indicator.draw() self.branch_indicator.draw()
if self.gauge is not None: if self.gauge is not None:
@@ -1426,9 +1470,10 @@ class Judgment:
class LaneHitEffect: class LaneHitEffect:
"""Display a gradient overlay when the player hits the drum""" """Display a gradient overlay when the player hits the drum"""
def __init__(self, type: Judgments | DrumType, is_2p: bool): def __init__(self, type: DrumType, judgment: Judgments, is_2p: bool):
self.is_2p = is_2p self.is_2p = is_2p
self.type = type self.type = type
self.judgment = judgment
self.fade = tex.get_animation(0, is_copy=True) self.fade = tex.get_animation(0, is_copy=True)
self.fade.start() self.fade.start()
self.is_finished = False self.is_finished = False
@@ -1439,12 +1484,12 @@ class LaneHitEffect:
self.is_finished = True self.is_finished = True
def draw(self): def draw(self):
if self.type == Judgments.GOOD: if self.type == DrumType.DON:
tex.draw_texture('lane', 'lane_hit_effect', frame=2, index=self.is_2p, fade=self.fade.attribute)
elif self.type == DrumType.DON:
tex.draw_texture('lane', 'lane_hit_effect', frame=0, index=self.is_2p, fade=self.fade.attribute) tex.draw_texture('lane', 'lane_hit_effect', frame=0, index=self.is_2p, fade=self.fade.attribute)
elif self.type == DrumType.KAT: elif self.type == DrumType.KAT:
tex.draw_texture('lane', 'lane_hit_effect', frame=1, index=self.is_2p, fade=self.fade.attribute) tex.draw_texture('lane', 'lane_hit_effect', frame=1, index=self.is_2p, fade=self.fade.attribute)
if self.judgment == Judgments.GOOD or self.judgment == Judgments.OK:
tex.draw_texture('lane', 'lane_hit_effect', frame=2, index=self.is_2p, fade=self.fade.attribute)
class DrumHitEffect: class DrumHitEffect:
"""Display the side of the drum hit""" """Display the side of the drum hit"""
@@ -1584,6 +1629,8 @@ class GaugeHitEffect:
class NoteArc: class NoteArc:
"""Note arcing from the player to the gauge""" """Note arcing from the player to the gauge"""
_arc_points_cache = {}
def __init__(self, note_type: int, current_ms: float, player_num: PlayerNum, big: bool, is_balloon: bool, start_x: float = 0, start_y: float = 0): def __init__(self, note_type: int, current_ms: float, player_num: PlayerNum, big: bool, is_balloon: bool, start_x: float = 0, start_y: float = 0):
self.note_type = note_type self.note_type = note_type
self.is_big = big self.is_big = big
@@ -1617,13 +1664,20 @@ class NoteArc:
self.x_i = self.start_x self.x_i = self.start_x
self.y_i = self.start_y self.y_i = self.start_y
self.is_finished = False self.is_finished = False
self.arc_points_cache = []
cache_key = (self.start_x, self.start_y, self.end_x, self.end_y, self.control_x, self.control_y, self.arc_points)
if cache_key not in NoteArc._arc_points_cache:
arc_points_list = []
for i in range(self.arc_points + 1): for i in range(self.arc_points + 1):
t = i / self.arc_points t = i / self.arc_points
t_inv = 1.0 - t t_inv = 1.0 - t
x = int(t_inv * t_inv * self.start_x + 2 * t_inv * t * self.control_x + t * t * self.end_x) x = int(t_inv * t_inv * self.start_x + 2 * t_inv * t * self.control_x + t * t * self.end_x)
y = int(t_inv * t_inv * self.start_y + 2 * t_inv * t * self.control_y + t * t * self.end_y) y = int(t_inv * t_inv * self.start_y + 2 * t_inv * t * self.control_y + t * t * self.end_y)
self.arc_points_cache.append((x, y)) arc_points_list.append((x, y))
NoteArc._arc_points_cache[cache_key] = arc_points_list
self.arc_points_cache = NoteArc._arc_points_cache[cache_key]
self.explosion_x, self.explosion_y = self.arc_points_cache[0] self.explosion_x, self.explosion_y = self.arc_points_cache[0]
self.explosion_anim = tex.get_animation(22) self.explosion_anim = tex.get_animation(22)
@@ -1760,7 +1814,7 @@ class BalloonAnimation:
tex.draw_texture('balloon', 'pop', frame=7, color=self.color, y=self.is_2p*tex.skin_config["2p_offset"].y) tex.draw_texture('balloon', 'pop', frame=7, color=self.color, y=self.is_2p*tex.skin_config["2p_offset"].y)
elif self.balloon_count >= 1: elif self.balloon_count >= 1:
balloon_index = min(6, (self.balloon_count - 1) * 6 // self.balloon_total) balloon_index = min(6, (self.balloon_count - 1) * 6 // self.balloon_total)
tex.draw_texture('balloon', 'pop', frame=balloon_index, color=self.color, index=self.player_num-1, y=self.is_2p*tex.skin_config["2p_offset"].y) tex.draw_texture('balloon', 'pop', frame=balloon_index, color=self.color, index=self.is_2p, y=self.is_2p*tex.skin_config["2p_offset"].y)
if self.balloon_count > 0: if self.balloon_count > 0:
tex.draw_texture('balloon', 'bubble', y=self.is_2p*(410 * tex.screen_scale), mirror='vertical' if self.is_2p else '') tex.draw_texture('balloon', 'bubble', y=self.is_2p*(410 * tex.screen_scale), mirror='vertical' if self.is_2p else '')
counter = str(max(0, self.balloon_total - self.balloon_count + 1)) counter = str(max(0, self.balloon_total - self.balloon_count + 1))

View File

@@ -1,17 +1,16 @@
import logging import logging
from pathlib import Path
import threading import threading
from pathlib import Path
import pyray as ray import pyray as ray
from libs.animation import Animation from libs.animation import Animation
from libs.file_navigator import navigator
from libs.global_objects import AllNetIcon from libs.global_objects import AllNetIcon
from libs.screen import Screen from libs.screen import Screen
from libs.song_hash import build_song_hashes from libs.song_hash import build_song_hashes
from libs.texture import tex from libs.texture import tex
from libs.utils import get_current_ms, global_data from libs.utils import get_current_ms, global_data
from libs.file_navigator import navigator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,7 +57,7 @@ class LoadScreen(Screen):
global_data.font_codepoints.add(character) global_data.font_codepoints.add(character)
codepoint_count = ray.ffi.new('int *', 0) codepoint_count = ray.ffi.new('int *', 0)
codepoints = ray.load_codepoints(''.join(global_data.font_codepoints), codepoint_count) codepoints = ray.load_codepoints(''.join(global_data.font_codepoints), codepoint_count)
global_data.font = ray.load_font_ex(str(Path('Graphics/Modified-DFPKanteiryu-XB.ttf')), 40, codepoints, len(global_data.font_codepoints)) global_data.font = ray.load_font_ex(str(Path(f'Skins/{global_data.config["paths"]["skin"]}/Graphics/Modified-DFPKanteiryu-XB.ttf')), 40, codepoints, len(global_data.font_codepoints))
def _load_navigator(self): def _load_navigator(self):
"""Background thread function to load navigator""" """Background thread function to load navigator"""

View File

@@ -1,20 +1,42 @@
import copy
import logging
import math import math
from collections import deque from collections import deque
import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import pyray as ray import pyray as ray
import copy
from libs.animation import Animation from libs.animation import Animation
from libs.audio import audio from libs.audio import audio
from libs.background import Background from libs.background import Background
from libs.global_data import Modifiers, PlayerNum, global_data from libs.global_data import Modifiers, PlayerNum, global_data
from libs.tja import Balloon, Drumroll, NoteType, TJAParser, TimelineObject, apply_modifiers
from libs.utils import get_current_ms, is_l_don_pressed, is_l_kat_pressed, is_r_don_pressed, is_r_kat_pressed
from libs.texture import tex from libs.texture import tex
from scenes.game import DrumHitEffect, DrumType, GameScreen, JudgeCounter, LaneHitEffect, Player, Side from libs.parsers.tja import (
Balloon,
Drumroll,
NoteType,
TimelineObject,
TJAParser,
apply_modifiers,
)
from libs.utils import (
get_current_ms,
is_l_don_pressed,
is_l_kat_pressed,
is_r_don_pressed,
is_r_kat_pressed,
)
from scenes.game import (
DrumHitEffect,
DrumType,
GameScreen,
JudgeCounter,
Judgments,
LaneHitEffect,
Player,
Side,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,16 +47,16 @@ class PracticeGameScreen(GameScreen):
def init_tja(self, song: Path): def init_tja(self, song: Path):
"""Initialize the TJA file""" """Initialize the TJA file"""
self.tja = TJAParser(song, start_delay=self.start_delay) self.parser = TJAParser(song, start_delay=self.start_delay)
self.scrobbling_tja = TJAParser(song, start_delay=self.start_delay) self.scrobbling_tja = TJAParser(song, start_delay=self.start_delay)
global_data.session_data[global_data.player_num].song_title = self.tja.metadata.title.get(global_data.config['general']['language'].lower(), self.tja.metadata.title['en']) global_data.session_data[global_data.player_num].song_title = self.parser.metadata.title.get(global_data.config['general']['language'].lower(), self.parser.metadata.title['en'])
if self.tja.metadata.wave.exists() and self.tja.metadata.wave.is_file() and self.song_music is None: if self.parser.metadata.wave.exists() and self.parser.metadata.wave.is_file() and self.song_music is None:
self.song_music = audio.load_music_stream(self.tja.metadata.wave, 'song') self.song_music = audio.load_music_stream(self.parser.metadata.wave, 'song')
self.player_1 = PracticePlayer(self.tja, global_data.player_num, global_data.session_data[global_data.player_num].selected_difficulty, False, global_data.modifiers[global_data.player_num]) self.player_1 = PracticePlayer(self.parser, global_data.player_num, global_data.session_data[global_data.player_num].selected_difficulty, False, global_data.modifiers[global_data.player_num])
notes, branch_m, branch_e, branch_n = self.tja.notes_to_position(self.player_1.difficulty) notes, branch_m, branch_e, branch_n = self.parser.notes_to_position(self.player_1.difficulty)
self.scrobble_timeline = notes.timeline self.scrobble_timeline = notes.timeline
_, self.scrobble_note_list, self.bars = apply_modifiers(notes, self.player_1.modifiers) _, self.scrobble_note_list, self.bars = apply_modifiers(notes, self.player_1.modifiers)
self.start_ms = (get_current_ms() - self.tja.metadata.offset*1000) self.start_ms = (get_current_ms() - self.parser.metadata.offset*1000)
self.scrobble_index = 0 self.scrobble_index = 0
self.scrobble_time = self.bars[self.scrobble_index].hit_ms self.scrobble_time = self.bars[self.scrobble_index].hit_ms
self.scrobble_move = Animation.create_move(200, total_distance=0) self.scrobble_move = Animation.create_move(200, total_distance=0)
@@ -77,7 +99,7 @@ class PracticeGameScreen(GameScreen):
start_time = self.bars[previous_bar_index].hit_ms - first_bar_time + self.start_delay start_time = self.bars[previous_bar_index].hit_ms - first_bar_time + self.start_delay
tja_copy = copy.deepcopy(self.scrobbling_tja) tja_copy = copy.deepcopy(self.scrobbling_tja)
self.player_1.tja = tja_copy self.player_1.parser = tja_copy
self.player_1.reset_chart() self.player_1.reset_chart()
self.player_1.don_notes = deque([note for note in self.player_1.don_notes if note.hit_ms > resume_time]) self.player_1.don_notes = deque([note for note in self.player_1.don_notes if note.hit_ms > resume_time])
@@ -89,7 +111,7 @@ class PracticeGameScreen(GameScreen):
self.pause_time = start_time self.pause_time = start_time
audio.play_music_stream(self.song_music, 'music') audio.play_music_stream(self.song_music, 'music')
audio.seek_music_stream(self.song_music, (self.pause_time - self.start_delay)/1000 - self.tja.metadata.offset) audio.seek_music_stream(self.song_music, (self.pause_time - self.start_delay)/1000 - self.parser.metadata.offset)
self.song_started = True self.song_started = True
self.start_ms = get_current_ms() - self.pause_time self.start_ms = get_current_ms() - self.pause_time
@@ -135,7 +157,7 @@ class PracticeGameScreen(GameScreen):
if self.transition.is_finished: if self.transition.is_finished:
self.start_song(self.current_ms) self.start_song(self.current_ms)
else: else:
self.start_ms = current_time - self.tja.metadata.offset*1000 self.start_ms = current_time - self.parser.metadata.offset*1000
self.update_background(current_time) self.update_background(current_time)
if self.song_music is not None: if self.song_music is not None:
@@ -290,7 +312,7 @@ class PracticePlayer(Player):
self.check_note(ms_from_start, drum_type, current_time, background) self.check_note(ms_from_start, drum_type, current_time, background)
def spawn_hit_effects(self, drum_type: DrumType, side: Side): def spawn_hit_effects(self, drum_type: DrumType, side: Side):
self.lane_hit_effect = LaneHitEffect(drum_type, self.is_2p) self.lane_hit_effect = LaneHitEffect(drum_type, Judgments.BAD, self.is_2p)
self.draw_drum_hit_list.append(PracticeDrumHitEffect(drum_type, side, self.is_2p, player_num=self.player_num)) self.draw_drum_hit_list.append(PracticeDrumHitEffect(drum_type, side, self.is_2p, player_num=self.player_num))
def draw_overlays(self, mask_shader: ray.Shader): def draw_overlays(self, mask_shader: ray.Shader):

View File

@@ -1,9 +1,10 @@
import logging import logging
import pyray as ray import pyray as ray
from libs.global_data import Difficulty, PlayerNum, reset_session
from libs.audio import audio from libs.audio import audio
from libs.chara_2d import Chara2D from libs.chara_2d import Chara2D
from libs.global_data import Difficulty, PlayerNum, reset_session
from libs.global_objects import AllNetIcon, CoinOverlay, Nameplate from libs.global_objects import AllNetIcon, CoinOverlay, Nameplate
from libs.screen import Screen from libs.screen import Screen
from libs.texture import tex from libs.texture import tex
@@ -12,7 +13,7 @@ from libs.utils import (
get_current_ms, get_current_ms,
global_data, global_data,
is_l_don_pressed, is_l_don_pressed,
is_r_don_pressed is_r_don_pressed,
) )
from scenes.game import ScoreMethod from scenes.game import ScoreMethod

View File

@@ -1,276 +1,502 @@
import json
import logging import logging
import pyray as ray import pyray as ray
from libs.animation import Animation
from libs.audio import audio from libs.audio import audio
from libs.config import get_key_string, save_config
from libs.global_objects import AllNetIcon, CoinOverlay, Indicator
from libs.screen import Screen from libs.screen import Screen
from libs.texture import tex from libs.texture import tex
from libs.utils import ( from libs.utils import (
OutlinedText,
get_current_ms,
global_data, global_data,
is_l_don_pressed, is_l_don_pressed,
is_l_kat_pressed, is_l_kat_pressed,
is_r_don_pressed, is_r_don_pressed,
is_r_kat_pressed, is_r_kat_pressed,
) )
from libs.config import save_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BaseOptionBox:
def __init__(self, name: str, description: str, path: str, values: dict):
self.name = OutlinedText(name, 30, ray.WHITE)
self.setting_header, self.setting_name = path.split('/')
self.description = description
self.is_highlighted = False
self.value = global_data.config[self.setting_header][self.setting_name]
def update(self, current_time):
pass
def move_left(self):
pass
def move_right(self):
pass
def confirm(self):
global_data.config[self.setting_header][self.setting_name] = self.value
def __repr__(self):
return str(self.__dict__)
def draw(self):
tex.draw_texture('background', 'overlay', scale=0.70)
if self.is_highlighted:
tex.draw_texture('background', 'title_highlight')
else:
tex.draw_texture('background', 'title')
text_x = tex.textures['background']['title'].x[0] + (tex.textures['background']['title'].width//2) - (self.name.texture.width//2)
text_y = tex.textures['background']['title'].y[0] + self.name.texture.height//4
self.name.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
ray.draw_text_ex(global_data.font, self.description, (450 * tex.screen_scale, 270 * tex.screen_scale), 25 * tex.screen_scale, 1, ray.BLACK)
class BoolOptionBox(BaseOptionBox):
def __init__(self, name: str, description: str, path: str, values: dict):
super().__init__(name, description, path, values)
language = global_data.config["general"]["language"]
self.on_value = OutlinedText(values["true"].get(language, values["true"]["en"]), int(30 * tex.screen_scale), ray.WHITE)
self.off_value = OutlinedText(values["false"].get(language, values["false"]["en"]), int(30 * tex.screen_scale), ray.WHITE)
def move_left(self):
self.value = False
def move_right(self):
self.value = True
def draw(self):
super().draw()
if not self.value:
tex.draw_texture('option', 'button_on', index=0)
else:
tex.draw_texture('option', 'button_off', index=0)
text_x = tex.textures["option"]["button_on"].x[0] + (tex.textures["option"]["button_on"].width//2) - (self.off_value.texture.width//2)
text_y = tex.textures["option"]["button_on"].y[0] + (tex.textures["option"]["button_on"].height//2) - (self.off_value.texture.height//2)
self.off_value.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
if self.value:
tex.draw_texture('option', 'button_on', index=1)
else:
tex.draw_texture('option', 'button_off', index=1)
text_x = tex.textures["option"]["button_on"].x[1] + (tex.textures["option"]["button_on"].width//2) - (self.on_value.texture.width//2)
text_y = tex.textures["option"]["button_on"].y[1] + (tex.textures["option"]["button_on"].height//2) - (self.on_value.texture.height//2)
self.on_value.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
class IntOptionBox(BaseOptionBox):
def __init__(self, name: str, description: str, path: str, values: dict):
super().__init__(name, description, path, values)
self.value_text = OutlinedText(str(self.value), int(30 * tex.screen_scale), ray.WHITE)
self.flicker_fade = Animation.create_fade(400, initial_opacity=0.0, final_opacity=1.0, reverse_delay=0, loop=True)
self.flicker_fade.start()
language = global_data.config["general"]["language"]
self.value_list = []
if values != dict():
self.value_list = list(values.keys())
self.value_index = 0
self.values = values
self.value_text = OutlinedText(self.values[str(self.value)].get(language, self.values[str(self.value)]["en"]), int(30 * tex.screen_scale), ray.WHITE)
def update(self, current_time):
self.flicker_fade.update(current_time)
def move_left(self):
if self.value_list:
self.value_index = max(self.value_index - 1, 0)
self.value = int(self.value_list[self.value_index])
self.value_text = OutlinedText(self.values[str(self.value)].get(global_data.config["general"]["language"], self.values[str(self.value)]["en"]), int(30 * tex.screen_scale), ray.WHITE)
else:
self.value -= 1
self.value_text = OutlinedText(str(self.value), int(30 * tex.screen_scale), ray.WHITE)
def move_right(self):
if self.value_list:
self.value_index = min(self.value_index + 1, len(self.value_list) - 1)
self.value = int(self.value_list[self.value_index])
self.value_text = OutlinedText(self.values[str(self.value)].get(global_data.config["general"]["language"], self.values[str(self.value)]["en"]), int(30 * tex.screen_scale), ray.WHITE)
else:
self.value += 1
self.value_text = OutlinedText(str(self.value), int(30 * tex.screen_scale), ray.WHITE)
def draw(self):
super().draw()
tex.draw_texture('option', 'button_off', index=2)
if self.is_highlighted:
tex.draw_texture('option', 'button_on', index=2, fade=self.flicker_fade.attribute)
text_x = tex.textures["option"]["button_on"].x[2] + (tex.textures["option"]["button_on"].width//2) - (self.value_text.texture.width//2)
text_y = tex.textures["option"]["button_on"].y[2] + (tex.textures["option"]["button_on"].height//2) - (self.value_text.texture.height//2)
self.value_text.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
class StrOptionBox(BaseOptionBox):
def __init__(self, name: str, description: str, path: str, values: dict):
super().__init__(name, description, path, values)
self.flicker_fade = Animation.create_fade(400, initial_opacity=0.0, final_opacity=1.0, reverse_delay=0, loop=True)
self.flicker_fade.start()
self.value_list = []
if values != dict():
self.value_list = list(values.keys())
self.value_index = 0
self.values = values
language = global_data.config["general"]["language"]
self.value_text = OutlinedText(self.values[self.value].get(language, self.values[self.value]["en"]), int(30 * tex.screen_scale), ray.WHITE)
else:
self.string = self.value
self.value_text = OutlinedText(self.value, int(30 * tex.screen_scale), ray.WHITE)
def update(self, current_time):
self.flicker_fade.update(current_time)
if self.is_highlighted and self.value_list == []:
if ray.is_key_pressed(ray.KeyboardKey.KEY_BACKSPACE):
self.string = self.string[:-1]
self.value_text = OutlinedText(self.string, int(30 * tex.screen_scale), ray.WHITE)
elif ray.is_key_pressed(ray.KeyboardKey.KEY_ENTER):
self.value = self.string
self.is_highlighted = False
key = ray.get_char_pressed()
if key > 0:
self.string += chr(key)
self.value_text = OutlinedText(self.string, int(30 * tex.screen_scale), ray.WHITE)
def move_left(self):
if self.value_list:
self.value_index = max(self.value_index - 1, 0)
self.value = self.value_list[self.value_index]
self.value_text = OutlinedText(self.values[self.value].get(global_data.config["general"]["language"], self.values[self.value]["en"]), int(30 * tex.screen_scale), ray.WHITE)
def move_right(self):
if self.value_list:
self.value_index = min(self.value_index + 1, len(self.value_list) - 1)
self.value = self.value_list[self.value_index]
self.value_text = OutlinedText(self.values[self.value].get(global_data.config["general"]["language"], self.values[self.value]["en"]), int(30 * tex.screen_scale), ray.WHITE)
def draw(self):
super().draw()
tex.draw_texture('option', 'button_off', index=2)
if self.is_highlighted:
tex.draw_texture('option', 'button_on', index=2, fade=self.flicker_fade.attribute)
text_x = tex.textures["option"]["button_on"].x[2] + (tex.textures["option"]["button_on"].width//2) - (self.value_text.texture.width//2)
text_y = tex.textures["option"]["button_on"].y[2] + (tex.textures["option"]["button_on"].height//2) - (self.value_text.texture.height//2)
self.value_text.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
class KeybindOptionBox(BaseOptionBox):
def __init__(self, name: str, description: str, path: str, values: dict):
super().__init__(name, description, path, values)
if isinstance(self.value, list):
text = ', '.join([get_key_string(key) for key in self.value])
else:
text = get_key_string(self.value)
self.value_text = OutlinedText(text, int(30 * tex.screen_scale), ray.WHITE)
self.flicker_fade = Animation.create_fade(400, initial_opacity=0.0, final_opacity=1.0, reverse_delay=0, loop=True)
self.flicker_fade.start()
def update(self, current_time):
self.flicker_fade.update(current_time)
if self.is_highlighted:
key = ray.get_key_pressed()
if key > 0:
self.value = key
audio.play_sound('don', 'sound')
self.value_text = OutlinedText(get_key_string(self.value), int(30 * tex.screen_scale), ray.WHITE)
self.is_highlighted = False
def draw(self):
super().draw()
tex.draw_texture('option', 'button_off', index=2)
if self.is_highlighted:
tex.draw_texture('option', 'button_on', index=2, fade=self.flicker_fade.attribute)
text_x = tex.textures["option"]["button_on"].x[2] + (tex.textures["option"]["button_on"].width//2) - (self.value_text.texture.width//2)
text_y = tex.textures["option"]["button_on"].y[2] + (tex.textures["option"]["button_on"].height//2) - (self.value_text.texture.height//2)
self.value_text.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
class KeyBindControllerOptionBox(BaseOptionBox):
def __init__(self, name: str, description: str, path: str, values: dict):
super().__init__(name, description, path, values)
if isinstance(self.value, list):
text = ', '.join([str(key) for key in self.value])
else:
text = str(self.value)
self.value_text = OutlinedText(text, int(30 * tex.screen_scale), ray.WHITE)
self.flicker_fade = Animation.create_fade(400, initial_opacity=0.0, final_opacity=1.0, reverse_delay=0, loop=True)
self.flicker_fade.start()
def update(self, current_time):
self.flicker_fade.update(current_time)
if self.is_highlighted:
key = ray.get_gamepad_button_pressed()
if key > 0:
self.value = key
audio.play_sound('don', 'sound')
self.value_text = OutlinedText(str(self.value), int(30 * tex.screen_scale), ray.WHITE)
self.is_highlighted = False
def draw(self):
super().draw()
tex.draw_texture('option', 'button_off', index=2)
if self.is_highlighted:
tex.draw_texture('option', 'button_on', index=2, fade=self.flicker_fade.attribute)
text_x = tex.textures["option"]["button_on"].x[2] + (tex.textures["option"]["button_on"].width//2) - (self.value_text.texture.width//2)
text_y = tex.textures["option"]["button_on"].y[2] + (tex.textures["option"]["button_on"].height//2) - (self.value_text.texture.height//2)
self.value_text.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
class FloatOptionBox(BaseOptionBox):
def __init__(self, name: str, description: str, path: str, values: dict):
super().__init__(name, description, path, values)
self.value_text = OutlinedText(str(int(self.value*100)) + "%", int(30 * tex.screen_scale), ray.WHITE)
self.flicker_fade = Animation.create_fade(400, initial_opacity=0.0, final_opacity=1.0, reverse_delay=0, loop=True)
self.flicker_fade.start()
def update(self, current_time):
self.flicker_fade.update(current_time)
def move_left(self):
self.value = ((self.value*100) - 1) / 100
self.value_text = OutlinedText(str(int(self.value*100))+"%", int(30 * tex.screen_scale), ray.WHITE)
def move_right(self):
self.value = ((self.value*100) + 1) / 100
self.value_text = OutlinedText(str(int(self.value*100))+"%", int(30 * tex.screen_scale), ray.WHITE)
def draw(self):
super().draw()
tex.draw_texture('option', 'button_off', index=2)
if self.is_highlighted:
tex.draw_texture('option', 'button_on', index=2, fade=self.flicker_fade.attribute)
text_x = tex.textures["option"]["button_on"].x[2] + (tex.textures["option"]["button_on"].width//2) - (self.value_text.texture.width//2)
text_y = tex.textures["option"]["button_on"].y[2] + (tex.textures["option"]["button_on"].height//2) - (self.value_text.texture.height//2)
self.value_text.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
class Box:
"""Box class for the entry screen"""
OPTION_BOX_MAP = {
"int": IntOptionBox,
"bool": BoolOptionBox,
"string": StrOptionBox,
"keybind": KeybindOptionBox,
"keybind_controller": KeyBindControllerOptionBox,
"float": FloatOptionBox
}
def __init__(self, name: str, text: str, box_options: dict):
self.name = name
self.text = OutlinedText(text, tex.skin_config["entry_box_text"].font_size - int(5*tex.screen_scale), ray.WHITE, outline_thickness=5)
self.x = 10 * tex.screen_scale
self.y = -50 * tex.screen_scale
self.move = tex.get_animation(0)
self.blue_arrow_fade = tex.get_animation(1)
self.blue_arrow_move = tex.get_animation(2)
self.is_selected = False
self.in_box = False
self.outline_color = ray.Color(109, 68, 24, 255)
self.direction = 1
self.target_position = float('inf')
self.start_position = self.y
self.option_index = 0
language = global_data.config["general"]["language"]
self.options = [Box.OPTION_BOX_MAP[
box_options[option]["type"]](box_options[option]["name"].get(language, box_options[option]["name"]["en"]),
box_options[option]["description"].get(language, box_options[option]["description"]["en"]), box_options[option]["path"],
box_options[option]["values"]) for option in box_options]
def __repr__(self):
return str(self.__dict__)
def move_left(self):
"""Move the box left"""
if self.y != self.target_position and self.target_position != float('inf'):
return False
self.move.start()
self.direction = 1
self.start_position = self.y
self.target_position = self.y + (100 * tex.screen_scale * self.direction)
if self.target_position >= 650:
self.target_position = -50 + (self.target_position - 650)
return True
def move_right(self):
"""Move the box right"""
if self.y != self.target_position and self.target_position != float('inf'):
return False
self.move.start()
self.start_position = self.y
self.direction = -1
self.target_position = self.y + (100 * tex.screen_scale * self.direction)
if self.target_position < -50:
self.target_position = 650 + (self.target_position + 50)
return True
def move_option_left(self):
if self.options[self.option_index].is_highlighted:
self.options[self.option_index].move_left()
return True
else:
if self.option_index == 0:
self.in_box = False
return False
self.option_index -= 1
return True
def move_option_right(self):
if self.options[self.option_index].is_highlighted:
self.options[self.option_index].move_right()
else:
self.option_index = min(self.option_index + 1, len(self.options) - 1)
def select_option(self):
self.options[self.option_index].is_highlighted = not self.options[self.option_index].is_highlighted
self.options[self.option_index].confirm()
def select(self):
self.in_box = True
def update(self, current_time_ms: float, is_selected: bool):
self.move.update(current_time_ms)
self.blue_arrow_fade.update(current_time_ms)
self.blue_arrow_move.update(current_time_ms)
self.is_selected = is_selected
if self.move.is_finished:
self.y = self.target_position
else:
self.y = self.start_position + (self.move.attribute * self.direction)
for option in self.options:
option.update(current_time_ms)
def _draw_highlighted(self):
tex.draw_texture('box', 'box_highlight', x=self.x, y=self.y)
def _draw_text(self):
text_x = self.x + (tex.textures['box']['box'].width//2) - (self.text.texture.width//2)
text_y = self.y + (tex.textures['box']['box'].height//2) - (self.text.texture.height//2)
if self.is_selected:
self.text.draw(outline_color=ray.BLACK, x=text_x, y=text_y)
else:
if self.name == 'exit':
self.text.draw(outline_color=ray.RED, x=text_x, y=text_y)
else:
self.text.draw(outline_color=self.outline_color, x=text_x, y=text_y)
def draw(self):
tex.draw_texture('box', 'box', x=self.x, y=self.y)
if self.is_selected:
self._draw_highlighted()
if self.in_box:
self.options[self.option_index].draw()
if not self.options[self.option_index].is_highlighted:
tex.draw_texture('background', 'blue_arrow', index=0, x=-self.blue_arrow_move.attribute, fade=self.blue_arrow_fade.attribute)
if self.option_index != len(self.options) - 1:
tex.draw_texture('background', 'blue_arrow', index=1, x=self.blue_arrow_move.attribute, fade=self.blue_arrow_fade.attribute, mirror='horizontal')
self._draw_text()
class BoxManager:
"""BoxManager class for the entry screen"""
def __init__(self, settings_template: dict):
language = global_data.config["general"]["language"]
self.boxes = [Box(config_name, settings_template[config_name]["name"].get(language, settings_template[config_name]["name"]["en"]), settings_template[config_name]["options"]) for config_name in settings_template]
self.num_boxes = len(self.boxes)
self.selected_box_index = 3
self.box_selected = False
for i, box in enumerate(self.boxes):
box.y += 100*i
box.start_position += 100*i
def move_left(self):
"""Move the cursor to the left"""
if self.box_selected:
box = self.boxes[self.selected_box_index]
self.box_selected = box.move_option_left()
else:
moved = True
for box in self.boxes:
if not box.move_left():
moved = False
if moved:
self.selected_box_index = (self.selected_box_index - 1) % self.num_boxes
def move_right(self):
"""Move the cursor to the right"""
if self.box_selected:
box = self.boxes[self.selected_box_index]
box.move_option_right()
else:
moved = True
for box in self.boxes:
if not box.move_right():
moved = False
if moved:
self.selected_box_index = (self.selected_box_index + 1) % self.num_boxes
def select_box(self):
if self.boxes[self.selected_box_index].name == "exit":
return "exit"
if self.box_selected:
box = self.boxes[self.selected_box_index]
box.select_option()
else:
self.box_selected = True
self.boxes[self.selected_box_index].in_box = True
def update(self, current_time_ms: float):
for i, box in enumerate(self.boxes):
is_selected = i == self.selected_box_index and not self.box_selected
box.update(current_time_ms, is_selected)
def draw(self):
for box in self.boxes:
box.draw()
class SettingsScreen(Screen): class SettingsScreen(Screen):
def on_screen_start(self): def on_screen_start(self):
super().on_screen_start() super().on_screen_start()
self.config = global_data.config self.indicator = Indicator(Indicator.State.SELECT)
self.headers = list(self.config.keys()) self.template = json.loads((tex.graphics_path / "settings_template.json").read_text(encoding='utf-8'))
self.headers.append('Exit') self.box_manager = BoxManager(self.template)
self.header_index = 0 self.coin_overlay = CoinOverlay()
self.setting_index = 0 self.allnet_indicator = AllNetIcon()
self.in_setting_edit = False audio.play_sound('bgm', 'music')
self.editing_key = False
self.editing_gamepad = False
def on_screen_end(self, next_screen: str): def on_screen_end(self, next_screen: str):
save_config(self.config) save_config(global_data.config)
global_data.config = self.config
audio.close_audio_device() audio.close_audio_device()
audio.device_type = global_data.config["audio"]["device_type"] audio.device_type = global_data.config["audio"]["device_type"]
sample_rate = global_data.config["audio"]["sample_rate"] audio.target_sample_rate = global_data.config["audio"]["sample_rate"]
if sample_rate < 0:
sample_rate = 44100
audio.target_sample_rate = sample_rate
audio.buffer_size = global_data.config["audio"]["buffer_size"] audio.buffer_size = global_data.config["audio"]["buffer_size"]
audio.volume_presets = global_data.config["volume"] audio.volume_presets = global_data.config["volume"]
audio.init_audio_device() audio.init_audio_device()
logger.info("Settings saved and audio device re-initialized") logger.info("Settings saved and audio device re-initialized")
return next_screen return super().on_screen_end(next_screen)
def get_current_settings(self): def handle_input(self):
"""Get the current section's settings as a list""" if is_l_kat_pressed():
current_header = self.headers[self.header_index] audio.play_sound('kat', 'sound')
if current_header == 'Exit' or current_header not in self.config: self.box_manager.move_left()
return [] elif is_r_kat_pressed():
return list(self.config[current_header].items()) audio.play_sound('kat', 'sound')
self.box_manager.move_right()
def handle_boolean_toggle(self, section, key): elif is_l_don_pressed() or is_r_don_pressed():
"""Toggle boolean values""" audio.play_sound('don', 'sound')
self.config[section][key] = not self.config[section][key] box_name = self.box_manager.select_box()
logger.info(f"Toggled boolean setting: {section}.{key} -> {self.config[section][key]}") if box_name == 'exit':
return self.on_screen_end("ENTRY")
def handle_numeric_change(self, section, key, increment):
"""Handle numeric value changes"""
current_value = self.config[section][key]
# Define step sizes for different settings
step_sizes = {
'judge_offset': 1,
'visual_offset': 1,
'sample_rate': 1000,
}
step = step_sizes.get(key, 1)
new_value = current_value + (step * increment)
if key == 'sample_rate':
valid_rates = [-1, 22050, 44100, 48000, 88200, 96000]
current_idx = valid_rates.index(current_value) if current_value in valid_rates else 2
new_idx = max(0, min(len(valid_rates) - 1, current_idx + increment))
new_value = valid_rates[new_idx]
if key == 'buffer_size':
valid_sizes = [-1, 32, 64, 128, 256, 512, 1024]
current_idx = valid_sizes.index(current_value) if current_value in valid_sizes else 2
new_idx = max(0, min(len(valid_sizes) - 1, current_idx + increment))
new_value = valid_sizes[new_idx]
self.config[section][key] = new_value
logger.info(f"Changed numeric setting: {section}.{key} -> {new_value}")
def handle_string_cycle(self, section, key):
"""Cycle through predefined string values"""
current_value = self.config[section][key]
options = {
'language': ['ja', 'en'],
}
if key in options:
values = options[key]
try:
current_idx = values.index(current_value)
new_idx = (current_idx + 1) % len(values)
self.config[section][key] = values[new_idx]
except ValueError:
self.config[section][key] = values[0]
logger.info(f"Cycled string setting: {section}.{key} -> {self.config[section][key]}")
def handle_key_binding(self, section, key):
"""Handle key binding changes"""
self.editing_key = True
logger.info(f"Started key binding edit for: {section}.{key}")
def update_key_binding(self):
"""Update key binding based on input"""
key_pressed = ray.get_key_pressed()
if key_pressed != 0:
# Convert keycode to character
if 65 <= key_pressed <= 90: # A-Z
new_key = chr(key_pressed)
current_header = self.headers[self.header_index]
settings = self.get_current_settings()
if settings:
setting_key, _ = settings[self.setting_index]
self.config[current_header][setting_key] = [new_key]
self.editing_key = False
logger.info(f"Key binding updated: {current_header}.{setting_key} -> {new_key}")
elif key_pressed == global_data.config["keys"]["back_key"]:
self.editing_key = False
logger.info("Key binding edit cancelled")
def handle_gamepad_binding(self, section, key):
self.editing_gamepad = True
logger.info(f"Started gamepad binding edit for: {section}.{key}")
def update_gamepad_binding(self):
"""Update gamepad binding based on input"""
button_pressed = ray.get_gamepad_button_pressed()
if button_pressed != 0:
current_header = self.headers[self.header_index]
settings = self.get_current_settings()
if settings:
setting_key, _ = settings[self.setting_index]
self.config[current_header][setting_key] = [button_pressed]
self.editing_gamepad = False
logger.info(f"Gamepad binding updated: {current_header}.{setting_key} -> {button_pressed}")
if ray.is_key_pressed(global_data.config["keys"]["back_key"]):
self.editing_gamepad = False
logger.info("Gamepad binding edit cancelled")
def update(self): def update(self):
super().update() super().update()
# Handle key binding editing current_time = get_current_ms()
if self.editing_key: self.indicator.update(current_time)
self.update_key_binding() self.box_manager.update(current_time)
return return self.handle_input()
if self.editing_gamepad:
self.update_gamepad_binding()
return
current_header = self.headers[self.header_index]
# Exit handling
if current_header == 'Exit' and (is_l_don_pressed() or is_r_don_pressed()):
logger.info("Exiting settings screen")
return self.on_screen_end("ENTRY")
# Navigation between sections
if not self.in_setting_edit:
if is_r_kat_pressed():
self.header_index = (self.header_index + 1) % len(self.headers)
self.setting_index = 0
logger.info(f"Navigated to next section: {self.headers[self.header_index]}")
elif is_l_kat_pressed():
self.header_index = (self.header_index - 1) % len(self.headers)
self.setting_index = 0
logger.info(f"Navigated to previous section: {self.headers[self.header_index]}")
elif (is_l_don_pressed() or is_r_don_pressed()) and current_header != 'Exit':
self.in_setting_edit = True
logger.info(f"Entered section edit: {current_header}")
else:
# Navigation within settings
settings = self.get_current_settings()
if not settings:
self.in_setting_edit = False
return
if is_r_kat_pressed():
self.setting_index = (self.setting_index + 1) % len(settings)
logger.info(f"Navigated to next setting: {settings[self.setting_index][0]}")
elif is_l_kat_pressed():
self.setting_index = (self.setting_index - 1) % len(settings)
logger.info(f"Navigated to previous setting: {settings[self.setting_index][0]}")
elif is_r_don_pressed():
# Modify setting value
setting_key, setting_value = settings[self.setting_index]
if isinstance(setting_value, bool):
self.handle_boolean_toggle(current_header, setting_key)
elif isinstance(setting_value, (int, float)):
self.handle_numeric_change(current_header, setting_key, 1)
elif isinstance(setting_value, str):
if 'keys' in current_header:
self.handle_key_binding(current_header, setting_key)
elif 'gamepad' in current_header:
self.handle_gamepad_binding(current_header, setting_key)
else:
self.handle_string_cycle(current_header, setting_key)
elif isinstance(setting_value, list) and len(setting_value) > 0:
if isinstance(setting_value[0], str) and len(setting_value[0]) == 1:
# Key binding
self.handle_key_binding(current_header, setting_key)
elif isinstance(setting_value[0], int):
self.handle_gamepad_binding(current_header, setting_key)
elif is_l_don_pressed():
# Modify setting value (reverse direction for numeric)
setting_key, setting_value = settings[self.setting_index]
if isinstance(setting_value, bool):
self.handle_boolean_toggle(current_header, setting_key)
elif isinstance(setting_value, (int, float)):
self.handle_numeric_change(current_header, setting_key, -1)
elif isinstance(setting_value, str):
if ('keys' not in current_header) and ('gamepad' not in current_header):
self.handle_string_cycle(current_header, setting_key)
elif ray.is_key_pressed(global_data.config["keys"]["back_key"]):
self.in_setting_edit = False
logger.info("Exited section edit")
def draw(self): def draw(self):
ray.draw_rectangle(0, 0, tex.screen_width, tex.screen_height, ray.BLACK) tex.draw_texture('background', 'background')
# Draw title self.box_manager.draw()
ray.draw_rectangle(0, 0, tex.screen_width, tex.screen_height, ray.BLACK) tex.draw_texture('background', 'footer')
ray.draw_text("SETTINGS", 20, 20, 30, ray.WHITE) self.indicator.draw(tex.skin_config['song_select_indicator'].x, tex.skin_config['song_select_indicator'].y)
self.coin_overlay.draw()
# Draw section headers self.allnet_indicator.draw()
current_header = self.headers[self.header_index]
for i, key in enumerate(self.headers):
color = ray.GREEN
if key == current_header:
color = ray.YELLOW if not self.in_setting_edit else ray.ORANGE
ray.draw_text(f'{key}', 20, i*25 + 70, 20, color)
# Draw current section settings
if current_header != 'Exit' and current_header in self.config:
settings = self.get_current_settings()
# Draw settings list
for i, (key, value) in enumerate(settings):
color = ray.GREEN
if self.in_setting_edit and i == self.setting_index:
color = ray.YELLOW
# Format value display
if isinstance(value, list):
display_value = ', '.join(map(str, value))
else:
display_value = str(value)
if key == 'device_type' and not isinstance(value, list):
display_value = f'{display_value} ({audio.get_host_api_name(value)})'
ray.draw_text(f'{key}: {display_value}', 250, i*25 + 70, 20, color)
# Draw instructions
y_offset = len(settings) * 25 + 150
if not self.in_setting_edit:
ray.draw_text("Don/Kat: Navigate sections", 20, y_offset, 16, ray.GRAY)
ray.draw_text("L/R Don: Enter section", 20, y_offset + 20, 16, ray.GRAY)
else:
ray.draw_text("Don/Kat: Navigate settings", 20, y_offset, 16, ray.GRAY)
ray.draw_text("L/R Don: Modify value", 20, y_offset + 20, 16, ray.GRAY)
ray.draw_text("ESC: Back to sections", 20, y_offset + 40, 16, ray.GRAY)
if self.editing_key:
ray.draw_text("Press a key to bind (ESC to cancel)", 20, y_offset + 60, 16, ray.RED)
else:
# Draw exit instruction
ray.draw_text("Press Don to exit settings", 250, 100, 20, ray.GREEN)

View File

@@ -1,18 +1,32 @@
import logging
import random import random
from dataclasses import fields from dataclasses import fields
from pathlib import Path from pathlib import Path
import pyray as ray import pyray as ray
import logging
from raylib import SHADER_UNIFORM_VEC3 from raylib import SHADER_UNIFORM_VEC3
from libs.file_navigator import DEFAULT_COLORS, BackBox, DanCourse, GenreIndex, navigator
from libs.audio import audio from libs.audio import audio
from libs.chara_2d import Chara2D from libs.chara_2d import Chara2D
from libs.file_navigator import Directory, SongBox, SongFile from libs.file_navigator import (
DEFAULT_COLORS,
BackBox,
DanCourse,
Directory,
GenreIndex,
SongBox,
SongFile,
SongFileOsu,
navigator,
)
from libs.global_data import Difficulty, Modifiers, PlayerNum from libs.global_data import Difficulty, Modifiers, PlayerNum
from libs.global_objects import AllNetIcon, CoinOverlay, Nameplate, Indicator, Timer from libs.global_objects import (
AllNetIcon,
CoinOverlay,
Indicator,
Nameplate,
Timer,
)
from libs.screen import Screen from libs.screen import Screen
from libs.texture import tex from libs.texture import tex
from libs.transition import Transition from libs.transition import Transition
@@ -32,6 +46,7 @@ class State:
BROWSING = 0 BROWSING = 0
SONG_SELECTED = 1 SONG_SELECTED = 1
DIFF_SORTING = 2 DIFF_SORTING = 2
SEARCHING = 3
class SongSelectScreen(Screen): class SongSelectScreen(Screen):
BOX_CENTER = 444 BOX_CENTER = 444
@@ -56,6 +71,7 @@ class SongSelectScreen(Screen):
self.game_transition = None self.game_transition = None
self.demo_song = None self.demo_song = None
self.diff_sort_selector = None self.diff_sort_selector = None
self.search_box = None
self.coin_overlay = CoinOverlay() self.coin_overlay = CoinOverlay()
self.allnet_indicator = AllNetIcon() self.allnet_indicator = AllNetIcon()
self.indicator = Indicator(Indicator.State.SELECT) self.indicator = Indicator(Indicator.State.SELECT)
@@ -69,6 +85,8 @@ class SongSelectScreen(Screen):
self.dan_transition = DanTransition() self.dan_transition = DanTransition()
self.shader = ray.load_shader('shader/dummy.vs', 'shader/colortransform.fs') self.shader = ray.load_shader('shader/dummy.vs', 'shader/colortransform.fs')
self.color = None self.color = None
song_format = tex.skin_config["song_num"].text[global_data.config["general"]["language"]]
self.song_num = OutlinedText(song_format.format(global_data.songs_played+1), tex.skin_config["song_num"].font_size, ray.WHITE)
self.load_shader_values(self.color) self.load_shader_values(self.color)
session_data = global_data.session_data[global_data.player_num] session_data = global_data.session_data[global_data.player_num]
@@ -84,7 +102,7 @@ class SongSelectScreen(Screen):
self.navigator.mark_crowns_dirty_for_song(selected_song) self.navigator.mark_crowns_dirty_for_song(selected_song)
curr_item = self.navigator.get_current_item() curr_item = self.navigator.get_current_item()
if isinstance(curr_item, SongFile): if not isinstance(curr_item, Directory):
curr_item.box.get_scores() curr_item.box.get_scores()
self.navigator.add_recent() self.navigator.add_recent()
@@ -99,7 +117,7 @@ class SongSelectScreen(Screen):
ray.set_shader_value(self.shader, source_loc, source_color, SHADER_UNIFORM_VEC3) ray.set_shader_value(self.shader, source_loc, source_color, SHADER_UNIFORM_VEC3)
ray.set_shader_value(self.shader, target_loc, target_color, SHADER_UNIFORM_VEC3) ray.set_shader_value(self.shader, target_loc, target_color, SHADER_UNIFORM_VEC3)
def finalize_song(self, current_item: SongFile): def finalize_song(self, current_item: SongFile | SongFileOsu):
global_data.session_data[global_data.player_num].selected_song = current_item.path global_data.session_data[global_data.player_num].selected_song = current_item.path
global_data.session_data[global_data.player_num].song_hash = global_data.song_hashes[current_item.hash][0]["diff_hashes"][self.player_1.selected_difficulty] global_data.session_data[global_data.player_num].song_hash = global_data.song_hashes[current_item.hash][0]["diff_hashes"][self.player_1.selected_difficulty]
global_data.session_data[global_data.player_num].selected_difficulty = self.player_1.selected_difficulty global_data.session_data[global_data.player_num].selected_difficulty = self.player_1.selected_difficulty
@@ -109,7 +127,7 @@ class SongSelectScreen(Screen):
self.screen_init = False self.screen_init = False
self.reset_demo_music() self.reset_demo_music()
current_item = self.navigator.get_current_item() current_item = self.navigator.get_current_item()
if isinstance(current_item, SongFile): if (isinstance(current_item, SongFile) or isinstance(current_item, SongFileOsu)) and self.player_1.is_ready:
self.finalize_song(current_item) self.finalize_song(current_item)
self.player_1.nameplate.unload() self.player_1.nameplate.unload()
if isinstance(current_item.box, SongBox) and current_item.box.yellow_box is not None: if isinstance(current_item.box, SongBox) and current_item.box.yellow_box is not None:
@@ -152,6 +170,11 @@ class SongSelectScreen(Screen):
self.diff_sort_selector = DiffSortSelect(self.navigator.diff_sort_statistics, self.navigator.diff_sort_diff, self.navigator.diff_sort_level) self.diff_sort_selector = DiffSortSelect(self.navigator.diff_sort_statistics, self.navigator.diff_sort_diff, self.navigator.diff_sort_level)
self.text_fade_in.start() self.text_fade_in.start()
self.text_fade_out.start() self.text_fade_out.start()
elif action == "search":
self.state = State.SEARCHING
self.search_box = SearchBox()
self.text_fade_in.start()
self.text_fade_out.start()
elif action == "select_song": elif action == "select_song":
current_song = self.navigator.get_current_item() current_song = self.navigator.get_current_item()
if isinstance(current_song, Directory) and current_song.box.genre_index == GenreIndex.DAN: if isinstance(current_song, Directory) and current_song.box.genre_index == GenreIndex.DAN:
@@ -161,7 +184,7 @@ class SongSelectScreen(Screen):
audio.stop_sound('bgm') audio.stop_sound('bgm')
return return
selected_song = self.navigator.select_current_item() selected_song = self.navigator.select_current_item()
if isinstance(selected_song, SongFile): if isinstance(selected_song, SongFile) or isinstance(selected_song, SongFileOsu):
self.state = State.SONG_SELECTED self.state = State.SONG_SELECTED
self.player_1.on_song_selected(selected_song) self.player_1.on_song_selected(selected_song)
audio.play_sound('don', 'sound') audio.play_sound('don', 'sound')
@@ -210,6 +233,21 @@ class SongSelectScreen(Screen):
self.navigator.diff_sort_level = level self.navigator.diff_sort_level = level
self.navigator.select_current_item() self.navigator.select_current_item()
def handle_input_search(self):
if self.search_box is None:
raise Exception("search box was not able to be created")
result = self.player_1.handle_input_search()
self.search_box.current_search = self.player_1.search_string
if result is not None:
self.state = State.BROWSING
self.search_box = None
self.text_fade_out.reset()
self.text_fade_in.reset()
self.navigator.current_search = result
self.navigator.select_current_item()
def _cancel_selection(self): def _cancel_selection(self):
"""Reset to browsing state""" """Reset to browsing state"""
self.player_1.selected_song = False self.player_1.selected_song = False
@@ -247,12 +285,12 @@ class SongSelectScreen(Screen):
def check_for_selection(self): def check_for_selection(self):
if self.player_1.selected_diff_highlight_fade.is_finished and not audio.is_sound_playing(f'voice_start_song_{global_data.player_num}p') and self.game_transition is None: if self.player_1.selected_diff_highlight_fade.is_finished and not audio.is_sound_playing(f'voice_start_song_{global_data.player_num}p') and self.game_transition is None:
selected_song = self.navigator.get_current_item() selected_song = self.navigator.get_current_item()
if not isinstance(selected_song, SongFile): if not isinstance(selected_song, SongFile) and not isinstance(selected_song, SongFileOsu):
raise Exception("picked directory") raise Exception("picked directory")
title = selected_song.tja.metadata.title.get( title = selected_song.parser.metadata.title.get(
global_data.config['general']['language'], '') global_data.config['general']['language'], '')
subtitle = selected_song.tja.metadata.subtitle.get( subtitle = selected_song.parser.metadata.subtitle.get(
global_data.config['general']['language'], '') global_data.config['general']['language'], '')
self.game_transition = Transition(title, subtitle) self.game_transition = Transition(title, subtitle)
self.game_transition.start() self.game_transition.start()
@@ -309,6 +347,9 @@ class SongSelectScreen(Screen):
if self.diff_sort_selector is not None: if self.diff_sort_selector is not None:
self.diff_sort_selector.update(current_time) self.diff_sort_selector.update(current_time)
if self.search_box is not None:
self.search_box.update(current_time)
self.check_for_selection() self.check_for_selection()
for song in self.navigator.items: for song in self.navigator.items:
@@ -318,12 +359,12 @@ class SongSelectScreen(Screen):
if not isinstance(song, Directory) and song.box.is_open: if not isinstance(song, Directory) and song.box.is_open:
if self.demo_song is None and current_time >= song.box.wait + (83.33*3): if self.demo_song is None and current_time >= song.box.wait + (83.33*3):
song.box.get_scores() song.box.get_scores()
if song.tja.metadata.wave.exists() and song.tja.metadata.wave.is_file(): if song.parser.metadata.wave.exists() and song.parser.metadata.wave.is_file():
self.demo_song = audio.load_music_stream(song.tja.metadata.wave, 'demo_song') self.demo_song = audio.load_music_stream(song.parser.metadata.wave, 'demo_song')
audio.play_music_stream(self.demo_song, 'music') audio.play_music_stream(self.demo_song, 'music')
audio.seek_music_stream(self.demo_song, song.tja.metadata.demostart) audio.seek_music_stream(self.demo_song, song.parser.metadata.demostart)
audio.stop_sound('bgm') audio.stop_sound('bgm')
logger.info(f"Demo song loaded and playing for {song.tja.metadata.title}") logger.info(f"Demo song loaded and playing for {song.parser.metadata.title}")
if song.box.is_open: if song.box.is_open:
current_box = song.box current_box = song.box
if not isinstance(current_box, BackBox) and current_time >= song.box.wait + (83.33*3): if not isinstance(current_box, BackBox) and current_time >= song.box.wait + (83.33*3):
@@ -341,7 +382,7 @@ class SongSelectScreen(Screen):
def draw_players(self): def draw_players(self):
self.player_1.draw(self.state) self.player_1.draw(self.state)
def draw(self): def draw_background(self):
width = tex.textures['box']['background'].width width = tex.textures['box']['background'].width
genre_index = self.genre_index genre_index = self.genre_index
last_genre_index = self.last_genre_index last_genre_index = self.last_genre_index
@@ -354,6 +395,26 @@ class SongSelectScreen(Screen):
tex.draw_texture('box', 'background', frame=genre_index, x=i-self.background_move.attribute, fade=1 - self.background_fade_change.attribute) tex.draw_texture('box', 'background', frame=genre_index, x=i-self.background_move.attribute, fade=1 - self.background_fade_change.attribute)
ray.end_shader_mode() ray.end_shader_mode()
def draw_overlay(self):
self.indicator.draw(tex.skin_config['song_select_indicator'].x, tex.skin_config['song_select_indicator'].y)
tex.draw_texture('global', 'song_num_bg', fade=0.75, x=-(self.song_num.texture.width-127), x2=(self.song_num.texture.width-127))
self.song_num.draw(ray.BLACK, x=tex.skin_config["song_num"].x-self.song_num.texture.width, y=tex.skin_config["song_num"].y)
if self.state == State.BROWSING or self.state == State.DIFF_SORTING:
self.timer_browsing.draw()
elif self.state == State.SONG_SELECTED:
self.timer_selected.draw()
self.coin_overlay.draw()
if self.game_transition is not None:
self.game_transition.draw()
if self.dan_transition.is_started:
self.dan_transition.draw()
self.allnet_indicator.draw()
def draw(self):
self.draw_background()
self.draw_background_diffs() self.draw_background_diffs()
if self.navigator.genre_bg is not None and self.state == State.BROWSING: if self.navigator.genre_bg is not None and self.state == State.BROWSING:
@@ -371,6 +432,9 @@ class SongSelectScreen(Screen):
if self.diff_sort_selector is not None: if self.diff_sort_selector is not None:
self.diff_sort_selector.draw() self.diff_sort_selector.draw()
if self.search_box is not None:
self.search_box.draw()
if (self.player_1.selected_song and self.state == State.SONG_SELECTED): if (self.player_1.selected_song and self.state == State.SONG_SELECTED):
tex.draw_texture('global', 'difficulty_select', fade=self.text_fade_in.attribute) tex.draw_texture('global', 'difficulty_select', fade=self.text_fade_in.attribute)
elif self.state == State.DIFF_SORTING: elif self.state == State.DIFF_SORTING:
@@ -382,24 +446,10 @@ class SongSelectScreen(Screen):
if self.state == State.BROWSING and self.navigator.items != []: if self.state == State.BROWSING and self.navigator.items != []:
curr_item = self.navigator.get_current_item() curr_item = self.navigator.get_current_item()
if isinstance(curr_item, SongFile): if not isinstance(curr_item, Directory):
curr_item.box.draw_score_history() curr_item.box.draw_score_history()
self.indicator.draw(tex.skin_config['song_select_indicator'].x, tex.skin_config['song_select_indicator'].y) self.draw_overlay()
tex.draw_texture('global', 'song_num_bg', fade=0.75)
tex.draw_texture('global', 'song_num', frame=global_data.songs_played % 4)
if self.state == State.BROWSING or self.state == State.DIFF_SORTING:
self.timer_browsing.draw()
elif self.state == State.SONG_SELECTED:
self.timer_selected.draw()
self.coin_overlay.draw()
if self.game_transition is not None:
self.game_transition.draw()
if self.dan_transition.is_started:
self.dan_transition.draw()
self.allnet_indicator.draw()
class SongSelectPlayer: class SongSelectPlayer:
def __init__(self, player_num: PlayerNum, text_fade_in): def __init__(self, player_num: PlayerNum, text_fade_in):
@@ -413,6 +463,7 @@ class SongSelectPlayer:
self.diff_select_move_right = False self.diff_select_move_right = False
self.neiro_selector = None self.neiro_selector = None
self.modifier_selector = None self.modifier_selector = None
self.search_string = ''
# References to shared animations # References to shared animations
self.diff_selector_move_1 = tex.get_animation(26, is_copy=True) self.diff_selector_move_1 = tex.get_animation(26, is_copy=True)
@@ -458,10 +509,10 @@ class SongSelectPlayer:
def on_song_selected(self, selected_song): def on_song_selected(self, selected_song):
"""Called when a song is selected""" """Called when a song is selected"""
if Difficulty.URA not in selected_song.tja.metadata.course_data: if Difficulty.URA not in selected_song.parser.metadata.course_data:
self.is_ura = False self.is_ura = False
elif (Difficulty.URA in selected_song.tja.metadata.course_data and elif (Difficulty.URA in selected_song.parser.metadata.course_data and
Difficulty.ONI not in selected_song.tja.metadata.course_data): Difficulty.ONI not in selected_song.parser.metadata.course_data):
self.is_ura = True self.is_ura = True
def handle_input_browsing(self, last_moved, selected_item): def handle_input_browsing(self, last_moved, selected_item):
@@ -478,13 +529,14 @@ class SongSelectPlayer:
audio.play_sound('skip', 'sound') audio.play_sound('skip', 'sound')
return "skip_right" return "skip_right"
wheel = ray.get_mouse_wheel_move()
# Navigate left # Navigate left
if is_l_kat_pressed(self.player_num): if is_l_kat_pressed(self.player_num) or wheel > 0:
audio.play_sound('kat', 'sound') audio.play_sound('kat', 'sound')
return "navigate_left" return "navigate_left"
# Navigate right # Navigate right
if is_r_kat_pressed(self.player_num): if is_r_kat_pressed(self.player_num) or wheel < 0:
audio.play_sound('kat', 'sound') audio.play_sound('kat', 'sound')
return "navigate_right" return "navigate_right"
@@ -495,6 +547,8 @@ class SongSelectPlayer:
return "go_back" return "go_back"
elif isinstance(selected_item, Directory) and selected_item.collection == Directory.COLLECTIONS[3]: elif isinstance(selected_item, Directory) and selected_item.collection == Directory.COLLECTIONS[3]:
return "diff_sort" return "diff_sort"
elif isinstance(selected_item, Directory) and selected_item.collection == Directory.COLLECTIONS[5]:
return "search"
else: else:
return "select_song" return "select_song"
@@ -522,6 +576,20 @@ class SongSelectPlayer:
return None return None
def handle_input_search(self):
if ray.is_key_pressed(ray.KeyboardKey.KEY_BACKSPACE):
self.search_string = self.search_string[:-1]
elif ray.is_key_pressed(ray.KeyboardKey.KEY_ENTER):
result = self.search_string
self.search_string = ''
return result
key = ray.get_char_pressed()
while key > 0:
self.search_string += chr(key)
key = ray.get_char_pressed()
return None
def handle_input(self, state, screen): def handle_input(self, state, screen):
"""Main input dispatcher. Delegates to state-specific handlers.""" """Main input dispatcher. Delegates to state-specific handlers."""
if self.is_voice_playing() or self.is_ready: if self.is_voice_playing() or self.is_ready:
@@ -533,6 +601,8 @@ class SongSelectPlayer:
screen.handle_input_selected() screen.handle_input_selected()
elif state == State.DIFF_SORTING: elif state == State.DIFF_SORTING:
screen.handle_input_diff_sort() screen.handle_input_diff_sort()
elif state == State.SEARCHING:
screen.handle_input_search()
def handle_input_selected(self, current_item): def handle_input_selected(self, current_item):
"""Handle input for selecting difficulty. Returns 'cancel', 'confirm', or None""" """Handle input for selecting difficulty. Returns 'cancel', 'confirm', or None"""
@@ -576,9 +646,7 @@ class SongSelectPlayer:
if is_l_kat_pressed(self.player_num) or is_r_kat_pressed(self.player_num): if is_l_kat_pressed(self.player_num) or is_r_kat_pressed(self.player_num):
audio.play_sound('kat', 'sound') audio.play_sound('kat', 'sound')
selected_song = current_item selected_song = current_item
if isinstance(selected_song, Directory): diffs = sorted(selected_song.parser.metadata.course_data)
raise Exception("Directory was chosen instead of song")
diffs = sorted(selected_song.tja.metadata.course_data)
prev_diff = self.selected_difficulty prev_diff = self.selected_difficulty
ret_val = None ret_val = None
@@ -1011,12 +1079,37 @@ class DiffSortSelect:
else: else:
self.draw_diff_select() self.draw_diff_select()
class SearchBox:
def __init__(self):
self.bg_resize = tex.get_animation(19)
self.diff_fade_in = tex.get_animation(20)
self.bg_resize.start()
self.diff_fade_in.start()
self.current_search = ''
def update(self, current_ms):
self.bg_resize.update(current_ms)
self.diff_fade_in.update(current_ms)
def draw(self):
ray.draw_rectangle(0, 0, tex.screen_width, tex.screen_height, ray.fade(ray.BLACK, 0.6))
tex.draw_texture('diff_sort', 'background', scale=self.bg_resize.attribute, center=True)
background = tex.textures['diff_sort']['background']
fade = self.diff_fade_in.attribute
text_box_width, text_box_height = 400 * tex.screen_scale, 60 * tex.screen_scale
x, y = background.width//2 + background.x[0] - text_box_width//2, background.height//2 + background.y[0] - text_box_height//2
text_box = ray.Rectangle(x, y, text_box_width, text_box_height)
ray.draw_rectangle_rec(text_box, ray.fade(ray.LIGHTGRAY, fade))
ray.draw_rectangle_lines(int(text_box.x), int(text_box.y), int(text_box.width), int(text_box.height), ray.fade(ray.DARKGRAY, fade))
text_size = ray.measure_text_ex(global_data.font, self.current_search, int(30 * tex.screen_scale), 1)
ray.draw_text_ex(global_data.font, self.current_search, ray.Vector2(x + text_box_width//2 - text_size.x//2, y + text_box_height//2 - text_size.y//2), int(30 * tex.screen_scale), 1, ray.BLACK)
class NeiroSelector: class NeiroSelector:
"""The menu for selecting the game hitsounds.""" """The menu for selecting the game hitsounds."""
def __init__(self, player_num: PlayerNum): def __init__(self, player_num: PlayerNum):
self.player_num = player_num self.player_num = player_num
self.selected_sound = global_data.hit_sound[self.player_num] self.selected_sound = global_data.hit_sound[self.player_num]
with open(Path("Sounds") / 'hit_sounds' / 'neiro_list.txt', encoding='utf-8-sig') as neiro_list: with open(Path('Skins') / global_data.config["paths"]["skin"] / Path("Sounds") / 'hit_sounds' / 'neiro_list.txt', encoding='utf-8-sig') as neiro_list:
self.sounds = neiro_list.readlines() self.sounds = neiro_list.readlines()
self.sounds.append('無音') self.sounds.append('無音')
self.load_sound() self.load_sound()
@@ -1037,9 +1130,9 @@ class NeiroSelector:
if self.selected_sound == len(self.sounds): if self.selected_sound == len(self.sounds):
return return
if self.selected_sound == 0: if self.selected_sound == 0:
self.curr_sound = audio.load_sound(Path("Sounds") / "hit_sounds" / str(self.selected_sound) / "don.wav", 'hit_sound') self.curr_sound = audio.load_sound(Path('Skins') / global_data.config["paths"]["skin"] / Path("Sounds") / "hit_sounds" / str(self.selected_sound) / "don.wav", 'hit_sound')
else: else:
self.curr_sound = audio.load_sound(Path("Sounds") / "hit_sounds" / str(self.selected_sound) / "don.ogg", 'hit_sound') self.curr_sound = audio.load_sound(Path('Skins') / global_data.config["paths"]["skin"] / Path("Sounds") / "hit_sounds" / str(self.selected_sound) / "don.ogg", 'hit_sound')
def move_left(self): def move_left(self):
if self.move.is_started and not self.move.is_finished: if self.move.is_started and not self.move.is_finished:
@@ -1131,16 +1224,23 @@ class ModifierSelector:
"inverse": "mod_abekobe", "inverse": "mod_abekobe",
"random": "mod_kimagure" "random": "mod_kimagure"
} }
NAME_MAP = { NAME_MAP_JA = {
"auto": "オート", "auto": "オート",
"speed": "はやさ", "speed": "はやさ",
"display": "ドロン", "display": "ドロン",
"inverse": "あべこべ", "inverse": "あべこべ",
"random": "ランダム" "random": "ランダム"
} }
NAME_MAP_EN = {
"auto": "Auto",
"speed": "Speed",
"display": "Display",
"inverse": "Inverse",
"random": "Random"
}
def __init__(self, player_num: PlayerNum): def __init__(self, player_num: PlayerNum):
self.player_num = player_num self.player_num = player_num
self.mods = fields(Modifiers) self.mods = fields(Modifiers)[:-1]
self.current_mod_index = 0 self.current_mod_index = 0
self.is_confirmed = False self.is_confirmed = False
self.is_finished = False self.is_finished = False
@@ -1152,19 +1252,24 @@ class ModifierSelector:
self.fade_sideways = tex.get_animation(32, is_copy=True) self.fade_sideways = tex.get_animation(32, is_copy=True)
self.direction = -1 self.direction = -1
audio.play_sound(f'voice_options_{self.player_num}p', 'sound') audio.play_sound(f'voice_options_{self.player_num}p', 'sound')
self.text_name = [OutlinedText(ModifierSelector.NAME_MAP[mod.name], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) for mod in self.mods] self.language = global_data.config["general"]["language"]
self.text_true = OutlinedText('する', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) if self.language == 'en':
self.text_false = OutlinedText('しない', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) name_map = ModifierSelector.NAME_MAP_EN
else:
name_map = ModifierSelector.NAME_MAP_JA
self.text_name = [OutlinedText(name_map[mod.name], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) for mod in self.mods]
self.text_true = OutlinedText(tex.skin_config["modifier_text_true"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_false = OutlinedText(tex.skin_config["modifier_text_false"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_speed = OutlinedText(str(global_data.modifiers[self.player_num].speed), tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_speed = OutlinedText(str(global_data.modifiers[self.player_num].speed), tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_kimagure = OutlinedText('きまぐれ', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_kimagure = OutlinedText(tex.skin_config["modifier_text_kimagure"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_detarame = OutlinedText('でたらめ', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_detarame = OutlinedText(tex.skin_config["modifier_text_detarame"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
# Secondary text objects for animation # Secondary text objects for animation
self.text_true_2 = OutlinedText('する', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_true_2 = OutlinedText(tex.skin_config["modifier_text_true"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_false_2 = OutlinedText('しない', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_false_2 = OutlinedText(tex.skin_config["modifier_text_false"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_speed_2 = OutlinedText(str(global_data.modifiers[self.player_num].speed), tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_speed_2 = OutlinedText(str(global_data.modifiers[self.player_num].speed), tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_kimagure_2 = OutlinedText('きまぐれ', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_kimagure_2 = OutlinedText(tex.skin_config["modifier_text_kimagure"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_detarame_2 = OutlinedText('でたらめ', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_detarame_2 = OutlinedText(tex.skin_config["modifier_text_detarame"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
def update(self, current_ms): def update(self, current_ms):
self.is_finished = self.is_confirmed and self.move.is_finished self.is_finished = self.is_confirmed and self.move.is_finished
@@ -1205,20 +1310,20 @@ class ModifierSelector:
if current_value: if current_value:
self.text_true.unload() self.text_true.unload()
self.text_true = self.text_true_2 self.text_true = self.text_true_2
self.text_true_2 = OutlinedText('する', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_true_2 = OutlinedText(tex.skin_config["modifier_text_true"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
else: else:
self.text_false.unload() self.text_false.unload()
self.text_false = self.text_false_2 self.text_false = self.text_false_2
self.text_false_2 = OutlinedText('しない', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_false_2 = OutlinedText(tex.skin_config["modifier_text_false"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
elif current_mod.name == 'random': elif current_mod.name == 'random':
if current_value == 1: if current_value == 1:
self.text_kimagure.unload() self.text_kimagure.unload()
self.text_kimagure = self.text_kimagure_2 self.text_kimagure = self.text_kimagure_2
self.text_kimagure_2 = OutlinedText('きまぐれ', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_kimagure_2 = OutlinedText(tex.skin_config["modifier_text_kimagure"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
elif current_value == 2: elif current_value == 2:
self.text_detarame.unload() self.text_detarame.unload()
self.text_detarame = self.text_detarame_2 self.text_detarame = self.text_detarame_2
self.text_detarame_2 = OutlinedText('でたらめ', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5) self.text_detarame_2 = OutlinedText(tex.skin_config["modifier_text_detarame"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
def left(self): def left(self):
if self.is_confirmed: if self.is_confirmed:

View File

@@ -2,10 +2,14 @@ import logging
import random import random
from pathlib import Path from pathlib import Path
import pyray as ray
from libs.audio import audio from libs.audio import audio
from libs.global_objects import AllNetIcon, CoinOverlay, EntryOverlay from libs.global_objects import AllNetIcon, CoinOverlay, EntryOverlay
from libs.screen import Screen
from libs.texture import tex from libs.texture import tex
from libs.utils import ( from libs.utils import (
OutlinedText,
get_current_ms, get_current_ms,
global_data, global_data,
global_tex, global_tex,
@@ -13,7 +17,6 @@ from libs.utils import (
is_r_don_pressed, is_r_don_pressed,
) )
from libs.video import VideoPlayer from libs.video import VideoPlayer
from libs.screen import Screen
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,19 +28,11 @@ class State:
class TitleScreen(Screen): class TitleScreen(Screen):
def __init__(self, name: str): def __init__(self, name: str):
super().__init__(name) super().__init__(name)
#normalize to accept both stings and lists in toml
#maybe normalize centrally? but it's used only here
vp = global_data.config["paths"]["video_path"]
video_paths = [vp] if isinstance(vp, str) else vp
self.op_video_list = [] self.op_video_list = []
self.attract_video_list = [] self.attract_video_list = []
for base in video_paths: base = Path(f"Skins/{global_data.config["paths"]["skin"]}/Videos")
base = Path(base)
self.op_video_list += list((base/"op_videos").glob("**/*.mp4")) self.op_video_list += list((base/"op_videos").glob("**/*.mp4"))
self.attract_video_list += list((base/"attract_videos").glob("**/*.mp4")) self.attract_video_list += list((base/"attract_videos").glob("**/*.mp4"))
self.coin_overlay = CoinOverlay()
self.allnet_indicator = AllNetIcon()
self.entry_overlay = EntryOverlay()
def on_screen_start(self): def on_screen_start(self):
super().on_screen_start() super().on_screen_start()
@@ -45,6 +40,10 @@ class TitleScreen(Screen):
self.op_video = None self.op_video = None
self.attract_video = None self.attract_video = None
self.warning_board = None self.warning_board = None
self.coin_overlay = CoinOverlay()
self.allnet_indicator = AllNetIcon()
self.entry_overlay = EntryOverlay()
self.hit_taiko_text = OutlinedText(global_tex.skin_config["hit_taiko_to_start"].text[global_data.config["general"]["language"]], tex.skin_config["hit_taiko_to_start"].font_size, ray.WHITE, spacing=5, outline_thickness=4)
self.fade_out = tex.get_animation(13) self.fade_out = tex.get_animation(13)
self.text_overlay_fade = tex.get_animation(14) self.text_overlay_fade = tex.get_animation(14)
@@ -121,8 +120,8 @@ class TitleScreen(Screen):
self.allnet_indicator.draw() self.allnet_indicator.draw()
self.entry_overlay.draw(tex.skin_config["entry_overlay_title"].x, y=tex.skin_config["entry_overlay_title"].y) self.entry_overlay.draw(tex.skin_config["entry_overlay_title"].x, y=tex.skin_config["entry_overlay_title"].y)
global_tex.draw_texture('overlay', 'hit_taiko_to_start', index=0, fade=self.text_overlay_fade.attribute) self.hit_taiko_text.draw(ray.BLACK, x=tex.screen_width*0.25 - self.hit_taiko_text.texture.width//2, y=tex.skin_config["hit_taiko_to_start"].y, fade=self.text_overlay_fade.attribute)
global_tex.draw_texture('overlay', 'hit_taiko_to_start', index=1, fade=self.text_overlay_fade.attribute) self.hit_taiko_text.draw(ray.BLACK, x=tex.screen_width*0.75 - self.hit_taiko_text.texture.width//2, y=tex.skin_config["hit_taiko_to_start"].y, fade=self.text_overlay_fade.attribute)
class WarningScreen: class WarningScreen:
"""Warning screen for the game""" """Warning screen for the game"""

View File

@@ -1,21 +1,30 @@
import logging
import copy import copy
import logging
from pathlib import Path from pathlib import Path
from libs.global_data import PlayerNum
from libs.tja import TJAParser
from libs.utils import get_current_ms
from libs.audio import audio
from libs.utils import global_data
from libs.video import VideoPlayer
import pyray as ray import pyray as ray
from scenes.game import ClearAnimation, FCAnimation, FailAnimation, GameScreen, Player, Background, ResultTransition
from libs.audio import audio
from libs.global_data import PlayerNum
from libs.parsers.tja import TJAParser
from libs.utils import get_current_ms, global_data
from libs.video import VideoPlayer
from scenes.game import (
Background,
ClearAnimation,
FailAnimation,
FCAnimation,
GameScreen,
Player,
ResultTransition,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TwoPlayerGameScreen(GameScreen): class TwoPlayerGameScreen(GameScreen):
def on_screen_start(self): def on_screen_start(self):
super().on_screen_start() super().on_screen_start()
scene_preset = self.tja.metadata.scene_preset scene_preset = self.parser.metadata.scene_preset
if self.background is not None: if self.background is not None:
self.background.unload() self.background.unload()
self.background = Background(PlayerNum.TWO_PLAYER, self.bpm, scene_preset=scene_preset) self.background = Background(PlayerNum.TWO_PLAYER, self.bpm, scene_preset=scene_preset)
@@ -23,7 +32,7 @@ class TwoPlayerGameScreen(GameScreen):
def load_hitsounds(self): def load_hitsounds(self):
"""Load the hit sounds""" """Load the hit sounds"""
sounds_dir = Path("Sounds") sounds_dir = Path(f"Skins/{global_data.config["paths"]["skin"]}/Sounds")
# Load hitsounds for 1P # Load hitsounds for 1P
if global_data.hit_sound[PlayerNum.P1] == -1: if global_data.hit_sound[PlayerNum.P1] == -1:
@@ -74,20 +83,20 @@ class TwoPlayerGameScreen(GameScreen):
def init_tja(self, song: Path): def init_tja(self, song: Path):
"""Initialize the TJA file""" """Initialize the TJA file"""
self.tja = TJAParser(song, start_delay=self.start_delay) self.parser = TJAParser(song, start_delay=self.start_delay)
if self.tja.metadata.bgmovie != Path() and self.tja.metadata.bgmovie.exists(): if self.parser.metadata.bgmovie != Path() and self.parser.metadata.bgmovie.exists():
self.movie = VideoPlayer(self.tja.metadata.bgmovie) self.movie = VideoPlayer(self.parser.metadata.bgmovie)
self.movie.set_volume(0.0) self.movie.set_volume(0.0)
else: else:
self.movie = None self.movie = None
global_data.session_data[PlayerNum.P1].song_title = self.tja.metadata.title.get(global_data.config['general']['language'].lower(), self.tja.metadata.title['en']) global_data.session_data[PlayerNum.P1].song_title = self.parser.metadata.title.get(global_data.config['general']['language'].lower(), self.parser.metadata.title['en'])
if self.tja.metadata.wave.exists() and self.tja.metadata.wave.is_file() and self.song_music is None: if self.parser.metadata.wave.exists() and self.parser.metadata.wave.is_file() and self.song_music is None:
self.song_music = audio.load_music_stream(self.tja.metadata.wave, 'song') self.song_music = audio.load_music_stream(self.parser.metadata.wave, 'song')
tja_copy = copy.deepcopy(self.tja) tja_copy = copy.deepcopy(self.parser)
self.player_1 = Player(self.tja, PlayerNum.P1, global_data.session_data[PlayerNum.P1].selected_difficulty, False, global_data.modifiers[PlayerNum.P1]) self.player_1 = Player(self.parser, PlayerNum.P1, global_data.session_data[PlayerNum.P1].selected_difficulty, False, global_data.modifiers[PlayerNum.P1])
self.player_2 = Player(tja_copy, PlayerNum.P2, global_data.session_data[PlayerNum.P2].selected_difficulty, True, global_data.modifiers[PlayerNum.P2]) self.player_2 = Player(tja_copy, PlayerNum.P2, global_data.session_data[PlayerNum.P2].selected_difficulty, True, global_data.modifiers[PlayerNum.P2])
self.start_ms = (get_current_ms() - self.tja.metadata.offset*1000) self.start_ms = (get_current_ms() - self.parser.metadata.offset*1000)
logger.info(f"TJA initialized for two-player song: {song}") logger.info(f"TJA initialized for two-player song: {song}")
def spawn_ending_anims(self): def spawn_ending_anims(self):
@@ -113,7 +122,7 @@ class TwoPlayerGameScreen(GameScreen):
if self.transition.is_finished: if self.transition.is_finished:
self.start_song(self.current_ms) self.start_song(self.current_ms)
else: else:
self.start_ms = current_time - self.tja.metadata.offset*1000 self.start_ms = current_time - self.parser.metadata.offset*1000
self.update_background(current_time) self.update_background(current_time)
if self.song_music is not None: if self.song_music is not None:

View File

@@ -1,4 +1,5 @@
import logging import logging
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.texture import tex from libs.texture import tex
from libs.utils import get_current_ms from libs.utils import get_current_ms

View File

@@ -1,10 +1,16 @@
import logging import logging
from libs.audio import audio
from libs.file_navigator import SongBox, SongFile from libs.file_navigator import SongBox, SongFile
from libs.global_data import PlayerNum from libs.global_data import PlayerNum
from libs.transition import Transition from libs.transition import Transition
from scenes.song_select import DiffSortSelect, SongSelectPlayer, SongSelectScreen, State
from libs.utils import get_current_ms, global_data from libs.utils import get_current_ms, global_data
from libs.audio import audio from scenes.song_select import (
DiffSortSelect,
SongSelectPlayer,
SongSelectScreen,
State,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -161,9 +167,9 @@ class TwoPlayerSongSelectScreen(SongSelectScreen):
if not isinstance(selected_song, SongFile): if not isinstance(selected_song, SongFile):
raise Exception("picked directory") raise Exception("picked directory")
title = selected_song.tja.metadata.title.get( title = selected_song.parser.metadata.title.get(
global_data.config['general']['language'], '') global_data.config['general']['language'], '')
subtitle = selected_song.tja.metadata.subtitle.get( subtitle = selected_song.parser.metadata.subtitle.get(
global_data.config['general']['language'], '') global_data.config['general']['language'], '')
self.game_transition = Transition(title, subtitle) self.game_transition = Transition(title, subtitle)
self.game_transition.start() self.game_transition.start()

View File

@@ -17,7 +17,7 @@ void main()
float outline = 0.0; float outline = 0.0;
int ringSamples = 16; int ringSamples = 16;
int rings = 2; int rings = 1;
for(int ring = 1; ring <= rings; ring++) { for(int ring = 1; ring <= rings; ring++) {
float ringRadius = float(ring) / float(rings); float ringRadius = float(ring) / float(rings);
for(int i = 0; i < ringSamples; i++) { for(int i = 0; i < ringSamples; i++) {

695
test/libs/test_animation.py Normal file
View File

@@ -0,0 +1,695 @@
import unittest
from unittest.mock import patch
from libs.animation import (
Animation,
BaseAnimation,
FadeAnimation,
MoveAnimation,
TextStretchAnimation,
TextureChangeAnimation,
TextureResizeAnimation,
parse_animations,
)
class TestBaseAnimation(unittest.TestCase):
"""Test cases for the BaseAnimation class."""
@patch('libs.animation.get_current_ms')
@patch('libs.animation.global_data')
def setUp(self, mock_global_data, mock_get_ms):
"""Set up test fixtures."""
mock_get_ms.return_value = 0.0
mock_global_data.input_locked = 0
@patch('libs.animation.get_current_ms')
def test_initialization(self, mock_get_ms):
"""Test basic initialization of BaseAnimation."""
mock_get_ms.return_value = 100.0
anim = BaseAnimation(duration=1000.0, delay=100.0, loop=True, lock_input=True)
self.assertEqual(anim.duration, 1000.0)
self.assertEqual(anim.delay, 100.0)
self.assertEqual(anim.delay_saved, 100.0)
self.assertEqual(anim.start_ms, 100.0)
self.assertFalse(anim.is_finished)
self.assertEqual(anim.attribute, 0)
self.assertFalse(anim.is_started)
self.assertTrue(anim.loop)
self.assertTrue(anim.lock_input)
@patch('libs.animation.get_current_ms')
@patch('libs.animation.global_data')
def test_start(self, mock_global_data, mock_get_ms):
"""Test starting an animation."""
mock_get_ms.return_value = 200.0
mock_global_data.input_locked = 0
anim = BaseAnimation(duration=1000.0, lock_input=True)
anim.start()
self.assertTrue(anim.is_started)
self.assertFalse(anim.is_finished)
self.assertEqual(mock_global_data.input_locked, 1)
@patch('libs.animation.get_current_ms')
@patch('libs.animation.global_data')
def test_restart(self, mock_global_data, mock_get_ms):
"""Test restarting an animation."""
mock_get_ms.side_effect = [0.0, 500.0, 1000.0]
mock_global_data.input_locked = 0
anim = BaseAnimation(duration=1000.0, delay=100.0, lock_input=True)
anim.is_finished = True
anim.delay = 0.0
anim.restart()
self.assertEqual(anim.start_ms, 500.0)
self.assertFalse(anim.is_finished)
self.assertEqual(anim.delay, 100.0)
self.assertEqual(mock_global_data.input_locked, 1)
@patch('libs.animation.get_current_ms')
@patch('libs.animation.global_data')
def test_pause_unpause(self, mock_global_data, mock_get_ms):
"""Test pausing and unpausing."""
mock_get_ms.return_value = 0.0
mock_global_data.input_locked = 1
anim = BaseAnimation(duration=1000.0, lock_input=True)
anim.is_started = True
anim.pause()
self.assertFalse(anim.is_started)
self.assertEqual(mock_global_data.input_locked, 0)
anim.unpause()
self.assertTrue(anim.is_started)
self.assertEqual(mock_global_data.input_locked, 1)
@patch('libs.animation.get_current_ms')
def test_loop_restarts(self, mock_get_ms):
"""Test that looped animations restart when finished."""
mock_get_ms.side_effect = [0.0, 100.0]
anim = BaseAnimation(duration=1000.0, loop=True)
anim.is_finished = True
with patch.object(anim, 'restart') as mock_restart:
anim.update(100.0)
mock_restart.assert_called_once()
@patch('libs.animation.get_current_ms')
@patch('libs.animation.global_data')
def test_input_lock_unlock(self, mock_global_data, mock_get_ms):
"""Test input locking mechanism."""
mock_get_ms.return_value = 0.0
mock_global_data.input_locked = 1
anim = BaseAnimation(duration=1000.0, lock_input=True)
anim.is_finished = True
anim.unlocked = False
anim.update(100.0)
self.assertTrue(anim.unlocked)
self.assertEqual(mock_global_data.input_locked, 0)
def test_easing_functions(self):
"""Test easing functions produce expected values."""
anim = BaseAnimation(duration=1000.0)
# Test quadratic ease in
self.assertAlmostEqual(anim._ease_in(0.5, "quadratic"), 0.25)
self.assertAlmostEqual(anim._ease_in(1.0, "quadratic"), 1.0)
# Test cubic ease in
self.assertAlmostEqual(anim._ease_in(0.5, "cubic"), 0.125)
# Test exponential ease in
self.assertEqual(anim._ease_in(0.0, "exponential"), 0)
# Test quadratic ease out
self.assertAlmostEqual(anim._ease_out(0.5, "quadratic"), 0.75)
# Test cubic ease out
self.assertAlmostEqual(anim._ease_out(0.5, "cubic"), 0.875)
# Test exponential ease out
self.assertEqual(anim._ease_out(1.0, "exponential"), 1)
class TestFadeAnimation(unittest.TestCase):
"""Test cases for the FadeAnimation class."""
@patch('libs.animation.get_current_ms')
def test_initialization(self, mock_get_ms):
"""Test fade animation initialization."""
mock_get_ms.return_value = 0.0
anim = FadeAnimation(
duration=1000.0,
initial_opacity=1.0,
final_opacity=0.0,
delay=100.0,
ease_in="quadratic"
)
self.assertEqual(anim.initial_opacity, 1.0)
self.assertEqual(anim.final_opacity, 0.0)
self.assertEqual(anim.attribute, 1.0)
self.assertEqual(anim.ease_in, "quadratic")
@patch('libs.animation.get_current_ms')
def test_fade_during_delay(self, mock_get_ms):
"""Test that opacity stays at initial during delay."""
mock_get_ms.return_value = 0.0
anim = FadeAnimation(duration=1000.0, initial_opacity=1.0, final_opacity=0.0, delay=500.0)
anim.start()
anim.update(250.0) # Within delay period
self.assertEqual(anim.attribute, 1.0)
self.assertFalse(anim.is_finished)
@patch('libs.animation.get_current_ms')
def test_fade_progression(self, mock_get_ms):
"""Test fade progresses correctly."""
mock_get_ms.return_value = 0.0
anim = FadeAnimation(duration=1000.0, initial_opacity=1.0, final_opacity=0.0)
anim.start()
anim.update(500.0) # Halfway through
self.assertAlmostEqual(anim.attribute, 0.5, places=2)
self.assertFalse(anim.is_finished)
@patch('libs.animation.get_current_ms')
def test_fade_completion(self, mock_get_ms):
"""Test fade completes at final opacity."""
mock_get_ms.return_value = 0.0
anim = FadeAnimation(duration=1000.0, initial_opacity=1.0, final_opacity=0.0)
anim.start()
anim.update(1000.0) # End of animation
self.assertEqual(anim.attribute, 0.0)
self.assertTrue(anim.is_finished)
@patch('libs.animation.get_current_ms')
def test_fade_with_reverse_delay(self, mock_get_ms):
"""Test fade reverses after reverse_delay."""
mock_get_ms.return_value = 0.0
anim = FadeAnimation(
duration=1000.0,
initial_opacity=1.0,
final_opacity=0.0,
reverse_delay=200.0
)
anim.start()
anim.update(1000.0) # Complete first fade
self.assertEqual(anim.attribute, 0.0)
self.assertTrue(anim.is_reversing)
self.assertEqual(anim.initial_opacity, 0.0)
self.assertEqual(anim.final_opacity, 1.0)
self.assertFalse(anim.is_finished)
@patch('libs.animation.get_current_ms')
def test_fade_with_easing(self, mock_get_ms):
"""Test fade applies easing correctly."""
mock_get_ms.return_value = 0.0
anim = FadeAnimation(
duration=1000.0,
initial_opacity=0.0,
final_opacity=1.0,
ease_in="quadratic"
)
anim.start()
anim.update(500.0) # Halfway
# With quadratic ease in, at 0.5 progress we should have 0.25
self.assertAlmostEqual(anim.attribute, 0.25, places=2)
class TestMoveAnimation(unittest.TestCase):
"""Test cases for the MoveAnimation class."""
@patch('libs.animation.get_current_ms')
def test_initialization(self, mock_get_ms):
"""Test move animation initialization."""
mock_get_ms.return_value = 0.0
anim = MoveAnimation(
duration=1000.0,
start_position=0,
total_distance=100,
delay=50.0
)
self.assertEqual(anim.start_position, 0)
self.assertEqual(anim.total_distance, 100)
self.assertEqual(anim.delay, 50.0)
@patch('libs.animation.get_current_ms')
def test_move_during_delay(self, mock_get_ms):
"""Test position stays at start during delay."""
mock_get_ms.return_value = 0.0
anim = MoveAnimation(duration=1000.0, start_position=50, total_distance=100, delay=200.0)
anim.start()
anim.update(100.0) # Within delay
self.assertEqual(anim.attribute, 50)
@patch('libs.animation.get_current_ms')
def test_move_progression(self, mock_get_ms):
"""Test move progresses correctly."""
mock_get_ms.return_value = 0.0
anim = MoveAnimation(duration=1000.0, start_position=0, total_distance=100)
anim.start()
anim.update(500.0) # Halfway
self.assertAlmostEqual(anim.attribute, 50.0, places=2)
@patch('libs.animation.get_current_ms')
def test_move_completion(self, mock_get_ms):
"""Test move completes at final position."""
mock_get_ms.return_value = 0.0
anim = MoveAnimation(duration=1000.0, start_position=0, total_distance=100)
anim.start()
anim.update(1000.0)
self.assertEqual(anim.attribute, 100)
self.assertTrue(anim.is_finished)
@patch('libs.animation.get_current_ms')
def test_move_with_reverse_delay(self, mock_get_ms):
"""Test move reverses after reverse_delay."""
mock_get_ms.return_value = 0.0
anim = MoveAnimation(
duration=1000.0,
start_position=0,
total_distance=100,
reverse_delay=100.0
)
anim.start()
anim.update(1000.0) # Complete first move
self.assertEqual(anim.start_position, 100)
self.assertEqual(anim.total_distance, -100)
self.assertIsNone(anim.reverse_delay)
self.assertFalse(anim.is_finished)
@patch('libs.animation.get_current_ms')
def test_move_with_easing(self, mock_get_ms):
"""Test move applies easing."""
mock_get_ms.return_value = 0.0
anim = MoveAnimation(
duration=1000.0,
start_position=0,
total_distance=100,
ease_out="quadratic"
)
anim.start()
anim.update(500.0)
# With quadratic ease out, at 0.5 progress we should have 0.75
self.assertAlmostEqual(anim.attribute, 75.0, places=2)
class TestTextureChangeAnimation(unittest.TestCase):
"""Test cases for the TextureChangeAnimation class."""
@patch('libs.animation.get_current_ms')
def test_initialization(self, mock_get_ms):
"""Test texture change animation initialization."""
mock_get_ms.return_value = 0.0
textures = [(0.0, 100.0, 0), (100.0, 200.0, 1), (200.0, 300.0, 2)]
anim = TextureChangeAnimation(duration=300.0, textures=textures)
self.assertEqual(anim.textures, textures)
self.assertEqual(anim.attribute, 0) # First texture index
@patch('libs.animation.get_current_ms')
def test_texture_change_progression(self, mock_get_ms):
"""Test texture changes at correct times."""
mock_get_ms.return_value = 0.0
textures = [(0.0, 100.0, 0), (100.0, 200.0, 1), (200.0, 300.0, 2)]
anim = TextureChangeAnimation(duration=300.0, textures=textures)
anim.start()
anim.update(50.0)
self.assertEqual(anim.attribute, 0)
anim.update(150.0)
self.assertEqual(anim.attribute, 1)
anim.update(250.0)
self.assertEqual(anim.attribute, 2)
@patch('libs.animation.get_current_ms')
def test_texture_change_completion(self, mock_get_ms):
"""Test texture change completes."""
mock_get_ms.return_value = 0.0
textures = [(0.0, 100.0, 0), (100.0, 200.0, 1)]
anim = TextureChangeAnimation(duration=200.0, textures=textures)
anim.start()
anim.update(300.0) # Past duration
self.assertTrue(anim.is_finished)
@patch('libs.animation.get_current_ms')
def test_texture_change_with_delay(self, mock_get_ms):
"""Test texture change respects delay."""
mock_get_ms.return_value = 0.0
textures = [(0.0, 100.0, 0), (100.0, 200.0, 1)]
anim = TextureChangeAnimation(duration=200.0, textures=textures, delay=100.0)
anim.start()
anim.update(50.0) # During delay
self.assertEqual(anim.attribute, 0)
anim.update(150.0) # 50ms into animation (after delay)
self.assertEqual(anim.attribute, 0)
class TestTextureResizeAnimation(unittest.TestCase):
"""Test cases for the TextureResizeAnimation class."""
@patch('libs.animation.get_current_ms')
def test_initialization(self, mock_get_ms):
"""Test texture resize initialization."""
mock_get_ms.return_value = 0.0
anim = TextureResizeAnimation(
duration=1000.0,
initial_size=1.0,
final_size=2.0
)
self.assertEqual(anim.initial_size, 1.0)
self.assertEqual(anim.final_size, 2.0)
self.assertEqual(anim.attribute, 1.0)
@patch('libs.animation.get_current_ms')
def test_resize_progression(self, mock_get_ms):
"""Test resize progresses correctly."""
mock_get_ms.return_value = 0.0
anim = TextureResizeAnimation(duration=1000.0, initial_size=1.0, final_size=2.0)
anim.start()
anim.update(500.0) # Halfway
self.assertAlmostEqual(anim.attribute, 1.5, places=2)
@patch('libs.animation.get_current_ms')
def test_resize_completion(self, mock_get_ms):
"""Test resize completes."""
mock_get_ms.return_value = 0.0
anim = TextureResizeAnimation(duration=1000.0, initial_size=1.0, final_size=0.5)
anim.start()
anim.update(1000.0)
self.assertEqual(anim.attribute, 0.5)
self.assertTrue(anim.is_finished)
class TestTextStretchAnimation(unittest.TestCase):
"""Test cases for the TextStretchAnimation class."""
@patch('libs.animation.get_current_ms')
def test_stretch_phases(self, mock_get_ms):
"""Test text stretch animation phases."""
mock_get_ms.return_value = 0.0
anim = TextStretchAnimation(duration=100.0)
anim.start()
# Phase 1: Growing
anim.update(50.0)
self.assertGreater(anim.attribute, 2)
# Phase 2: Shrinking back
anim.update(150.0)
self.assertGreater(anim.attribute, 0)
# Phase 3: Finished
anim.update(300.0)
self.assertEqual(anim.attribute, 0)
self.assertTrue(anim.is_finished)
class TestAnimationFactory(unittest.TestCase):
"""Test cases for the Animation factory class."""
def test_create_fade(self):
"""Test factory creates fade animation."""
anim = Animation.create_fade(1000.0, initial_opacity=1.0, final_opacity=0.0)
self.assertIsInstance(anim, FadeAnimation)
self.assertEqual(anim.duration, 1000.0)
self.assertEqual(anim.initial_opacity, 1.0)
def test_create_move(self):
"""Test factory creates move animation."""
anim = Animation.create_move(1000.0, start_position=0, total_distance=100)
self.assertIsInstance(anim, MoveAnimation)
self.assertEqual(anim.duration, 1000.0)
self.assertEqual(anim.total_distance, 100)
def test_create_texture_change(self):
"""Test factory creates texture change animation."""
textures = [(0.0, 100.0, 0)]
anim = Animation.create_texture_change(1000.0, textures=textures)
self.assertIsInstance(anim, TextureChangeAnimation)
self.assertEqual(anim.textures, textures)
def test_create_texture_resize(self):
"""Test factory creates texture resize animation."""
anim = Animation.create_texture_resize(1000.0, initial_size=1.0, final_size=2.0)
self.assertIsInstance(anim, TextureResizeAnimation)
self.assertEqual(anim.initial_size, 1.0)
class TestParseAnimations(unittest.TestCase):
"""Test cases for parse_animations function."""
def test_parse_basic_animation(self):
"""Test parsing a simple animation."""
animation_json = [
{
"id": 1,
"type": "fade",
"duration": 1000.0,
"initial_opacity": 1.0,
"final_opacity": 0.0
}
]
result = parse_animations(animation_json)
self.assertIn(1, result)
self.assertIsInstance(result[1], FadeAnimation)
self.assertEqual(result[1].duration, 1000.0)
def test_parse_multiple_animations(self):
"""Test parsing multiple animations."""
animation_json = [
{"id": 1, "type": "fade", "duration": 1000.0},
{"id": 2, "type": "move", "duration": 500.0, "total_distance": 50}
]
result = parse_animations(animation_json)
self.assertEqual(len(result), 2)
self.assertIsInstance(result[1], FadeAnimation)
self.assertIsInstance(result[2], MoveAnimation)
def test_parse_with_reference(self):
"""Test parsing animations with references."""
animation_json = [
{"id": 1, "type": "fade", "duration": 1000.0, "initial_opacity": 1.0},
{
"id": 2,
"type": "fade",
"duration": {"reference_id": 1, "property": "duration"},
"initial_opacity": 0.5
}
]
result = parse_animations(animation_json)
self.assertEqual(result[2].duration, 1000.0)
self.assertEqual(result[2].initial_opacity, 0.5)
def test_parse_with_reference_and_init_val(self):
"""Test parsing with reference and init_val modifier."""
animation_json = [
{"id": 1, "type": "fade", "duration": 1000.0},
{
"id": 2,
"type": "fade",
"duration": {
"reference_id": 1,
"property": "duration",
"init_val": 500.0
}
}
]
result = parse_animations(animation_json)
self.assertEqual(result[2].duration, 1500.0)
def test_parse_missing_id_raises_error(self):
"""Test that missing id raises exception."""
animation_json = [
{"type": "fade", "duration": 1000.0}
]
with self.assertRaises(Exception) as context:
parse_animations(animation_json)
self.assertIn("requires id", str(context.exception))
def test_parse_missing_type_raises_error(self):
"""Test that missing type raises exception."""
animation_json = [
{"id": 1, "duration": 1000.0}
]
with self.assertRaises(Exception) as context:
parse_animations(animation_json)
self.assertIn("requires type", str(context.exception))
def test_parse_circular_reference_raises_error(self):
"""Test that circular references are detected."""
animation_json = [
{
"id": 1,
"type": "fade",
"duration": {"reference_id": 2, "property": "duration"}
},
{
"id": 2,
"type": "fade",
"duration": {"reference_id": 1, "property": "duration"}
}
]
with self.assertRaises(Exception) as context:
parse_animations(animation_json)
self.assertIn("Circular reference", str(context.exception))
def test_parse_unknown_type_raises_error(self):
"""Test that unknown animation type raises exception."""
animation_json = [
{"id": 1, "type": "unknown_type", "duration": 1000.0}
]
with self.assertRaises(Exception) as context:
parse_animations(animation_json)
self.assertIn("Unknown Animation type", str(context.exception))
def test_parse_missing_reference_property_raises_error(self):
"""Test that missing reference property raises exception."""
animation_json = [
{"id": 1, "type": "fade", "duration": 1000.0},
{
"id": 2,
"type": "fade",
"duration": {"reference_id": 1}
}
]
with self.assertRaises(Exception) as context:
parse_animations(animation_json)
self.assertIn("requires 'property'", str(context.exception))
def test_parse_nonexistent_reference_raises_error(self):
"""Test that referencing nonexistent animation raises exception."""
animation_json = [
{
"id": 1,
"type": "fade",
"duration": {"reference_id": 999, "property": "duration"}
}
]
with self.assertRaises(Exception) as context:
parse_animations(animation_json)
self.assertIn("not found", str(context.exception))
def test_parse_ignores_comments(self):
"""Test that comments are ignored during parsing."""
animation_json = [
{
"id": 1,
"type": "fade",
"duration": 1000.0,
"comment": "This is a fade animation"
}
]
result = parse_animations(animation_json)
self.assertIn(1, result)
self.assertIsInstance(result[1], FadeAnimation)
def test_parse_nested_references(self):
"""Test parsing nested reference chains."""
animation_json = [
{"id": 1, "type": "fade", "duration": 1000.0},
{
"id": 2,
"type": "fade",
"duration": {"reference_id": 1, "property": "duration"}
},
{
"id": 3,
"type": "fade",
"duration": {
"reference_id": 2,
"property": "duration",
"init_val": 500.0
}
}
]
result = parse_animations(animation_json)
self.assertEqual(result[3].duration, 1500.0)
if __name__ == '__main__':
unittest.main()

518
test/libs/test_audio.py Normal file
View File

@@ -0,0 +1,518 @@
import shutil
import struct
import unittest
import wave
from pathlib import Path
from unittest.mock import patch
from libs.audio import AudioEngine, audio
from libs.config import VolumeConfig
DEFAULT_CONFIG = VolumeConfig(sound=0.8, music=0.7, voice=0.6, hitsound=0.5, attract_mode=0.4)
class TestAudioEngine(unittest.TestCase):
"""Integration tests using the audio library."""
@classmethod
def setUpClass(cls):
"""Set up test fixtures once for all tests."""
# Create temporary directory for test audio files
cls.test_dir = Path().cwd() / Path("temp")
cls.test_dir.mkdir(exist_ok=True)
cls.sounds_dir = Path(cls.test_dir) / "Sounds"
cls.sounds_dir.mkdir(exist_ok=True)
# Create test WAV files
cls._create_test_wav(cls.sounds_dir / "don.wav")
cls._create_test_wav(cls.sounds_dir / "ka.wav")
cls._create_test_wav(cls.sounds_dir / "test_sound.wav")
cls._create_test_wav(cls.sounds_dir / "test_music.wav", duration=2.0)
# Create screen sounds directory
cls.screen_sounds = cls.sounds_dir / "menu"
cls.screen_sounds.mkdir()
cls._create_test_wav(cls.screen_sounds / "click.wav")
cls._create_test_wav(cls.screen_sounds / "hover.wav")
# Create global sounds directory
cls.global_sounds = cls.sounds_dir / "global"
cls.global_sounds.mkdir()
cls._create_test_wav(cls.global_sounds / "confirm.wav")
cls.volume_presets = DEFAULT_CONFIG
@classmethod
def tearDownClass(cls):
"""Clean up test files."""
shutil.rmtree(cls.test_dir)
@staticmethod
def _create_test_wav(filepath, duration=0.1, frequency=440):
"""Create a simple test WAV file."""
sample_rate = 44100
num_samples = int(sample_rate * duration)
with wave.open(str(filepath), 'w') as wav_file:
wav_file.setnchannels(1) # Mono
wav_file.setsampwidth(2) # 16-bit
wav_file.setframerate(sample_rate)
for i in range(num_samples):
# Generate a simple sine wave
value = int(32767.0 * 0.3 *
(i % (sample_rate // frequency)) /
(sample_rate // frequency))
wav_file.writeframes(struct.pack('h', value))
def setUp(self):
"""Set up each test."""
self.mock_config_path = self.sounds_dir
# Store original audio singleton state to avoid test pollution
self._original_audio_sounds_path = audio.sounds_path
def tearDown(self):
"""Tear down each test."""
# Restore original audio singleton state
audio.sounds_path = self._original_audio_sounds_path
# Clear any sounds or music loaded during tests
if hasattr(audio, 'sounds') and isinstance(audio.sounds, dict):
audio.sounds.clear()
if hasattr(audio, 'music_streams') and isinstance(audio.music_streams, dict):
audio.music_streams.clear()
@patch('libs.audio.get_config')
def test_initialization(self, mock_config):
"""Test AudioEngine initialization."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(
device_type=0,
sample_rate=44100.0,
buffer_size=512,
volume_presets=self.volume_presets,
sounds_path=self.sounds_dir
)
self.assertEqual(engine.device_type, 0)
self.assertEqual(engine.target_sample_rate, 44100.0)
self.assertEqual(engine.buffer_size, 512)
self.assertEqual(engine.volume_presets, self.volume_presets)
self.assertFalse(engine.audio_device_ready)
@patch('libs.audio.get_config')
def test_init_and_close_audio_device(self, mock_config):
"""Test initializing and closing audio device."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
# Initialize
success = engine.init_audio_device()
self.assertTrue(success)
self.assertTrue(engine.audio_device_ready)
self.assertTrue(engine.is_audio_device_ready())
# Close
engine.close_audio_device()
self.assertFalse(engine.audio_device_ready)
@patch('libs.audio.get_config')
def test_master_volume(self, mock_config):
"""Test master volume control."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
# Set and get master volume
engine.set_master_volume(0.75)
volume = engine.get_master_volume()
self.assertAlmostEqual(volume, 0.75, places=2)
# Test clamping
engine.set_master_volume(1.5)
volume = engine.get_master_volume()
self.assertLessEqual(volume, 1.0)
engine.set_master_volume(-0.5)
volume = engine.get_master_volume()
self.assertGreaterEqual(volume, 0.0)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_load_and_unload_sound(self, mock_config):
"""Test loading and unloading sounds."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
# Load sound
sound_path = self.sounds_dir / "test_sound.wav"
sound_id = engine.load_sound(sound_path, "test")
self.assertEqual(sound_id, "test")
self.assertIn("test", engine.sounds)
# Unload sound
engine.unload_sound("test")
self.assertNotIn("test", engine.sounds)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_load_nonexistent_sound(self, mock_config):
"""Test loading a non-existent sound file."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
sound_id = engine.load_sound(Path("nonexistent.wav"), "bad")
self.assertEqual(sound_id, "")
self.assertNotIn("bad", engine.sounds)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_play_and_stop_sound(self, mock_config):
"""Test playing and stopping sounds."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
# Load and play sound
sound_path = self.sounds_dir / "test_sound.wav"
engine.load_sound(sound_path, "test")
engine.play_sound("test", "sound")
# Give it a moment to start
import time
time.sleep(0.05)
# Check if playing (might not be if audio is very short)
# Just verify no exceptions were raised
is_playing = engine.is_sound_playing("test")
self.assertIsInstance(is_playing, bool)
# Stop sound
engine.stop_sound("test")
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_play_don_and_kat(self, mock_config):
"""Test playing the special don and kat sounds."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
# Play don
engine.play_sound("don", "sound")
is_playing = engine.is_sound_playing("don")
self.assertIsInstance(is_playing, bool)
engine.stop_sound("don")
# Play kat
engine.play_sound("kat", "sound")
is_playing = engine.is_sound_playing("kat")
self.assertIsInstance(is_playing, bool)
engine.stop_sound("kat")
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_sound_volume_control(self, mock_config):
"""Test setting sound volume."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
sound_path = self.sounds_dir / "test_sound.wav"
engine.load_sound(sound_path, "test")
# Set volume (should not raise exception)
engine.set_sound_volume("test", 0.5)
engine.play_sound("test", "")
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_sound_pan_control(self, mock_config):
"""Test setting sound pan."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
sound_path = self.sounds_dir / "test_sound.wav"
engine.load_sound(sound_path, "test")
# Set pan (should not raise exception)
engine.set_sound_pan("test", -0.5) # Left
engine.set_sound_pan("test", 0.5) # Right
engine.set_sound_pan("test", 0.0) # Center
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_load_screen_sounds(self, mock_config):
"""Test loading sounds for a screen."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
engine.load_screen_sounds("menu")
# Check that screen sounds were loaded
self.assertIn("click", engine.sounds)
self.assertIn("hover", engine.sounds)
# Check that global sounds were loaded
self.assertIn("confirm", engine.sounds)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_unload_all_sounds(self, mock_config):
"""Test unloading all sounds."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
# Load multiple sounds
engine.load_sound(self.sounds_dir / "test_sound.wav", "s1")
engine.load_sound(self.sounds_dir / "test_sound.wav", "s2")
engine.load_sound(self.sounds_dir / "test_sound.wav", "s3")
self.assertEqual(len(engine.sounds), 3)
# Unload all
engine.unload_all_sounds()
self.assertEqual(len(engine.sounds), 0)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_load_and_play_music_stream(self, mock_config):
"""Test loading and playing music streams."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
music_path = self.sounds_dir / "test_music.wav"
music_id = engine.load_music_stream(music_path, "bgm")
print(music_id)
self.assertEqual(music_id, "bgm")
self.assertIn("bgm", engine.music_streams)
# Play music
engine.play_music_stream("bgm", "music")
# Update music stream
engine.update_music_stream("bgm")
# Check if playing
is_playing = engine.is_music_stream_playing("bgm")
self.assertIsInstance(is_playing, bool)
# Stop music
engine.stop_music_stream("bgm")
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_music_time_functions(self, mock_config):
"""Test getting music time length and played."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
music_path = self.sounds_dir / "test_music.wav"
music_file = engine.load_music_stream(music_path, "bgm")
# Get time length
length = engine.get_music_time_length(music_file)
self.assertGreater(length, 0.0)
self.assertLess(length, 10.0) # Should be around 2 seconds
# Play and get time played
engine.play_music_stream(music_file, "music")
engine.update_music_stream(music_file)
import time
time.sleep(0.1)
engine.update_music_stream(music_file)
played = engine.get_music_time_played(music_file)
self.assertGreaterEqual(played, 0.0)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_music_volume_control(self, mock_config):
"""Test setting music volume."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
music_path = self.sounds_dir / "test_music.wav"
engine.load_music_stream(music_path, "bgm")
# Set volume (should not raise exception)
engine.set_music_volume("bgm", 0.6)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_seek_music_stream(self, mock_config):
"""Test seeking in music stream."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
music_path = self.sounds_dir / "test_music.wav"
engine.load_music_stream(music_path, "bgm")
# Seek to position (should not raise exception)
engine.seek_music_stream("bgm", 0.5)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_unload_music_stream(self, mock_config):
"""Test unloading music stream."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
music_path = self.sounds_dir / "test_music.wav"
engine.load_music_stream(music_path, "bgm")
self.assertIn("bgm", engine.music_streams)
engine.unload_music_stream("bgm")
self.assertNotIn("bgm", engine.music_streams)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_unload_all_music(self, mock_config):
"""Test unloading all music streams."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
# Load multiple music streams
music_path = self.sounds_dir / "test_music.wav"
engine.load_music_stream(music_path, "bgm1")
engine.load_music_stream(music_path, "bgm2")
self.assertEqual(len(engine.music_streams), 2)
engine.unload_all_music()
self.assertEqual(len(engine.music_streams), 0)
finally:
engine.close_audio_device()
@patch('libs.audio.get_config')
def test_host_api_functions(self, mock_config):
"""Test host API query functions."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
engine.init_audio_device()
# List host APIs (should not crash)
engine.list_host_apis()
# Get host API name
name = engine.get_host_api_name(0)
self.assertIsInstance(name, str)
@patch('libs.audio.get_config')
def test_full_lifecycle(self, mock_config):
"""Test complete audio engine lifecycle."""
mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}}
engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir)
if engine.init_audio_device():
try:
# Load sounds and music
engine.load_sound(self.sounds_dir / "test_sound.wav", "sfx")
engine.load_music_stream(self.sounds_dir / "test_music.wav", "bgm")
# Set volumes
engine.set_master_volume(0.8)
engine.set_sound_volume("sfx", 0.7)
engine.set_music_volume("bgm", 0.6)
# Play audio
engine.play_sound("sfx", "sound")
engine.play_music_stream("bgm", "music")
import time
time.sleep(0.1)
# Update music
engine.update_music_stream("bgm")
# Stop
engine.stop_sound("sfx")
engine.stop_music_stream("bgm")
# Cleanup
engine.unload_sound("sfx")
engine.unload_music_stream("bgm")
finally:
engine.close_audio_device()
if __name__ == '__main__':
# Run tests
unittest.main(verbosity=2)

View File

@@ -0,0 +1,551 @@
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from libs.global_data import (
Camera,
Crown,
DanResultData,
DanResultExam,
DanResultSong,
Difficulty,
GlobalData,
Modifiers,
PlayerNum,
ResultData,
ScoreMethod,
SessionData,
global_data,
reset_session,
)
class TestPlayerNum(unittest.TestCase):
"""Test cases for the PlayerNum enum."""
def test_player_num_values(self):
"""Test PlayerNum enum values."""
self.assertEqual(PlayerNum.ALL, 0)
self.assertEqual(PlayerNum.P1, 1)
self.assertEqual(PlayerNum.P2, 2)
self.assertEqual(PlayerNum.TWO_PLAYER, 3)
self.assertEqual(PlayerNum.DAN, 4)
def test_player_num_is_int_enum(self):
"""Test that PlayerNum values are integers."""
self.assertIsInstance(PlayerNum.P1, int)
self.assertIsInstance(PlayerNum.P2, int)
class TestScoreMethod(unittest.TestCase):
"""Test cases for the ScoreMethod class."""
def test_score_method_constants(self):
"""Test ScoreMethod constants."""
self.assertEqual(ScoreMethod.GEN3, "gen3")
self.assertEqual(ScoreMethod.SHINUCHI, "shinuchi")
class TestDifficulty(unittest.TestCase):
"""Test cases for the Difficulty enum."""
def test_difficulty_values(self):
"""Test Difficulty enum values."""
self.assertEqual(Difficulty.EASY, 0)
self.assertEqual(Difficulty.NORMAL, 1)
self.assertEqual(Difficulty.HARD, 2)
self.assertEqual(Difficulty.ONI, 3)
self.assertEqual(Difficulty.URA, 4)
self.assertEqual(Difficulty.TOWER, 5)
self.assertEqual(Difficulty.DAN, 6)
def test_difficulty_ordering(self):
"""Test that difficulty levels are ordered correctly."""
self.assertLess(Difficulty.EASY, Difficulty.NORMAL)
self.assertLess(Difficulty.NORMAL, Difficulty.HARD)
self.assertLess(Difficulty.HARD, Difficulty.ONI)
self.assertLess(Difficulty.ONI, Difficulty.URA)
class TestCrown(unittest.TestCase):
"""Test cases for the Crown enum."""
def test_crown_values(self):
"""Test Crown enum values."""
self.assertEqual(Crown.NONE, 0)
self.assertEqual(Crown.CLEAR, 1)
self.assertEqual(Crown.FC, 2)
self.assertEqual(Crown.DFC, 3)
def test_crown_ordering(self):
"""Test crown achievement ordering."""
self.assertLess(Crown.NONE, Crown.CLEAR)
self.assertLess(Crown.CLEAR, Crown.FC)
self.assertLess(Crown.FC, Crown.DFC)
class TestModifiers(unittest.TestCase):
"""Test cases for the Modifiers dataclass."""
def test_default_values(self):
"""Test default modifier values."""
mods = Modifiers()
self.assertFalse(mods.auto)
self.assertEqual(mods.speed, 1.0)
self.assertFalse(mods.display)
self.assertFalse(mods.inverse)
self.assertEqual(mods.random, 0)
def test_custom_values(self):
"""Test custom modifier values."""
mods = Modifiers(auto=True, speed=2.0, display=True, inverse=True, random=3)
self.assertTrue(mods.auto)
self.assertEqual(mods.speed, 2.0)
self.assertTrue(mods.display)
self.assertTrue(mods.inverse)
self.assertEqual(mods.random, 3)
def test_speed_multiplier(self):
"""Test different speed multiplier values."""
mods1 = Modifiers(speed=0.5)
mods2 = Modifiers(speed=1.5)
mods3 = Modifiers(speed=3.0)
self.assertEqual(mods1.speed, 0.5)
self.assertEqual(mods2.speed, 1.5)
self.assertEqual(mods3.speed, 3.0)
class TestDanResultSong(unittest.TestCase):
"""Test cases for the DanResultSong dataclass."""
def test_default_values(self):
"""Test default DanResultSong values."""
song = DanResultSong()
self.assertEqual(song.selected_difficulty, 0)
self.assertEqual(song.diff_level, 0)
self.assertEqual(song.song_title, "default_title")
self.assertEqual(song.genre_index, 0)
self.assertEqual(song.good, 0)
self.assertEqual(song.ok, 0)
self.assertEqual(song.bad, 0)
self.assertEqual(song.drumroll, 0)
def test_custom_values(self):
"""Test custom DanResultSong values."""
song = DanResultSong(
selected_difficulty=3,
diff_level=10,
song_title="Test Song",
genre_index=5,
good=100,
ok=20,
bad=5,
drumroll=15
)
self.assertEqual(song.selected_difficulty, 3)
self.assertEqual(song.diff_level, 10)
self.assertEqual(song.song_title, "Test Song")
self.assertEqual(song.genre_index, 5)
self.assertEqual(song.good, 100)
self.assertEqual(song.ok, 20)
self.assertEqual(song.bad, 5)
self.assertEqual(song.drumroll, 15)
class TestDanResultExam(unittest.TestCase):
"""Test cases for the DanResultExam class."""
def test_default_values(self):
"""Test default DanResultExam values."""
exam = DanResultExam()
self.assertEqual(exam.progress, 0)
self.assertEqual(exam.counter_value, 0)
self.assertEqual(exam.bar_texture, "exam_red")
self.assertFalse(exam.failed)
def test_custom_values(self):
"""Test custom DanResultExam values."""
exam = DanResultExam()
exam.progress = 0.75
exam.counter_value = 150
exam.bar_texture = "exam_gold"
exam.failed = True
self.assertEqual(exam.progress, 0.75)
self.assertEqual(exam.counter_value, 150)
self.assertEqual(exam.bar_texture, "exam_gold")
self.assertTrue(exam.failed)
class TestDanResultData(unittest.TestCase):
"""Test cases for the DanResultData dataclass."""
def test_default_values(self):
"""Test default DanResultData values."""
data = DanResultData()
self.assertEqual(data.dan_color, 0)
self.assertEqual(data.dan_title, "default_title")
self.assertEqual(data.score, 0)
self.assertEqual(data.gauge_length, 0.0)
self.assertEqual(data.max_combo, 0)
self.assertEqual(data.songs, [])
self.assertEqual(data.exams, [])
self.assertEqual(data.exam_data, [])
def test_with_songs(self):
"""Test DanResultData with songs."""
song1 = DanResultSong(song_title="Song 1")
song2 = DanResultSong(song_title="Song 2")
data = DanResultData(songs=[song1, song2])
self.assertEqual(len(data.songs), 2)
self.assertEqual(data.songs[0].song_title, "Song 1")
self.assertEqual(data.songs[1].song_title, "Song 2")
def test_with_exam_data(self):
"""Test DanResultData with exam data."""
exam1 = DanResultExam()
exam1.progress = 0.5
exam2 = DanResultExam()
exam2.progress = 1.0
data = DanResultData(exam_data=[exam1, exam2])
self.assertEqual(len(data.exam_data), 2)
self.assertEqual(data.exam_data[0].progress, 0.5)
self.assertEqual(data.exam_data[1].progress, 1.0)
class TestResultData(unittest.TestCase):
"""Test cases for the ResultData dataclass."""
def test_default_values(self):
"""Test default ResultData values."""
data = ResultData()
self.assertEqual(data.score, 0)
self.assertEqual(data.good, 0)
self.assertEqual(data.ok, 0)
self.assertEqual(data.bad, 0)
self.assertEqual(data.max_combo, 0)
self.assertEqual(data.total_drumroll, 0)
self.assertEqual(data.gauge_length, 0)
self.assertEqual(data.prev_score, 0)
def test_custom_values(self):
"""Test custom ResultData values."""
data = ResultData(
score=500000,
good=150,
ok=30,
bad=10,
max_combo=120,
total_drumroll=45,
gauge_length=0.85,
prev_score=450000
)
self.assertEqual(data.score, 500000)
self.assertEqual(data.good, 150)
self.assertEqual(data.ok, 30)
self.assertEqual(data.bad, 10)
self.assertEqual(data.max_combo, 120)
self.assertEqual(data.total_drumroll, 45)
self.assertEqual(data.gauge_length, 0.85)
self.assertEqual(data.prev_score, 450000)
def test_total_notes(self):
"""Test calculating total notes from result data."""
data = ResultData(good=100, ok=50, bad=10)
total = data.good + data.ok + data.bad
self.assertEqual(total, 160)
class TestSessionData(unittest.TestCase):
"""Test cases for the SessionData dataclass."""
def test_default_values(self):
"""Test default SessionData values."""
session = SessionData()
self.assertEqual(session.selected_song, Path())
self.assertEqual(session.song_hash, "")
self.assertEqual(session.selected_dan, [])
self.assertEqual(session.selected_dan_exam, [])
self.assertEqual(session.dan_color, 0)
self.assertEqual(session.selected_difficulty, 0)
self.assertEqual(session.song_title, "default_title")
self.assertEqual(session.genre_index, 0)
self.assertIsInstance(session.result_data, ResultData)
self.assertIsInstance(session.dan_result_data, DanResultData)
def test_custom_song_selection(self):
"""Test custom song selection."""
song_path = Path("Songs/TestSong/song.tja")
session = SessionData(
selected_song=song_path,
song_hash="abc123",
selected_difficulty=3,
song_title="Test Song"
)
self.assertEqual(session.selected_song, song_path)
self.assertEqual(session.song_hash, "abc123")
self.assertEqual(session.selected_difficulty, 3)
self.assertEqual(session.song_title, "Test Song")
def test_dan_selection(self):
"""Test dan course selection."""
dan_songs = [(Mock(), 0, 3, 10), (Mock(), 1, 3, 10)]
dan_exams = [Mock(), Mock(), Mock()]
session = SessionData(
selected_dan=dan_songs,
selected_dan_exam=dan_exams,
dan_color=2
)
self.assertEqual(len(session.selected_dan), 2)
self.assertEqual(len(session.selected_dan_exam), 3)
self.assertEqual(session.dan_color, 2)
def test_result_data_independence(self):
"""Test that each session has independent result data."""
session1 = SessionData()
session2 = SessionData()
session1.result_data.score = 100000
self.assertEqual(session1.result_data.score, 100000)
self.assertEqual(session2.result_data.score, 0)
class TestCamera(unittest.TestCase):
"""Test cases for the Camera class."""
@patch('libs.global_data.ray')
def test_default_values(self, mock_ray):
"""Test default Camera values."""
mock_ray.Vector2 = Mock(return_value=Mock())
mock_ray.BLACK = Mock()
camera = Camera()
self.assertEqual(camera.zoom, 1.0)
self.assertEqual(camera.h_scale, 1.0)
self.assertEqual(camera.v_scale, 1.0)
self.assertEqual(camera.rotation, 0.0)
class TestGlobalData(unittest.TestCase):
"""Test cases for the GlobalData dataclass."""
def test_default_values(self):
"""Test default GlobalData values."""
data = GlobalData()
self.assertEqual(data.songs_played, 0)
self.assertIsInstance(data.camera, Camera)
self.assertEqual(data.song_hashes, {})
self.assertEqual(data.song_paths, {})
self.assertEqual(data.score_db, "")
self.assertEqual(data.song_progress, 0.0)
self.assertEqual(data.total_songs, 0)
self.assertEqual(data.hit_sound, [0, 0, 0])
self.assertEqual(data.player_num, PlayerNum.P1)
self.assertEqual(data.input_locked, 0)
def test_modifiers_list(self):
"""Test that modifiers list has correct size."""
data = GlobalData()
self.assertEqual(len(data.modifiers), 3)
self.assertIsInstance(data.modifiers[0], Modifiers)
self.assertIsInstance(data.modifiers[1], Modifiers)
self.assertIsInstance(data.modifiers[2], Modifiers)
def test_session_data_list(self):
"""Test that session data list has correct size."""
data = GlobalData()
self.assertEqual(len(data.session_data), 3)
self.assertIsInstance(data.session_data[0], SessionData)
self.assertIsInstance(data.session_data[1], SessionData)
self.assertIsInstance(data.session_data[2], SessionData)
def test_song_hashes_dict(self):
"""Test song_hashes dictionary operations."""
data = GlobalData()
data.song_hashes["hash1"] = [{"path": "Songs/Song1"}]
data.song_hashes["hash2"] = [{"path": "Songs/Song2"}]
self.assertEqual(len(data.song_hashes), 2)
self.assertIn("hash1", data.song_hashes)
self.assertIn("hash2", data.song_hashes)
def test_song_paths_dict(self):
"""Test song_paths dictionary operations."""
data = GlobalData()
path1 = Path("Songs/Song1/song.tja")
path2 = Path("Songs/Song2/song.tja")
data.song_paths[path1] = "hash1"
data.song_paths[path2] = "hash2"
self.assertEqual(len(data.song_paths), 2)
self.assertEqual(data.song_paths[path1], "hash1")
self.assertEqual(data.song_paths[path2], "hash2")
def test_input_locked_counter(self):
"""Test input_locked as a counter."""
data = GlobalData()
self.assertEqual(data.input_locked, 0)
data.input_locked += 1
self.assertEqual(data.input_locked, 1)
data.input_locked += 1
self.assertEqual(data.input_locked, 2)
data.input_locked -= 1
self.assertEqual(data.input_locked, 1)
def test_songs_played_counter(self):
"""Test songs_played counter."""
data = GlobalData()
self.assertEqual(data.songs_played, 0)
data.songs_played += 1
self.assertEqual(data.songs_played, 1)
data.songs_played += 1
self.assertEqual(data.songs_played, 2)
def test_hit_sound_indices(self):
"""Test hit_sound indices list."""
data = GlobalData()
self.assertEqual(data.hit_sound, [0, 0, 0])
data.hit_sound[0] = 1
data.hit_sound[1] = 2
data.hit_sound[2] = 3
self.assertEqual(data.hit_sound, [1, 2, 3])
class TestGlobalDataSingleton(unittest.TestCase):
"""Test cases for the global_data singleton."""
def test_global_data_exists(self):
"""Test that global_data instance exists."""
self.assertIsInstance(global_data, GlobalData)
def test_global_data_modifiable(self):
"""Test that global_data can be modified."""
original_songs_played = global_data.songs_played
global_data.songs_played += 1
self.assertEqual(global_data.songs_played, original_songs_played + 1)
# Reset for other tests
global_data.songs_played = original_songs_played
class TestResetSession(unittest.TestCase):
"""Test cases for reset_session function."""
def test_reset_session_clears_p1_data(self):
"""Test that reset_session clears player 1 data."""
global_data.session_data[1].result_data.score = 100000
global_data.session_data[1].song_title = "Test Song"
reset_session()
self.assertIsInstance(global_data.session_data[1], SessionData)
self.assertEqual(global_data.session_data[1].song_title, "default_title")
def test_reset_session_clears_p2_data(self):
"""Test that reset_session clears player 2 data."""
global_data.session_data[2].result_data.score = 50000
global_data.session_data[2].selected_difficulty = 3
reset_session()
self.assertIsInstance(global_data.session_data[2], SessionData)
self.assertEqual(global_data.session_data[2].selected_difficulty, 0)
def test_reset_session_preserves_index_0(self):
"""Test that reset_session doesn't affect index 0."""
original_data = global_data.session_data[0]
original_data.song_title = "Should Not Change"
reset_session()
self.assertEqual(global_data.session_data[0].song_title, "Should Not Change")
def test_reset_session_creates_new_instances(self):
"""Test that reset_session creates new SessionData instances."""
old_p1_session = global_data.session_data[1]
old_p2_session = global_data.session_data[2]
reset_session()
self.assertIsNot(global_data.session_data[1], old_p1_session)
self.assertIsNot(global_data.session_data[2], old_p2_session)
class TestDataclassIntegration(unittest.TestCase):
"""Integration tests for dataclass interactions."""
def test_session_with_result_data(self):
"""Test SessionData with populated ResultData."""
session = SessionData()
session.result_data.score = 750000
session.result_data.good = 200
session.result_data.max_combo = 180
self.assertEqual(session.result_data.score, 750000)
self.assertEqual(session.result_data.good, 200)
self.assertEqual(session.result_data.max_combo, 180)
def test_session_with_dan_result_data(self):
"""Test SessionData with populated DanResultData."""
session = SessionData()
session.dan_result_data.dan_title = "10th Dan"
session.dan_result_data.dan_color = 5
song1 = DanResultSong(song_title="Dan Song 1")
song2 = DanResultSong(song_title="Dan Song 2")
session.dan_result_data.songs = [song1, song2]
self.assertEqual(session.dan_result_data.dan_title, "10th Dan")
self.assertEqual(len(session.dan_result_data.songs), 2)
def test_modifiers_independent_per_player(self):
"""Test that each player has independent modifiers."""
data = GlobalData()
data.modifiers[1].speed = 2.0
data.modifiers[2].speed = 1.5
self.assertEqual(data.modifiers[1].speed, 2.0)
self.assertEqual(data.modifiers[2].speed, 1.5)
self.assertEqual(data.modifiers[0].speed, 1.0)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,289 @@
import unittest
from unittest.mock import Mock, patch
from libs.global_data import PlayerNum
from libs.global_objects import (
AllNetIcon,
CoinOverlay,
EntryOverlay,
Indicator,
Nameplate,
Timer,
)
class TestNameplate(unittest.TestCase):
"""Test cases for the Nameplate class."""
def setUp(self):
"""Set up test fixtures."""
# Mock global_tex and its methods
self.mock_tex = Mock()
self.mock_tex.skin_config = {
"nameplate_text_name": Mock(font_size=20, x=100, y=50, width=200),
"nameplate_text_title": Mock(font_size=16, x=100, y=80, width=150),
"nameplate_title_offset": Mock(x=10),
"nameplate_dan_offset": Mock(x=20)
}
self.mock_tex.get_animation = Mock(return_value=Mock(start=Mock(), update=Mock(), is_finished=False))
@patch('libs.global_objects.global_tex')
@patch('libs.global_objects.OutlinedText')
def test_initialization_basic(self, mock_text, mock_global_tex):
"""Test basic nameplate initialization."""
mock_global_tex.skin_config = self.mock_tex.skin_config
nameplate = Nameplate("TestPlayer", "TestTitle", PlayerNum.P1, 5, False, False, 0)
self.assertEqual(nameplate.dan_index, 5)
self.assertEqual(nameplate.player_num, 1)
self.assertFalse(nameplate.is_gold)
self.assertFalse(nameplate.is_rainbow)
self.assertEqual(nameplate.title_bg, 0)
@patch('libs.global_objects.global_tex')
@patch('libs.global_objects.OutlinedText')
def test_initialization_rainbow(self, mock_text, mock_global_tex):
"""Test rainbow nameplate initialization."""
mock_global_tex.skin_config = self.mock_tex.skin_config
mock_animation = Mock()
mock_global_tex.get_animation.return_value = mock_animation
nameplate = Nameplate("Player", "Title", PlayerNum.P1, 3, False, True, 0)
self.assertTrue(nameplate.is_rainbow)
mock_global_tex.get_animation.assert_called_once_with(12)
mock_animation.start.assert_called_once()
@patch('libs.global_objects.global_tex')
def test_update_rainbow_animation(self, mock_global_tex):
"""Test rainbow animation update logic."""
mock_animation = Mock(is_finished=False, update=Mock())
mock_global_tex.get_animation.return_value = mock_animation
mock_global_tex.skin_config = self.mock_tex.skin_config
with patch('libs.global_objects.OutlinedText'):
nameplate = Nameplate("P", "T", PlayerNum.P1, 0, False, True, 0)
nameplate.update(1000.0)
mock_animation.update.assert_called_once_with(1000.0)
@patch('libs.global_objects.global_tex')
def test_update_rainbow_restart(self, mock_global_tex):
"""Test rainbow animation restarts when finished."""
mock_animation = Mock(is_finished=True, update=Mock(), restart=Mock())
mock_global_tex.get_animation.return_value = mock_animation
mock_global_tex.skin_config = self.mock_tex.skin_config
with patch('libs.global_objects.OutlinedText'):
nameplate = Nameplate("P", "T", PlayerNum.P1, 0, False, True, 0)
nameplate.update(1000.0)
mock_animation.restart.assert_called_once()
@patch('libs.global_objects.global_tex')
def test_unload(self, mock_global_tex):
"""Test nameplate resource cleanup."""
mock_global_tex.skin_config = self.mock_tex.skin_config
with patch('libs.global_objects.OutlinedText') as mock_text:
mock_name = Mock()
mock_title = Mock()
mock_text.side_effect = [mock_name, mock_title]
nameplate = Nameplate("P", "T", PlayerNum.P1, 0, False, False, 0)
nameplate.unload()
mock_name.unload.assert_called_once()
mock_title.unload.assert_called_once()
class TestIndicator(unittest.TestCase):
"""Test cases for the Indicator class."""
@patch('libs.global_objects.global_tex')
@patch('libs.global_objects.OutlinedText')
def test_initialization(self, mock_text, mock_global_tex):
"""Test indicator initialization with different states."""
mock_global_tex.get_animation.return_value = Mock()
mock_global_tex.skin_config = {"indicator_text": Mock(text={"en": "Select"}, font_size=20)}
with patch('libs.global_objects.global_data') as mock_data:
mock_data.config = {"general": {"language": "en"}}
indicator = Indicator(Indicator.State.SELECT)
self.assertEqual(indicator.state, Indicator.State.SELECT)
self.assertEqual(mock_global_tex.get_animation.call_count, 3)
@patch('libs.global_objects.global_tex')
def test_update_animations(self, mock_global_tex):
"""Test that all animations update correctly."""
mock_don_fade = Mock()
mock_arrow_move = Mock()
mock_arrow_fade = Mock()
mock_global_tex.get_animation.side_effect = [mock_don_fade, mock_arrow_move, mock_arrow_fade]
mock_global_tex.skin_config = {"indicator_text": Mock(text={"en": "S"}, font_size=20)}
with patch('libs.global_objects.global_data.config', {"general": {"language": "en"}}):
with patch('libs.global_objects.OutlinedText'):
indicator = Indicator(Indicator.State.SKIP)
indicator.update(500.0)
mock_don_fade.update.assert_called_once_with(500.0)
mock_arrow_move.update.assert_called_once_with(500.0)
mock_arrow_fade.update.assert_called_once_with(500.0)
class TestTimer(unittest.TestCase):
"""Test cases for the Timer class."""
@patch('libs.global_objects.get_config')
@patch('libs.global_objects.global_tex')
def test_initialization(self, mock_tex, mock_config):
"""Test timer initialization."""
mock_config.return_value = {"general": {"timer_frozen": False}}
mock_tex.get_animation.return_value = Mock()
mock_func = Mock()
timer = Timer(30, 0.0, mock_func)
self.assertEqual(timer.time, 30)
self.assertEqual(timer.counter, "30")
self.assertFalse(timer.is_finished)
self.assertFalse(timer.is_frozen)
@patch('libs.global_objects.audio')
@patch('libs.global_objects.get_config')
@patch('libs.global_objects.global_tex')
def test_countdown_normal(self, mock_tex, mock_config, mock_audio):
"""Test normal countdown behavior."""
mock_config.return_value = {"general": {"timer_frozen": False}}
mock_tex.get_animation.return_value = Mock(update=Mock(), start=Mock())
mock_func = Mock()
timer = Timer(15, 0.0, mock_func)
timer.update(1000.0)
self.assertEqual(timer.time, 14)
self.assertEqual(timer.counter, "14")
@patch('libs.global_objects.audio')
@patch('libs.global_objects.get_config')
@patch('libs.global_objects.global_tex')
def test_countdown_below_ten(self, mock_tex, mock_config, mock_audio):
"""Test countdown triggers animations below 10."""
mock_config.return_value = {"general": {"timer_frozen": False}}
mock_animation = Mock(update=Mock(), start=Mock())
mock_tex.get_animation.return_value = mock_animation
mock_func = Mock()
timer = Timer(10, 0.0, mock_func)
timer.update(1000.0)
self.assertEqual(timer.time, 9)
mock_audio.play_sound.assert_called_with('timer_blip', 'sound')
self.assertEqual(mock_animation.start.call_count, 3)
@patch('libs.global_objects.audio')
@patch('libs.global_objects.get_config')
@patch('libs.global_objects.global_tex')
def test_voice_triggers(self, mock_tex, mock_config, mock_audio):
"""Test voice announcements at specific times."""
mock_config.return_value = {"general": {"timer_frozen": False}}
mock_tex.get_animation.return_value = Mock(update=Mock(), start=Mock())
mock_func = Mock()
# Test 10 second voice
timer = Timer(11, 0.0, mock_func)
timer.update(1000.0)
mock_audio.play_sound.assert_called_with('voice_timer_10', 'voice')
# Test 5 second voice
timer = Timer(6, 0.0, mock_func)
timer.update(1000.0)
mock_audio.play_sound.assert_called_with('voice_timer_5', 'voice')
@patch('libs.global_objects.audio')
@patch('libs.global_objects.get_config')
@patch('libs.global_objects.global_tex')
def test_timer_finish_callback(self, mock_tex, mock_config, mock_audio):
"""Test callback is triggered when timer reaches zero."""
mock_config.return_value = {"general": {"timer_frozen": False}}
mock_tex.get_animation.return_value = Mock(update=Mock(), start=Mock())
mock_audio.is_sound_playing.return_value = False
mock_func = Mock()
timer = Timer(1, 0.0, mock_func)
timer.update(1000.0)
timer.update(2000.0)
mock_func.assert_called_once()
self.assertTrue(timer.is_finished)
@patch('libs.global_objects.get_config')
@patch('libs.global_objects.global_tex')
def test_timer_frozen(self, mock_tex, mock_config):
"""Test frozen timer doesn't count down."""
mock_config.return_value = {"general": {"timer_frozen": True}}
mock_tex.get_animation.return_value = Mock(update=Mock())
mock_func = Mock()
timer = Timer(10, 0.0, mock_func)
initial_time = timer.time
timer.update(1000.0)
self.assertEqual(timer.time, initial_time)
class TestCoinOverlay(unittest.TestCase):
"""Test cases for the CoinOverlay class."""
@patch('libs.global_objects.global_tex')
@patch('libs.global_objects.global_data')
@patch('libs.global_objects.OutlinedText')
def test_initialization(self, mock_text, mock_data, mock_tex):
"""Test coin overlay initialization."""
mock_tex.skin_config = {
"free_play": Mock(text={"en": "Free Play"}, font_size=24, y=100)
}
mock_data.config = {"general": {"language": "en"}}
_ = CoinOverlay()
mock_text.assert_called_once()
class TestAllNetIcon(unittest.TestCase):
"""Test cases for the AllNetIcon class."""
@patch('libs.global_objects.get_config')
def test_initialization_offline(self, mock_config):
"""Test AllNet icon initializes offline."""
mock_config.return_value = {"general": {"fake_online": False}}
icon = AllNetIcon()
self.assertFalse(icon.online)
@patch('libs.global_objects.get_config')
def test_initialization_online(self, mock_config):
"""Test AllNet icon initializes online."""
mock_config.return_value = {"general": {"fake_online": True}}
icon = AllNetIcon()
self.assertTrue(icon.online)
class TestEntryOverlay(unittest.TestCase):
"""Test cases for the EntryOverlay class."""
@patch('libs.global_objects.get_config')
def test_initialization(self, mock_config):
"""Test entry overlay initialization."""
mock_config.return_value = {"general": {"fake_online": False}}
overlay = EntryOverlay()
self.assertFalse(overlay.online)

333
test/libs/test_screen.py Normal file
View File

@@ -0,0 +1,333 @@
import unittest
from unittest.mock import Mock, patch
from libs.screen import Screen
class TestScreen(unittest.TestCase):
"""Test cases for the Screen class."""
def setUp(self):
"""Set up test fixtures."""
self.screen_name = "test_screen"
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_initialization(self, mock_audio, mock_tex):
"""Test screen initialization."""
screen = Screen(self.screen_name)
self.assertEqual(screen.screen_name, self.screen_name)
self.assertFalse(screen.screen_init)
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_on_screen_start(self, mock_audio, mock_tex):
"""Test on_screen_start loads textures and sounds."""
screen = Screen(self.screen_name)
screen.on_screen_start()
mock_tex.load_screen_textures.assert_called_once_with(self.screen_name)
mock_audio.load_screen_sounds.assert_called_once_with(self.screen_name)
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_do_screen_start_first_call(self, mock_audio, mock_tex):
"""Test _do_screen_start initializes screen on first call."""
screen = Screen(self.screen_name)
self.assertFalse(screen.screen_init)
screen._do_screen_start()
self.assertTrue(screen.screen_init)
mock_tex.load_screen_textures.assert_called_once_with(self.screen_name)
mock_audio.load_screen_sounds.assert_called_once_with(self.screen_name)
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_do_screen_start_subsequent_calls(self, mock_audio, mock_tex):
"""Test _do_screen_start doesn't reinitialize on subsequent calls."""
screen = Screen(self.screen_name)
screen._do_screen_start()
screen._do_screen_start()
screen._do_screen_start()
# Should only be called once despite multiple calls
mock_tex.load_screen_textures.assert_called_once()
mock_audio.load_screen_sounds.assert_called_once()
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_on_screen_end(self, mock_audio, mock_tex):
"""Test on_screen_end unloads resources and returns next screen."""
screen = Screen(self.screen_name)
screen.screen_init = True
next_screen = "next_screen"
result = screen.on_screen_end(next_screen)
self.assertEqual(result, next_screen)
self.assertFalse(screen.screen_init)
mock_audio.unload_all_sounds.assert_called_once()
mock_audio.unload_all_music.assert_called_once()
mock_tex.unload_textures.assert_called_once()
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_on_screen_end_unload_order(self, mock_audio, mock_tex):
"""Test that resources are unloaded in correct order."""
screen = Screen(self.screen_name)
screen.screen_init = True
manager = Mock()
manager.attach_mock(mock_audio.unload_all_sounds, 'unload_sounds')
manager.attach_mock(mock_audio.unload_all_music, 'unload_music')
manager.attach_mock(mock_tex.unload_textures, 'unload_textures')
screen.on_screen_end("next")
# Verify order: sounds, music, then textures
calls = manager.mock_calls
self.assertEqual(calls[0][0], 'unload_sounds')
self.assertEqual(calls[1][0], 'unload_music')
self.assertEqual(calls[2][0], 'unload_textures')
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_update_not_initialized(self, mock_audio, mock_tex):
"""Test update initializes screen if not already initialized."""
screen = Screen(self.screen_name)
self.assertFalse(screen.screen_init)
screen.update()
self.assertTrue(screen.screen_init)
mock_tex.load_screen_textures.assert_called_once()
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_update_already_initialized(self, mock_audio, mock_tex):
"""Test update doesn't reinitialize if already initialized."""
screen = Screen(self.screen_name)
screen.screen_init = True
screen.update()
# Should not load again
mock_tex.load_screen_textures.assert_not_called()
mock_audio.load_screen_sounds.assert_not_called()
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_update_returns_value(self, mock_audio, mock_tex):
"""Test update returns value from _do_screen_start."""
screen = Screen(self.screen_name)
with patch.object(screen, '_do_screen_start', return_value="test_value"):
result = screen.update()
self.assertEqual(result, "test_value")
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_draw_default_implementation(self, mock_audio, mock_tex):
"""Test draw has empty default implementation."""
screen = Screen(self.screen_name)
# Should not raise any errors
screen.draw()
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_do_draw_when_initialized(self, mock_audio, mock_tex):
"""Test _do_draw calls draw when screen is initialized."""
screen = Screen(self.screen_name)
screen.screen_init = True
with patch.object(screen, 'draw') as mock_draw:
screen._do_draw()
mock_draw.assert_called_once()
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_do_draw_when_not_initialized(self, mock_audio, mock_tex):
"""Test _do_draw doesn't call draw when screen is not initialized."""
screen = Screen(self.screen_name)
screen.screen_init = False
with patch.object(screen, 'draw') as mock_draw:
screen._do_draw()
mock_draw.assert_not_called()
class TestScreenSubclass(unittest.TestCase):
"""Test cases for Screen subclass behavior."""
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_subclass_custom_on_screen_start(self, mock_audio, mock_tex):
"""Test that subclass can override on_screen_start."""
class CustomScreen(Screen):
def __init__(self, name):
super().__init__(name)
self.custom_init_called = False
def on_screen_start(self):
super().on_screen_start()
self.custom_init_called = True
screen = CustomScreen("custom")
screen.on_screen_start()
self.assertTrue(screen.custom_init_called)
mock_tex.load_screen_textures.assert_called_once_with("custom")
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_subclass_custom_update(self, mock_audio, mock_tex):
"""Test that subclass can override update."""
class CustomScreen(Screen):
def __init__(self, name):
super().__init__(name)
self.update_count = 0
def update(self):
result = super().update()
self.update_count += 1
return result
screen = CustomScreen("custom")
screen.update()
screen.update()
self.assertEqual(screen.update_count, 2)
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_subclass_custom_draw(self, mock_audio, mock_tex):
"""Test that subclass can override draw."""
class CustomScreen(Screen):
def __init__(self, name):
super().__init__(name)
self.draw_called = False
def draw(self):
self.draw_called = True
screen = CustomScreen("custom")
screen.screen_init = True
screen._do_draw()
self.assertTrue(screen.draw_called)
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_subclass_custom_on_screen_end(self, mock_audio, mock_tex):
"""Test that subclass can override on_screen_end."""
class CustomScreen(Screen):
def __init__(self, name):
super().__init__(name)
self.cleanup_called = False
def on_screen_end(self, next_screen):
self.cleanup_called = True
return super().on_screen_end(next_screen)
screen = CustomScreen("custom")
screen.screen_init = True
result = screen.on_screen_end("next")
self.assertTrue(screen.cleanup_called)
self.assertEqual(result, "next")
class TestScreenLifecycle(unittest.TestCase):
"""Test cases for complete screen lifecycle."""
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_full_lifecycle(self, mock_audio, mock_tex):
"""Test complete screen lifecycle from start to end."""
screen = Screen("lifecycle_test")
# Initial state
self.assertFalse(screen.screen_init)
# Start screen
screen.update()
self.assertTrue(screen.screen_init)
mock_tex.load_screen_textures.assert_called_once_with("lifecycle_test")
mock_audio.load_screen_sounds.assert_called_once_with("lifecycle_test")
# Multiple updates don't reinitialize
screen.update()
screen.update()
self.assertEqual(mock_tex.load_screen_textures.call_count, 1)
# Draw while initialized
with patch.object(screen, 'draw') as mock_draw:
screen._do_draw()
mock_draw.assert_called_once()
# End screen
result = screen.on_screen_end("next_screen")
self.assertEqual(result, "next_screen")
self.assertFalse(screen.screen_init)
mock_audio.unload_all_sounds.assert_called_once()
mock_audio.unload_all_music.assert_called_once()
mock_tex.unload_textures.assert_called_once()
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_multiple_screen_transitions(self, mock_audio, mock_tex):
"""Test transitioning between multiple screens."""
screen1 = Screen("screen1")
screen2 = Screen("screen2")
screen3 = Screen("screen3")
# Initialize first screen
screen1.update()
self.assertTrue(screen1.screen_init)
# Transition to second screen
next_name = screen1.on_screen_end("screen2")
self.assertEqual(next_name, "screen2")
self.assertFalse(screen1.screen_init)
screen2.update()
self.assertTrue(screen2.screen_init)
# Transition to third screen
next_name = screen2.on_screen_end("screen3")
self.assertEqual(next_name, "screen3")
self.assertFalse(screen2.screen_init)
screen3.update()
self.assertTrue(screen3.screen_init)
@patch('libs.screen.tex')
@patch('libs.screen.audio')
def test_screen_reinitialize_after_end(self, mock_audio, mock_tex):
"""Test that screen can be reinitialized after ending."""
screen = Screen("reinit_test")
# First initialization
screen.update()
self.assertTrue(screen.screen_init)
# End screen
screen.on_screen_end("next")
self.assertFalse(screen.screen_init)
# Reinitialize
mock_tex.load_screen_textures.reset_mock()
mock_audio.load_screen_sounds.reset_mock()
screen.update()
self.assertTrue(screen.screen_init)
mock_tex.load_screen_textures.assert_called_once()
mock_audio.load_screen_sounds.assert_called_once()
if __name__ == '__main__':
unittest.main()

262
test/libs/test_texture.py Normal file
View File

@@ -0,0 +1,262 @@
import unittest
from unittest.mock import Mock, patch
from libs.texture import (
FramedTexture,
SkinInfo,
Texture,
TextureWrapper,
)
class TestSkinInfo(unittest.TestCase):
"""Test cases for the SkinInfo dataclass."""
def test_initialization(self):
"""Test SkinInfo initialization."""
skin_info = SkinInfo(
x=100.0,
y=200.0,
font_size=24,
width=300.0,
height=100.0,
text={"en": "Test", "ja": "テスト"}
)
self.assertEqual(skin_info.x, 100.0)
self.assertEqual(skin_info.y, 200.0)
self.assertEqual(skin_info.font_size, 24)
self.assertEqual(skin_info.width, 300.0)
self.assertEqual(skin_info.height, 100.0)
self.assertEqual(skin_info.text, {"en": "Test", "ja": "テスト"})
def test_repr(self):
"""Test SkinInfo string representation."""
skin_info = SkinInfo(
x=100.0,
y=200.0,
font_size=24,
width=300.0,
height=100.0,
text={"en": "Test"}
)
repr_str = repr(skin_info)
self.assertIn("100.0", repr_str)
self.assertIn("200.0", repr_str)
class TestTexture(unittest.TestCase):
"""Test cases for the Texture class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_texture = Mock()
self.mock_texture.width = 100
self.mock_texture.height = 50
@patch('libs.texture.ray')
def test_initialization_single_texture(self, mock_ray):
"""Test Texture initialization with single texture."""
texture = Texture(
name="test_texture",
texture=self.mock_texture,
init_vals={}
)
self.assertEqual(texture.name, "test_texture")
self.assertEqual(texture.texture, self.mock_texture)
self.assertEqual(texture.width, 100)
self.assertEqual(texture.height, 50)
self.assertEqual(texture.x, [0])
self.assertEqual(texture.y, [0])
@patch('libs.texture.ray')
def test_initialization_with_init_vals(self, mock_ray):
"""Test Texture initialization with init_vals."""
init_vals = {"x": 10, "y": 20}
texture = Texture(
name="test",
texture=self.mock_texture,
init_vals=init_vals
)
self.assertEqual(texture.init_vals, init_vals)
self.assertEqual(texture.name, "test")
@patch('libs.texture.ray')
def test_default_values(self, mock_ray):
"""Test Texture default values."""
texture = Texture(name="test", texture=self.mock_texture, init_vals={})
self.assertEqual(texture.x, [0])
self.assertEqual(texture.y, [0])
self.assertEqual(texture.x2, [100])
self.assertEqual(texture.y2, [50])
self.assertEqual(texture.controllable, [False])
@patch('libs.texture.ray')
def test_repr(self, mock_ray):
"""Test Texture string representation."""
texture = Texture(name="test", texture=self.mock_texture, init_vals={})
repr_str = repr(texture)
self.assertIn("test", repr_str)
class TestFramedTexture(unittest.TestCase):
"""Test cases for the FramedTexture class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_textures = [Mock() for _ in range(4)]
for tex in self.mock_textures:
tex.width = 200
tex.height = 100
@patch('libs.texture.ray')
def test_initialization(self, mock_ray):
"""Test FramedTexture initialization."""
framed = FramedTexture(
name="test_framed",
texture=self.mock_textures,
init_vals={}
)
self.assertEqual(framed.name, "test_framed")
self.assertEqual(framed.texture, self.mock_textures)
self.assertEqual(framed.width, 200)
self.assertEqual(framed.height, 100)
self.assertEqual(framed.x, [0])
self.assertEqual(framed.y, [0])
@patch('libs.texture.ray')
def test_default_values(self, mock_ray):
"""Test FramedTexture default values."""
framed = FramedTexture(
name="test",
texture=self.mock_textures,
init_vals={}
)
self.assertEqual(framed.x, [0])
self.assertEqual(framed.y, [0])
self.assertEqual(framed.x2, [200])
self.assertEqual(framed.y2, [100])
class TestTextureWrapper(unittest.TestCase):
"""Test cases for the TextureWrapper class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_texture = Mock()
self.mock_texture.width = 100
self.mock_texture.height = 50
@patch('libs.texture.get_config')
@patch('libs.texture.Path')
def test_initialization(self, mock_path_cls, mock_get_config):
"""Test TextureWrapper initialization."""
mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}}
# Mock the skin_config.json file
mock_path_instance = Mock()
mock_config_path = Mock()
mock_config_path.exists.return_value = True
mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}'
mock_path_instance.__truediv__ = Mock(return_value=mock_config_path)
mock_path_cls.return_value = mock_path_instance
wrapper = TextureWrapper()
self.assertEqual(wrapper.screen_width, 1280)
self.assertEqual(wrapper.screen_height, 720)
self.assertIsInstance(wrapper.textures, dict)
@patch('libs.texture.get_config')
@patch('libs.texture.Path')
def test_get_animation(self, mock_path_cls, mock_get_config):
"""Test getting animation from list."""
mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}}
# Mock the skin_config.json file
mock_path_instance = Mock()
mock_config_path = Mock()
mock_config_path.exists.return_value = True
mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}'
mock_path_instance.__truediv__ = Mock(return_value=mock_config_path)
mock_path_cls.return_value = mock_path_instance
mock_animation = Mock()
wrapper = TextureWrapper()
wrapper.animations = {0: mock_animation}
result = wrapper.get_animation(0)
self.assertEqual(result, mock_animation)
@patch('libs.texture.get_config')
@patch('libs.texture.Path')
@patch('libs.texture.ray')
def test_read_tex_obj_data(self, mock_ray, mock_path_cls, mock_get_config):
"""Test reading texture object data from JSON."""
mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}}
# Mock the skin_config.json file
mock_path_instance = Mock()
mock_config_path = Mock()
mock_config_path.exists.return_value = True
mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}'
mock_path_instance.__truediv__ = Mock(return_value=mock_config_path)
mock_path_cls.return_value = mock_path_instance
wrapper = TextureWrapper()
# Create a mock texture object
mock_texture = Mock()
mock_texture.x = [0]
mock_texture.y = [0]
# Test with a dictionary mapping
tex_mapping = {"x": 10, "y": 20}
wrapper._read_tex_obj_data(tex_mapping, mock_texture)
# Verify the texture attributes were updated (they are lists)
self.assertEqual(mock_texture.x, [10])
self.assertEqual(mock_texture.y, [20])
@patch('libs.texture.get_config')
@patch('libs.texture.Path')
def test_read_tex_obj_data_not_exists(self, mock_path_cls, mock_get_config):
"""Test reading texture data with empty mapping."""
mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}}
# Mock the skin_config.json file
mock_path_instance = Mock()
mock_config_path = Mock()
mock_config_path.exists.return_value = True
mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}'
mock_path_instance.__truediv__ = Mock(return_value=mock_config_path)
mock_path_cls.return_value = mock_path_instance
wrapper = TextureWrapper()
# Create a mock texture object
mock_texture = Mock()
mock_texture.x = [0]
mock_texture.y = [0]
# Test with empty mapping (should not modify texture)
tex_mapping = {}
wrapper._read_tex_obj_data(tex_mapping, mock_texture)
# Verify the texture attributes remained unchanged
self.assertEqual(mock_texture.x, [0])
self.assertEqual(mock_texture.y, [0])
if __name__ == '__main__':
unittest.main()

182
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@@ -83,6 +83,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
] ]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" },
{ url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" },
{ url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" },
{ url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" },
{ url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" },
{ url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" },
{ url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" },
{ url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" },
{ url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" },
{ url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" },
{ url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" },
{ url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" },
{ url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" },
{ url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" },
{ url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" },
{ url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" },
{ url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "nuitka" name = "nuitka"
version = "2.8.4" version = "2.8.4"
@@ -102,6 +181,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
] ]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.22" version = "2.22"
@@ -111,6 +208,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
] ]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyinstrument"
version = "5.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/80/ce/824ee634994e612156f7b84eaf50b8523c676ebfed8d8dd12939a82f4c15/pyinstrument-5.1.1.tar.gz", hash = "sha256:bc401cda990b3c1cfe8e0e0473cbd605df3c63b73478a89ac4ab108f2184baa8", size = 264730, upload-time = "2025-08-12T11:35:43.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/d4/b94f47aa7d301f6cdf5924bb75caacd0d0a1852bd4e876e3a64fc5798dad/pyinstrument-5.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:45af421c60c943a7f1619afabeba4951d4cc16b4206490d7d5b7ef5a4e2dfd42", size = 130315, upload-time = "2025-08-12T11:34:52.91Z" },
{ url = "https://files.pythonhosted.org/packages/1e/42/1bc2f28e139f69a0918d5d5dc1d59e65c640d4da9dd153fa48c2a8a87dd9/pyinstrument-5.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2603db3d745a65de66c96929ab9b0fcce050511eb24e32856ea2458785b8917f", size = 122805, upload-time = "2025-08-12T11:34:54.201Z" },
{ url = "https://files.pythonhosted.org/packages/a8/85/2f0c9115cd8a01e0a18d0650d9f3f20ff71e8ca17bd4af60dd3a0cb76f8a/pyinstrument-5.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fe32492100efaa1b0a488c237fe420fdaf141646733a31a97f96c4e1fa6bbf8", size = 148210, upload-time = "2025-08-12T11:34:55.662Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/3c73a63e6913378cc7e9ffb5af1e50836511eee83b7c7bf252fad7ec24e4/pyinstrument-5.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:999b5373f8b1e846357923063ae5c9275ad8a85ed4e0a42960a349288d1f5007", size = 146995, upload-time = "2025-08-12T11:34:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/ab/8b/d21f4b6d8849881e9572967818e3e6d2dcb212e7dfa89e4e356d359db32b/pyinstrument-5.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58a2f69052178ec624e4df0cf546eda48b3a381572ac1cb3272b4c163888af9d", size = 147029, upload-time = "2025-08-12T11:34:58.255Z" },
{ url = "https://files.pythonhosted.org/packages/8a/4d/1e43cecf2bcf4a3dd1100f4fc7a3da6438a65d0b95ca7b8ab5d094ea7c0b/pyinstrument-5.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9bbc00d2e258edbefeb39b61ad4636099b08acd1effdd40d76883a13e7bf5a", size = 146668, upload-time = "2025-08-12T11:34:59.401Z" },
{ url = "https://files.pythonhosted.org/packages/34/48/00322b48e7adb665d04303b487454eb0c13a76ec0af8da20f452098fcc12/pyinstrument-5.1.1-cp313-cp313-win32.whl", hash = "sha256:cf2d8933e2aeaa02d4cb6279d83ef11ee882fb243fff96e3378153a730aadd6e", size = 124288, upload-time = "2025-08-12T11:35:00.514Z" },
{ url = "https://files.pythonhosted.org/packages/f5/14/d56515a110f74799aefc7489c1578ce4d99a4d731309559a427f954e7abc/pyinstrument-5.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:2402683a92617066b13a6d48f904396dcd15938016875b392534df027660eed4", size = 125041, upload-time = "2025-08-12T11:35:01.913Z" },
{ url = "https://files.pythonhosted.org/packages/18/2b/e4bdcabb5ae67de2ec3fa1f6e4eb4ae707b0bf460f895d4594792cdc919b/pyinstrument-5.1.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:688acba1c00cad73e43254e610f8e384a53ced3b0dbb5268fb44636e2b99663e", size = 130358, upload-time = "2025-08-12T11:35:03.569Z" },
{ url = "https://files.pythonhosted.org/packages/20/36/616f8db63997c096d3fb65e657cdf5bd2a63b53ed24a14750770dc500979/pyinstrument-5.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:116f5ad8cec4d6f5626305d7c1a104f5845a084bfb4b192d231eb8c41ea81f9a", size = 122827, upload-time = "2025-08-12T11:35:04.661Z" },
{ url = "https://files.pythonhosted.org/packages/af/7a/4f5d2bbc7c2466d46eb5ff47c6e667464eead47140e01a64be45215a59d4/pyinstrument-5.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d139d12a637001d3884344330054ce8335b2c8165dc3dd239726e1b358576bd", size = 147947, upload-time = "2025-08-12T11:35:05.786Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/c9b0081c0e52789a910390ce44e54c1318999d74386f15d92d0deb522aff/pyinstrument-5.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc5b87b1e27bec94457fed8d03c755a3c09edb4f35d975dbdffd77d863173254", size = 146702, upload-time = "2025-08-12T11:35:07.202Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1b/745ed7997da22ae68ff21b8f28e5e3a97b220335dce4ee7cf46d5eb17b32/pyinstrument-5.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15f4a2ed9562efab34b555e1208955cf9681b2272489d7a59cd0e289344ada2e", size = 146836, upload-time = "2025-08-12T11:35:08.297Z" },
{ url = "https://files.pythonhosted.org/packages/70/f0/05cefdcf79d1901f9d179e7f55f3acaadbc5fee7af955cebb3f555280638/pyinstrument-5.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1cb0c79bfa2b2b5734213429c9d7f455e5af664cfde785c69a5780f6c532c1fd", size = 146463, upload-time = "2025-08-12T11:35:09.483Z" },
{ url = "https://files.pythonhosted.org/packages/6c/cb/6a6f33316be3c7b8247f8ca0e418a2b6fb68d64c227169b7dbee50009366/pyinstrument-5.1.1-cp314-cp314-win32.whl", hash = "sha256:3b9f1216ae4848a8983dc405e1a42e46e75bd8ae96aaba328d4358b8fc80a7a0", size = 124950, upload-time = "2025-08-12T11:35:11.607Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ea/99caeb29f446f57d077a83c7c5f2b7c27c1719984d425f679bf2ec1eb6b0/pyinstrument-5.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:26971d4a17e0d5d4f6737e71c9de7a7ce5c83ab7daf078c6bf330be41d65273b", size = 125720, upload-time = "2025-08-12T11:35:12.683Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d0/953b75d634565ef34f8ed559f2e4af7cd1f2d5f5b578092e8f1d8199e4b1/pyinstrument-5.1.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:62362843884d654401ec4c25fed35f4b4ded077d96b3396f1e791c31e4203d3e", size = 131258, upload-time = "2025-08-12T11:35:13.805Z" },
{ url = "https://files.pythonhosted.org/packages/a6/a4/4ec87cfd0974d79b2fcd72b3e20336fc65b96a5b08f2eb2867bf71b27b82/pyinstrument-5.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f2d640230b71c6d9ac8f27a9c5cd07fc8a6acad9196d1e48d9c33658b176fb80", size = 123276, upload-time = "2025-08-12T11:35:14.933Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/6a210989c8ede85f91b7e4ba5d9730492f1d081762570c06c750d787536c/pyinstrument-5.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f54f7292c63461c75ddf193f5e733803e463ccbc54f2fb7c9591337ddea7d10", size = 155767, upload-time = "2025-08-12T11:35:16.124Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a8/5ac81ffbfe36d2e5c3332a9452746a21540987da0d9491db751a905bba13/pyinstrument-5.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c156eb442f9f22960ae16bd195051863d5e8a68b877926e88bbaf8bbdc1456d1", size = 153423, upload-time = "2025-08-12T11:35:17.312Z" },
{ url = "https://files.pythonhosted.org/packages/3f/55/5620c2a61403cde044e81e33056c14fbf5793eea33f67f2223d61abec9ae/pyinstrument-5.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:caadaf67ad5926c46af784316024793c909b9e9ee550475855fd32171c4bd033", size = 153542, upload-time = "2025-08-12T11:35:18.729Z" },
{ url = "https://files.pythonhosted.org/packages/7a/83/a8f22466652250a847dfdf58f9a2717b470fdbbcb075c7f730bf608041a6/pyinstrument-5.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88ef2e8f483a5e1501d79a7ebdab592a597467810ed24d8db09ab6f568e938d3", size = 152337, upload-time = "2025-08-12T11:35:19.849Z" },
{ url = "https://files.pythonhosted.org/packages/0d/a6/cd4590da14deaeda6315519c26064874bbb9648a1358b80e8a8ca5d4add0/pyinstrument-5.1.1-cp314-cp314t-win32.whl", hash = "sha256:265bc4389f82e6521777bfab426a62a15c4940955e86f75db79a44e7349f9757", size = 125621, upload-time = "2025-08-12T11:35:21.201Z" },
{ url = "https://files.pythonhosted.org/packages/b3/30/177102e798539368aef25688a6a171d66ec92e6f16b6b651a89045a2bd13/pyinstrument-5.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fa254f269a72a007b5d02c18cd4b67081e0efabbd33e18acdbd5e3be905afa06", size = 126528, upload-time = "2025-08-12T11:35:22.578Z" },
]
[[package]] [[package]]
name = "pypresence" name = "pypresence"
version = "4.6.1" version = "4.6.1"
@@ -122,11 +260,13 @@ wheels = [
[[package]] [[package]]
name = "pytaiko" name = "pytaiko"
version = "1.0" version = "1.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "av" }, { name = "av" },
{ name = "pyinstrument" },
{ name = "pypresence" }, { name = "pypresence" },
{ name = "pytest" },
{ name = "raylib-sdl" }, { name = "raylib-sdl" },
{ name = "tomlkit" }, { name = "tomlkit" },
] ]
@@ -134,18 +274,54 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "nuitka" }, { name = "nuitka" },
{ name = "pytest-cov" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "av", specifier = ">=16.0.1" }, { name = "av", specifier = ">=16.0.1" },
{ name = "pyinstrument", specifier = ">=5.1.1" },
{ name = "pypresence", specifier = ">=4.6.1" }, { name = "pypresence", specifier = ">=4.6.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "raylib-sdl", specifier = ">=5.5.0.2" }, { name = "raylib-sdl", specifier = ">=5.5.0.2" },
{ name = "tomlkit", specifier = ">=0.13.3" }, { name = "tomlkit", specifier = ">=0.13.3" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "nuitka", specifier = ">=2.8.4" }] dev = [
{ name = "nuitka", specifier = ">=2.8.4" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]] [[package]]
name = "raylib-sdl" name = "raylib-sdl"