22 Commits

Author SHA1 Message Date
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
28 changed files with 4550 additions and 185 deletions

View File

@@ -84,6 +84,33 @@ jobs:
- name: Setup Python
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
shell: bash
run: |

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

View File

@@ -5,7 +5,8 @@ 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 (
RL_FUNC_ADD,
@@ -26,6 +27,8 @@ from libs.utils import (
global_data,
global_tex,
)
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
@@ -67,6 +70,8 @@ class Screens:
DAN_RESULT = "DAN_RESULT"
PRACTICE_SELECT = "PRACTICE_SELECT"
GAME_PRACTICE = "GAME_PRACTICE"
AI_SELECT = "AI_SELECT"
AI_GAME = "AI_GAME"
SETTINGS = "SETTINGS"
DEV_MENU = "DEV_MENU"
LOADING = "LOADING"
@@ -161,12 +166,12 @@ def create_song_db():
def update_camera_for_window_size(camera, virtual_width, virtual_height):
"""Update camera zoom, offset, scale, and rotation to maintain aspect ratio"""
screen_width = ray.get_screen_width()
screen_height = ray.get_screen_height()
screen_width = ray.GetScreenWidth()
screen_height = ray.GetScreenHeight()
if screen_width == 0 or screen_height == 0:
camera.zoom = 1.0
camera.offset = ray.Vector2(0, 0)
camera.offset = (0, 0)
camera.rotation = 0.0
return
@@ -186,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
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_y - zoom_offset_y - v_scale_offset_y + (global_data.camera.offset.y * scale)
)
camera.rotation = global_data.camera.rotation
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'
def setup_logging():
log_level = global_data.config["general"]["log_level"]
if sys.platform == 'win32':
import io
@@ -219,41 +217,33 @@ def main():
handlers=[console_handler, file_handler]
)
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"]:
ray.set_config_flags(ray.ConfigFlags.FLAG_VSYNC_HINT)
ray.SetConfigFlags(ray.FLAG_VSYNC_HINT)
logger.info("VSync enabled")
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']}")
ray.set_config_flags(ray.ConfigFlags.FLAG_MSAA_4X_HINT)
ray.set_config_flags(ray.ConfigFlags.FLAG_WINDOW_RESIZABLE)
ray.set_trace_log_level(ray.TraceLogLevel.LOG_WARNING)
ray.SetConfigFlags(ray.FLAG_MSAA_4X_HINT)
ray.SetConfigFlags(ray.FLAG_WINDOW_RESIZABLE)
ray.SetTraceLogLevel(ray.LOG_WARNING)
ray.init_window(screen_width, screen_height, "PyTaiko")
logger.info(f"Window initialized: {screen_width}x{screen_height}")
global_tex.load_screen_textures('global')
logger.info("Global screen textures loaded")
global_tex.load_zip('chara', 'chara_0')
global_tex.load_zip('chara', 'chara_1')
logger.info("Chara textures loaded")
if global_data.config["video"]["borderless"]:
ray.toggle_borderless_windowed()
logger.info("Borderless window enabled")
if global_data.config["video"]["fullscreen"]:
ray.toggle_fullscreen()
logger.info("Fullscreen enabled")
current_screen = Screens.LOADING
def init_audio():
audio.set_log_level((logger.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")
def check_args():
if len(sys.argv) == 1:
pass
else:
return Screens.LOADING
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('difficulty', type=int, nargs='?', default=None,
@@ -279,19 +269,74 @@ def main():
global_data.session_data[PlayerNum.P1].selected_song = path
global_data.session_data[PlayerNum.P1].selected_difficulty = selected_difficulty
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()
if curr_fps != 0 and curr_fps != last_fps:
last_fps = curr_fps
if last_fps < 30:
ray.DrawText(f'{last_fps} FPS'.encode('utf-8'), 20, 20, 20, ray.RED)
elif last_fps < 60:
ray.DrawText(f'{last_fps} FPS'.encode('utf-8'), 20, 20, 20, ray.YELLOW)
else:
ray.DrawText(f'{last_fps} FPS'.encode('utf-8'), 20, 20, 20, ray.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}")
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()
title_screen = TitleScreen('title')
@@ -303,6 +348,8 @@ def main():
game_screen_2p = TwoPlayerGameScreen('game')
game_screen_practice = PracticeGameScreen('game')
practice_select_screen = PracticeSongSelectScreen('song_select')
ai_select_screen = AISongSelectScreen('song_select')
ai_game_screen = AIBattleGameScreen('game')
result_screen = ResultScreen('result')
result_screen_2p = TwoPlayerResultScreen('result')
settings_screen = SettingsScreen('settings')
@@ -320,6 +367,8 @@ def main():
Screens.GAME: game_screen,
Screens.GAME_2P: game_screen_2p,
Screens.GAME_PRACTICE: game_screen_practice,
Screens.AI_SELECT: ai_select_screen,
Screens.AI_GAME: ai_game_screen,
Screens.RESULT: result_screen,
Screens.RESULT_2P: result_screen_2p,
Screens.SETTINGS: settings_screen,
@@ -330,51 +379,41 @@ def main():
Screens.LOADING: load_screen
}
camera = ray.Camera2D()
camera.target = ray.Vector2(0, 0)
camera = pyray.Camera2D()
camera.target = pyray.Vector2(0, 0)
camera.rotation = 0.0
update_camera_for_window_size(camera, screen_width, screen_height)
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.set_exit_key(global_data.config["keys"]["exit_key"])
ray.rlSetBlendFactorsSeparate(RL_SRC_ALPHA, RL_ONE_MINUS_SRC_ALPHA, RL_ONE, RL_ONE_MINUS_SRC_ALPHA, RL_FUNC_ADD, RL_FUNC_ADD)
ray.SetExitKey(global_data.config["keys"]["exit_key"])
ray.hide_cursor()
ray.HideCursor()
logger.info("Cursor hidden")
last_fps = 1
last_color = ray.BLACK
last_color = pyray.BLACK
while not ray.window_should_close():
while not ray.WindowShouldClose():
if discord_connected:
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"}]
)
check_discord_heartbeat(current_screen)
if ray.is_key_pressed(global_data.config["keys"]["fullscreen_key"]):
ray.toggle_fullscreen()
if ray.IsKeyPressed(global_data.config["keys"]["fullscreen_key"]):
ray.ToggleFullscreen()
logger.info("Toggled fullscreen")
elif ray.is_key_pressed(global_data.config["keys"]["borderless_key"]):
ray.toggle_borderless_windowed()
elif ray.IsKeyPressed(global_data.config["keys"]["borderless_key"]):
ray.ToggleBorderlessWindowed()
logger.info("Toggled borderless windowed mode")
update_camera_for_window_size(camera, screen_width, screen_height)
ray.begin_drawing()
ray.BeginDrawing()
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
ray.begin_mode_2d(camera)
ray.begin_blend_mode(ray.BlendMode.BLEND_CUSTOM_SEPARATE)
pyray.begin_mode_2d(camera)
ray.BeginBlendMode(ray.BLEND_CUSTOM_SEPARATE)
screen = screen_mapping[current_screen]
@@ -388,26 +427,15 @@ def main():
global_data.input_locked = 0
if global_data.config["general"]["fps_counter"]:
curr_fps = ray.get_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)
draw_fps(last_fps)
ray.draw_rectangle(-screen_width, 0, 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)
draw_outer_border(screen_width, screen_height, last_color)
ray.end_blend_mode()
ray.end_mode_2d()
ray.end_drawing()
ray.EndBlendMode()
ray.EndMode2D()
ray.EndDrawing()
ray.close_window()
ray.CloseWindow()
audio.close_audio_device()
if discord_connected:
RPC.close()

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)
![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)
[![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)
[![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">

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

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

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

@@ -119,7 +119,8 @@ except OSError as e:
class AudioEngine:
"""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)
if sample_rate < 0:
self.target_sample_rate = 44100
@@ -131,7 +132,10 @@ class AudioEngine:
self.audio_device_ready = False
self.volume_presets = volume_presets
self.sounds_path = Path(f"Skins/{get_config()["paths"]["skin"]}/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):
lib.set_log_level(level) # type: ignore

View File

@@ -107,7 +107,7 @@ class Chara2D:
self.current_anim = self.past_anim
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.
@@ -117,9 +117,9 @@ class Chara2D:
mirror (bool): Whether to mirror the character horizontally.
"""
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:
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:
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

@@ -1038,7 +1038,8 @@ class Directory(FileSystemItem):
'RECENT',
'FAVORITE',
'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):
super().__init__(path, name)
@@ -1143,6 +1144,7 @@ class FileNavigator:
self.genre_bg = None
self.song_count = 0
self.in_dan_select = False
self.current_search = ''
logger.info("FileNavigator initialized")
def initialize(self, root_dirs: list[Path]):
@@ -1392,6 +1394,42 @@ class FileNavigator:
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.tja.metadata.subtitle["en"].lower(), search_name.lower()) < 2:
items.append(song)
return items
def load_current_directory(self, selected_item: Optional[Directory] = None):
"""Load pre-generated items for the current directory (unified for root and subdirs)"""
dir_key = str(self.current_dir)
@@ -1438,6 +1476,8 @@ class FileNavigator:
content_items = self.load_diff_sort_items(selected_item, dir_key)
elif selected_item.collection == Directory.COLLECTIONS[4]:
content_items = self.load_recommended_items(selected_item, dir_key)
elif selected_item.collection == Directory.COLLECTIONS[5]:
content_items = self.search_song(self.current_search)
if content_items == []:
self.go_back()

View File

@@ -14,6 +14,7 @@ class PlayerNum(IntEnum):
P2 = 2
TWO_PLAYER = 3
DAN = 4
AI = 5
class ScoreMethod():
GEN3 = "gen3"
@@ -44,6 +45,7 @@ class Modifiers:
display: bool = False
inverse: bool = False
random: int = 0
subdiff: int = 0
@dataclass
class DanResultSong:

View File

@@ -58,6 +58,9 @@ class Nameplate:
"""
tex = global_tex
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:
frame = 2
title_offset = 0

View File

@@ -72,6 +72,13 @@ class TextureWrapper:
self.animations: dict[int, BaseAnimation] = dict()
self.skin_config: dict[str, SkinInfo] = dict()
self.graphics_path = Path(f'Skins/{get_config()['paths']['skin']}/Graphics')
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():
raise Exception("skin is missing a skin_config.json")
@@ -290,7 +297,7 @@ class TextureWrapper:
else:
ray.DrawTexturePro(tex_object.texture, source_rect, dest_rect, origin, rotation, final_color)
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,
mirror: str = '', x: float = 0, y: float = 0, x2: float = 0, y2: float = 0,

View File

@@ -1148,7 +1148,6 @@ def modifier_inverse(notes: NoteList):
def modifier_random(notes: NoteList, value: int):
"""Randomly modifies the type of the notes in the given NoteList.
value: 1 == kimagure, 2 == detarame"""
#value: 1 == kimagure, 2 == detarame
modded_notes = notes.play_notes.copy()
percentage = int(len(modded_notes) / 5) * value
selected_notes = random.sample(range(len(modded_notes)), percentage)
@@ -1166,4 +1165,393 @@ def apply_modifiers(notes: NoteList, modifiers: Modifiers):
play_notes = modifier_inverse(notes)
play_notes = modifier_random(notes, modifiers.random)
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

@@ -6,11 +6,30 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"av>=16.0.1",
"pyinstrument>=5.1.1",
"pypresence>=4.6.1",
"pytest>=9.0.2",
"raylib-sdl>=5.5.0.2",
"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]
exclude = ["*.git", ".github/", ".venv/", "cache/"]
paths = ["."]
@@ -18,4 +37,5 @@ paths = ["."]
[dependency-groups]
dev = [
"nuitka>=2.8.4",
"pytest-cov>=6.0.0",
]

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

@@ -0,0 +1,463 @@
import copy
import logging
import random
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.tja import Note, 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 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)
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.tja = TJAParser(song, start_delay=self.start_delay)
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'])
if self.tja.metadata.wave.exists() and self.tja.metadata.wave.is_file() and self.song_music is None:
self.song_music = audio.load_music_stream(self.tja.metadata.wave, 'song')
tja_copy = copy.deepcopy(self.tja)
self.player_1 = PlayerNoChara(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.gauge = AIGauge(self.player_1.player_num, self.player_1.difficulty, self.tja.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, global_data.session_data[global_data.player_num].selected_difficulty, True, ai_modifiers)
self.start_ms = (get_current_ms() - self.tja.metadata.offset*1000)
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(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.tja.metadata.offset*1000
self.update_background(current_time)
if self.song_music is not None:
audio.update_music_stream(self.song_music)
self.player_1.update(self.current_ms, current_time, None)
self.player_2.update(self.current_ms, current_time, None)
section_notes = self.total_notes // 5
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
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 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):
self.background.update(current_time, (self.player_1.good_count, self.player_1.ok_count), (self.player_2.good_count, self.player_2.ok_count))
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.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 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, controllable=True)
# 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):
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 = 0.90
self.ok_percentage = 0.07
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, controllable=True)
# 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.contest_point_fade.start()
def update(self, current_ms: float, player_judge: tuple[int, int], ai_judge: tuple[int, int]):
self.contest_point_fade.update(current_ms)
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 + 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', '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.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
def update_players(self, current_time) -> str:
self.player_1.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()
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.tja.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.tja.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
if self.player_num == PlayerNum.P1:
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))
else:
self.nameplate.draw(tex.skin_config["song_select_nameplate_2p"].x, tex.skin_config["song_select_nameplate_2p"].y)
self.chara.draw(mirror=True, x=tex.skin_config["song_select_chara_2p"].x, y=tex.skin_config["song_select_chara_2p"].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

@@ -56,6 +56,9 @@ class EntryScreen(Screen):
self.chara = Chara2D(0)
self.announce_played = False
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')
def on_screen_end(self, next_screen: str):
@@ -164,7 +167,7 @@ class EntryScreen(Screen):
tex.draw_texture('side_select', 'box_right', 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)
@@ -180,7 +183,7 @@ class EntryScreen(Screen):
else:
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', '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)
def draw_player_drum(self):
@@ -449,8 +452,9 @@ class BoxManager:
self.box_titles: list[OutlinedText] = [
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_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_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", "SETTINGS"]
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),
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.boxes = [Box(self.box_titles[i], self.box_locations[i]) for i in range(len(self.box_titles))]
self.selected_box_index = 0
@@ -509,7 +513,7 @@ class BoxManager:
def update(self, current_time_ms: float, is_2p: bool):
self.is_2p = 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):
box.location = self.box_locations[i]
self.fade_out.update(current_time_ms)

View File

@@ -401,7 +401,7 @@ class Player:
def reset_chart(self):
notes, self.branch_m, self.branch_e, self.branch_n = self.tja.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.kat_notes = deque([note for note in self.play_notes if note.type in {NoteType.KAT, NoteType.KAT_L}])
@@ -449,7 +449,7 @@ class Player:
self.bpm = 120
if self.timeline and hasattr(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):
self.get_load_time(note)
if note.type == NoteType.TAIL:
@@ -490,6 +490,7 @@ class Player:
for branch in (self.branch_m, self.branch_e, self.branch_n):
if branch:
for section in branch:
section.play_notes, section.draw_notes, section.bars = apply_modifiers(section, self.modifiers)
if section.draw_notes:
for note in section.draw_notes:
self.get_load_time(note)
@@ -1281,21 +1282,21 @@ class Player:
modifiers_to_draw.append('mod_shinuchi')
# Speed modifiers
if global_data.modifiers[self.player_num].speed >= 4:
if self.modifiers.speed >= 4:
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')
elif global_data.modifiers[self.player_num].speed > 1:
elif self.modifiers.speed > 1:
modifiers_to_draw.append('mod_baisaku')
# Other modifiers
if global_data.modifiers[self.player_num].display:
if self.modifiers.display:
modifiers_to_draw.append('mod_doron')
if global_data.modifiers[self.player_num].inverse:
if self.modifiers.inverse:
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')
elif global_data.modifiers[self.player_num].random == 1:
elif self.modifiers.random == 1:
modifiers_to_draw.append('mod_kimagure')
# Draw all modifiers in one batch
@@ -1335,13 +1336,13 @@ class Player:
self.judge_counter.draw()
# 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:
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)
else:
tex.draw_texture('lane', 'auto_icon', index=self.is_2p)
self.draw_modifiers()
self.chara.draw(y=(self.is_2p*tex.skin_config["game_2p_offset"].y))
@@ -1359,6 +1360,8 @@ class Player:
def draw(self, ms_from_start: float, start_ms: float, mask_shader: ray.Shader, dan_transition = None):
# Group 1: Background and lane elements
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:
self.branch_indicator.draw()
if self.gauge is not None:
@@ -1766,7 +1769,7 @@ class BalloonAnimation:
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:
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:
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))

View File

@@ -45,6 +45,7 @@ class State:
BROWSING = 0
SONG_SELECTED = 1
DIFF_SORTING = 2
SEARCHING = 3
class SongSelectScreen(Screen):
BOX_CENTER = 444
@@ -69,6 +70,7 @@ class SongSelectScreen(Screen):
self.game_transition = None
self.demo_song = None
self.diff_sort_selector = None
self.search_box = None
self.coin_overlay = CoinOverlay()
self.allnet_indicator = AllNetIcon()
self.indicator = Indicator(Indicator.State.SELECT)
@@ -124,7 +126,7 @@ class SongSelectScreen(Screen):
self.screen_init = False
self.reset_demo_music()
current_item = self.navigator.get_current_item()
if isinstance(current_item, SongFile):
if isinstance(current_item, SongFile) and self.player_1.is_ready:
self.finalize_song(current_item)
self.player_1.nameplate.unload()
if isinstance(current_item.box, SongBox) and current_item.box.yellow_box is not None:
@@ -167,6 +169,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.text_fade_in.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":
current_song = self.navigator.get_current_item()
if isinstance(current_song, Directory) and current_song.box.genre_index == GenreIndex.DAN:
@@ -225,6 +232,21 @@ class SongSelectScreen(Screen):
self.navigator.diff_sort_level = level
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):
"""Reset to browsing state"""
self.player_1.selected_song = False
@@ -324,6 +346,9 @@ class SongSelectScreen(Screen):
if self.diff_sort_selector is not None:
self.diff_sort_selector.update(current_time)
if self.search_box is not None:
self.search_box.update(current_time)
self.check_for_selection()
for song in self.navigator.items:
@@ -356,7 +381,7 @@ class SongSelectScreen(Screen):
def draw_players(self):
self.player_1.draw(self.state)
def draw(self):
def draw_background(self):
width = tex.textures['box']['background'].width
genre_index = self.genre_index
last_genre_index = self.last_genre_index
@@ -369,6 +394,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)
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()
if self.navigator.genre_bg is not None and self.state == State.BROWSING:
@@ -386,6 +431,9 @@ class SongSelectScreen(Screen):
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:
@@ -400,21 +448,7 @@ class SongSelectScreen(Screen):
if isinstance(curr_item, SongFile):
curr_item.box.draw_score_history()
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()
self.draw_overlay()
class SongSelectPlayer:
def __init__(self, player_num: PlayerNum, text_fade_in):
@@ -428,6 +462,7 @@ class SongSelectPlayer:
self.diff_select_move_right = False
self.neiro_selector = None
self.modifier_selector = None
self.search_string = ''
# References to shared animations
self.diff_selector_move_1 = tex.get_animation(26, is_copy=True)
@@ -511,6 +546,8 @@ class SongSelectPlayer:
return "go_back"
elif isinstance(selected_item, Directory) and selected_item.collection == Directory.COLLECTIONS[3]:
return "diff_sort"
elif isinstance(selected_item, Directory) and selected_item.collection == Directory.COLLECTIONS[5]:
return "search"
else:
return "select_song"
@@ -538,6 +575,20 @@ class SongSelectPlayer:
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):
"""Main input dispatcher. Delegates to state-specific handlers."""
if self.is_voice_playing() or self.is_ready:
@@ -549,6 +600,8 @@ class SongSelectPlayer:
screen.handle_input_selected()
elif state == State.DIFF_SORTING:
screen.handle_input_diff_sort()
elif state == State.SEARCHING:
screen.handle_input_search()
def handle_input_selected(self, current_item):
"""Handle input for selecting difficulty. Returns 'cancel', 'confirm', or None"""
@@ -592,8 +645,6 @@ class SongSelectPlayer:
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
if isinstance(selected_song, Directory):
raise Exception("Directory was chosen instead of song")
diffs = sorted(selected_song.tja.metadata.course_data)
prev_diff = self.selected_difficulty
ret_val = None
@@ -1027,12 +1078,37 @@ class DiffSortSelect:
else:
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:
"""The menu for selecting the game hitsounds."""
def __init__(self, player_num: PlayerNum):
self.player_num = 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.append('無音')
self.load_sound()
@@ -1053,9 +1129,9 @@ class NeiroSelector:
if self.selected_sound == len(self.sounds):
return
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:
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):
if self.move.is_started and not self.move.is_finished:
@@ -1147,16 +1223,23 @@ class ModifierSelector:
"inverse": "mod_abekobe",
"random": "mod_kimagure"
}
NAME_MAP = {
NAME_MAP_JA = {
"auto": "オート",
"speed": "はやさ",
"display": "ドロン",
"inverse": "あべこべ",
"random": "ランダム"
}
NAME_MAP_EN = {
"auto": "Auto",
"speed": "Speed",
"display": "Display",
"inverse": "Inverse",
"random": "Random"
}
def __init__(self, player_num: PlayerNum):
self.player_num = player_num
self.mods = fields(Modifiers)
self.mods = fields(Modifiers)[:-1]
self.current_mod_index = 0
self.is_confirmed = False
self.is_finished = False
@@ -1168,19 +1251,24 @@ class ModifierSelector:
self.fade_sideways = tex.get_animation(32, is_copy=True)
self.direction = -1
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.text_true = OutlinedText('する', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.text_false = OutlinedText('しない', tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
self.language = global_data.config["general"]["language"]
if self.language == 'en':
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_kimagure = OutlinedText('きまぐれ', 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_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_detarame"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
# 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_false_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_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_kimagure_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"].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_detarame"].text[self.language], tex.skin_config["modifier_text"].font_size, ray.WHITE, outline_thickness=3.5)
def update(self, current_ms):
self.is_finished = self.is_confirmed and self.move.is_finished
@@ -1221,20 +1309,20 @@ class ModifierSelector:
if current_value:
self.text_true.unload()
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:
self.text_false.unload()
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':
if current_value == 1:
self.text_kimagure.unload()
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:
self.text_detarame.unload()
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):
if self.is_confirmed:

View File

@@ -43,7 +43,7 @@ class TitleScreen(Screen):
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)
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.text_overlay_fade = tex.get_animation(14)

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()

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

@@ -0,0 +1,289 @@
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.copy.deepcopy')
def test_get_animation_copy(self, mock_deepcopy, mock_path_cls, mock_get_config):
"""Test getting animation copy."""
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()
mock_copy = Mock()
mock_deepcopy.return_value = mock_copy
wrapper = TextureWrapper()
wrapper.animations = {0: mock_animation}
result = wrapper.get_animation(0, is_copy=True)
mock_deepcopy.assert_called_once_with(mock_animation)
self.assertEqual(result, mock_copy)
@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
revision = 3
revision = 2
requires-python = ">=3.13"
[[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" },
]
[[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]]
name = "nuitka"
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" },
]
[[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]]
name = "pycparser"
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" },
]
[[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]]
name = "pypresence"
version = "4.6.1"
@@ -122,11 +260,13 @@ wheels = [
[[package]]
name = "pytaiko"
version = "1.0"
version = "1.1"
source = { virtual = "." }
dependencies = [
{ name = "av" },
{ name = "pyinstrument" },
{ name = "pypresence" },
{ name = "pytest" },
{ name = "raylib-sdl" },
{ name = "tomlkit" },
]
@@ -134,18 +274,54 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "nuitka" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "av", specifier = ">=16.0.1" },
{ name = "pyinstrument", specifier = ">=5.1.1" },
{ name = "pypresence", specifier = ">=4.6.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "raylib-sdl", specifier = ">=5.5.0.2" },
{ name = "tomlkit", specifier = ">=0.13.3" },
]
[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]]
name = "raylib-sdl"