import json import logging import random import sqlite3 from dataclasses import dataclass from datetime import datetime, timedelta from enum import IntEnum from pathlib import Path from typing import Optional, Union import pyray as ray from raylib import SHADER_UNIFORM_VEC3 from libs.animation import Animation, MoveAnimation from libs.audio import audio from libs.global_data import Crown, Difficulty, ScoreMethod from libs.parsers.osz import OsuParser from libs.texture import tex from libs.parsers.tja import TJAParser, test_encodings from libs.utils import OutlinedText, get_current_ms, global_data BOX_CENTER = 594 * tex.screen_scale logger = logging.getLogger(__name__) def rgb_to_hue(r, g, b): rf = r / 255.0 gf = g / 255.0 bf = b / 255.0 max_val = max(rf, gf, bf) min_val = min(rf, gf, bf) delta = max_val - min_val if delta == 0: return 0 # Gray/white, no hue if max_val == rf: hue = 60.0 * (((gf - bf) / delta) % 6) elif max_val == gf: hue = 60.0 * ((bf - rf) / delta + 2.0) else: hue = 60.0 * ((rf - gf) / delta + 4.0) if hue < 0: hue += 360.0 return hue def calculate_hue_shift(source_rgb, target_rgb): source_hue = rgb_to_hue(*source_rgb) target_hue = rgb_to_hue(*target_rgb) shift = (target_hue - source_hue) / 360.0 while shift < 0: shift += 1.0 while shift >= 1.0: shift -= 1.0 return shift def darken_color(rgb: tuple[int, int, int]): r, g, b = rgb darkening_factor = 0.63 darkened_r = int(r * darkening_factor) darkened_g = int(g * darkening_factor) darkened_b = int(b * darkening_factor) return (darkened_r, darkened_g, darkened_b) class TextureIndex(IntEnum): BLANK = 0 VOCALOID = 1 DEFAULT = 2 RECOMMENDED = 3 FAVORITE = 4 RECENT = 5 class GenreIndex(IntEnum): TUTORIAL = 0 JPOP = 1 ANIME = 2 VOCALOID = 3 CHILDREN = 4 VARIETY = 5 CLASSICAL = 6 GAME = 7 NAMCO = 8 DEFAULT = 9 RECOMMENDED = 10 FAVORITE = 11 RECENT = 12 DAN = 13 DIFFICULTY = 14 class BaseBox(): """Base class for all box types in the song select screen.""" def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex): self.text_name = name self.texture_index = texture_index self.genre_index = GenreIndex.DEFAULT self.back_color = back_color if fore_color is not None: self.fore_color = ray.Color(fore_color[0], fore_color[1], fore_color[2], 255) elif self.back_color is not None: dark_ver = darken_color(self.back_color) self.fore_color = ray.Color(dark_ver[0], dark_ver[1], dark_ver[2], 255) else: self.fore_color = ray.Color(101, 0, 82, 255) self.position = float('inf') self.start_position = float('inf') self.target_position = float('inf') self.open_anim = Animation.create_move(233, total_distance=150*tex.screen_scale, delay=50) self.open_fade = Animation.create_fade(200, initial_opacity=0, final_opacity=1.0) self.move = Animation.create_move(133, total_distance=100 * tex.screen_scale, ease_out='cubic') self.move.start() self.shader = None self.is_open = False self.text_loaded = False self.wait = 0 def load_text(self): font_size = tex.skin_config["song_box_name"].font_size if len(self.text_name) >= 30: font_size -= int(10 * tex.screen_scale) self.name = OutlinedText(self.text_name, font_size, ray.WHITE, outline_thickness=5, vertical=True) if self.back_color is not None: self.shader = ray.load_shader('shader/dummy.vs', 'shader/colortransform.fs') source_rgb = (142, 212, 30) target_rgb = self.back_color source_color = ray.ffi.new('float[3]', [source_rgb[0]/255.0, source_rgb[1]/255.0, source_rgb[2]/255.0]) target_color = ray.ffi.new('float[3]', [target_rgb[0]/255.0, target_rgb[1]/255.0, target_rgb[2]/255.0]) source_loc = ray.get_shader_location(self.shader, 'sourceColor') target_loc = ray.get_shader_location(self.shader, 'targetColor') ray.set_shader_value(self.shader, source_loc, source_color, SHADER_UNIFORM_VEC3) ray.set_shader_value(self.shader, target_loc, target_color, SHADER_UNIFORM_VEC3) def move_box(self, direction: int): if self.position != self.target_position: distance = abs(self.target_position - self.position) self.move = Animation.create_move(133, total_distance=distance * tex.screen_scale * direction, ease_out='cubic') self.start_position = self.position self.move.start() def update(self, current_time: float, is_diff_select: bool): self.is_diff_select = is_diff_select self.open_anim.update(current_time) self.open_fade.update(current_time) self.move.update(current_time) if not self.move.is_finished: self.position = self.start_position + int(self.move.attribute) else: self.position = self.target_position def _draw_closed(self, x: float, y: float, outer_fade_override: float): if self.shader is not None and self.texture_index == TextureIndex.BLANK: ray.begin_shader_mode(self.shader) tex.draw_texture('box', 'folder_texture_left', frame=self.texture_index, x=x, fade=outer_fade_override) tex.draw_texture('box', 'folder_texture', frame=self.texture_index, x=x, x2=tex.skin_config["song_box_bg"].width, fade=outer_fade_override) tex.draw_texture('box', 'folder_texture_right', frame=self.texture_index, x=x, fade=outer_fade_override) if self.shader is not None and self.texture_index == TextureIndex.BLANK: ray.end_shader_mode() if self.texture_index == TextureIndex.DEFAULT: tex.draw_texture('box', 'genre_overlay', x=x, y=y, fade=outer_fade_override) if self.genre_index == GenreIndex.DIFFICULTY: tex.draw_texture('box', 'diff_overlay', x=x, y=y, fade=outer_fade_override) def _draw_open(self, x: float, y: float, fade_override: Optional[float], is_ura: bool): pass def draw(self, x: float, y: float, is_ura: bool, inner_fade_override: Optional[float] = None, outer_fade_override: float = 1.0): if self.is_open and get_current_ms() >= self.wait + 83.33: self._draw_open(x, y, inner_fade_override, is_ura) else: self._draw_closed(x, y, outer_fade_override) class BackBox(BaseBox): COLOR = (170, 115, 35) def __init__(self, name: str): super().__init__(name, BackBox.COLOR, BackBox.COLOR, TextureIndex.BLANK) self.yellow_box = None def load_text(self): super().load_text() self.text_loaded = True def update(self, current_time: float, is_diff_select: bool): super().update(current_time, is_diff_select) is_open_prev = self.is_open self.is_open = self.position == BOX_CENTER if self.yellow_box is not None: self.yellow_box.update(is_diff_select) if not is_open_prev and self.is_open: self.yellow_box = YellowBox(True) self.yellow_box.create_anim() self.wait = current_time def _draw_closed(self, x: float, y: float, outer_fade_override: float): super()._draw_closed(x, y, outer_fade_override) tex.draw_texture('box', 'back_text', x=x, y=y, fade=outer_fade_override) def _draw_open(self, x: float, y: float, fade_override: Optional[float] = None, is_ura: bool = False): if self.yellow_box is not None: self.yellow_box.draw(self, fade_override, is_ura, self.name) class SongBox(BaseBox): def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, tja: TJAParser | OsuParser): super().__init__(name, back_color, fore_color, texture_index) self.scores = dict() self.hash = dict() self.score_history = None self.history_wait = 0 self.parser = tja self.is_favorite = False self.yellow_box = None def load_text(self): super().load_text() self.text_loaded = True def get_scores(self): with sqlite3.connect(global_data.score_db) as con: cursor = con.cursor() # Batch database query for all diffs at once if self.parser.metadata.course_data: hash_values = [self.hash[diff] for diff in self.parser.metadata.course_data if diff in self.hash] placeholders = ','.join('?' * len(hash_values)) batch_query = f""" SELECT hash, score, good, ok, bad, drumroll, clear FROM Scores WHERE hash IN ({placeholders}) """ cursor.execute(batch_query, hash_values) hash_to_score = {row[0]: row[1:] for row in cursor.fetchall()} for diff in self.parser.metadata.course_data: if diff not in self.hash: continue diff_hash = self.hash[diff] self.scores[diff] = hash_to_score.get(diff_hash) self.score_history = None def update(self, current_time: float, is_diff_select: bool): super().update(current_time, is_diff_select) is_open_prev = self.is_open self.is_open = self.position == BOX_CENTER if self.yellow_box is not None: self.yellow_box.update(is_diff_select) if self.history_wait == 0: self.history_wait = current_time if self.score_history is None and {k: v for k, v in self.scores.items() if v is not None}: self.score_history = ScoreHistory(self.scores, current_time) if not is_open_prev and self.is_open: self.yellow_box = YellowBox(False, tja=self.parser) self.yellow_box.create_anim() self.wait = current_time if current_time >= self.history_wait + 3000: self.history_wait = current_time if self.score_history is not None: self.score_history.update(current_time) def _draw_closed(self, x: float, y: float, outer_fade_override: float): super()._draw_closed(x, y, outer_fade_override) self.name.draw(outline_color=self.fore_color, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+tex.skin_config["song_box_name"].y, y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override) if self.parser.ex_data.new: tex.draw_texture('yellow_box', 'ex_data_new_song_balloon', x=x, y=y, fade=outer_fade_override) valid_scores = {k: v for k, v in self.scores.items() if v is not None} if valid_scores: highest_key = max(valid_scores.keys()) score = self.scores[highest_key] if score and score[5] == Crown.DFC: tex.draw_texture('yellow_box', 'crown_dfc', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override) elif score and score[5] == Crown.FC: tex.draw_texture('yellow_box', 'crown_fc', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override) elif score and score[5] >= Crown.CLEAR: tex.draw_texture('yellow_box', 'crown_clear', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override) def _draw_open(self, x: float, y: float, fade_override=None, is_ura=False): if self.yellow_box is not None: self.yellow_box.draw(self, fade_override, is_ura, self.name) def draw_score_history(self): if self.is_open and get_current_ms() >= self.wait + 83.33: if self.score_history is not None and get_current_ms() >= self.history_wait + 3000: self.score_history.draw() class SongBoxOsu(SongBox): def update(self, current_time: float, is_diff_select: bool): super().update(current_time, is_diff_select) is_open_prev = self.is_open self.is_open = self.position == BOX_CENTER if self.yellow_box is not None: self.yellow_box.update(is_diff_select) if self.history_wait == 0: self.history_wait = current_time if self.score_history is None and {k: v for k, v in self.scores.items() if v is not None}: self.score_history = ScoreHistory(self.scores, current_time) if not is_open_prev and self.is_open: self.yellow_box = YellowBox(False) self.yellow_box.create_anim() self.wait = current_time if current_time >= self.history_wait + 3000: self.history_wait = current_time if self.score_history is not None: self.score_history.update(current_time) def _draw_closed(self, x: float, y: float, outer_fade_override: float): super()._draw_closed(x, y, outer_fade_override) self.name.draw(outline_color=self.fore_color, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+tex.skin_config["song_box_name"].y, y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override) valid_scores = {k: v for k, v in self.scores.items() if v is not None} if valid_scores: highest_key = max(valid_scores.keys()) score = self.scores[highest_key] if score and score[5] == Crown.DFC: tex.draw_texture('yellow_box', 'crown_dfc', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override) elif score and score[5] == Crown.FC: tex.draw_texture('yellow_box', 'crown_fc', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override) elif score and score[5] >= Crown.CLEAR: tex.draw_texture('yellow_box', 'crown_clear', x=x, y=y, frame=min(Difficulty.URA, highest_key), fade=outer_fade_override) class FolderBox(BaseBox): def __init__(self, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex, genre_index: GenreIndex, tja_count: int = 0, box_texture: Optional[str] = None): super().__init__(name, back_color, fore_color, texture_index) self.box_texture_path = Path(box_texture) if box_texture else None self.is_back = self.back_color == BackBox.COLOR self.tja_count = tja_count self.crown = dict() self.genre_index = genre_index def load_text(self): super().load_text() self.hori_name = OutlinedText(self.text_name, tex.skin_config['song_hori_name'].font_size, ray.WHITE, outline_thickness=5) self.box_texture = ray.load_texture(str(self.box_texture_path)) if self.box_texture_path and self.box_texture_path.exists() else None if self.box_texture is not None: ray.gen_texture_mipmaps(self.box_texture) ray.set_texture_filter(self.box_texture, ray.TextureFilter.TEXTURE_FILTER_TRILINEAR) self.tja_count_text = OutlinedText(str(self.tja_count), tex.skin_config['song_tja_count'].font_size, ray.WHITE, outline_thickness=5) self.text_loaded = True def update(self, current_time: float, is_diff_select: bool): super().update(current_time, is_diff_select) is_open_prev = self.is_open self.is_open = self.position == BOX_CENTER if not is_open_prev and self.is_open: self.open_anim.start() self.open_fade.start() self.wait = current_time if self.back_color != BackBox.COLOR and not audio.is_sound_playing('voice_enter'): audio.play_sound(f'genre_voice_{self.genre_index}', 'voice') elif not self.is_open and is_open_prev and self.back_color != BackBox.COLOR and audio.is_sound_playing(f'genre_voice_{self.genre_index}'): audio.stop_sound(f'genre_voice_{self.genre_index}') def _draw_closed(self, x: float, y: float, outer_fade_override: float): super()._draw_closed(x, y, outer_fade_override) if self.shader is not None and self.texture_index == TextureIndex.BLANK: ray.begin_shader_mode(self.shader) tex.draw_texture('box', 'folder_clip', frame=self.texture_index, x=x - ((1 * tex.screen_scale)), y=y, fade=outer_fade_override) if self.shader is not None and self.texture_index == TextureIndex.BLANK: ray.end_shader_mode() self.name.draw(outline_color=self.fore_color, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+tex.skin_config["song_box_name"].y, y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override) if self.crown: #Folder lamp highest_crown = max(self.crown) if self.crown[highest_crown] == Crown.DFC: tex.draw_texture('yellow_box', 'crown_dfc', x=x, y=y, frame=min(Difficulty.URA, highest_crown), fade=outer_fade_override) elif self.crown[highest_crown] == Crown.FC: tex.draw_texture('yellow_box', 'crown_fc', x=x, y=y, frame=min(Difficulty.URA, highest_crown), fade=outer_fade_override) else: tex.draw_texture('yellow_box', 'crown_clear', x=x, y=y, frame=min(Difficulty.URA, highest_crown), fade=outer_fade_override) def _draw_open(self, x: float, y: float, fade_override: Optional[float], is_ura: bool): color = ray.WHITE if fade_override is not None: color = ray.fade(ray.WHITE, fade_override) if not self.is_back and self.open_anim.attribute >= (100 * tex.screen_scale): if self.shader is not None and self.texture_index == TextureIndex.BLANK: ray.begin_shader_mode(self.shader) tex.draw_texture('box', 'folder_top_edge', x=x, y=y - self.open_anim.attribute, color=color, mirror='horizontal', frame=self.texture_index) tex.draw_texture('box', 'folder_top', x=x, y=y - self.open_anim.attribute, color=color, frame=self.texture_index) tex.draw_texture('box', 'folder_top_edge', x=x+tex.skin_config["song_folder_top"].x, y=y - self.open_anim.attribute, color=color, frame=self.texture_index) dest_width = min(tex.skin_config["song_hori_name"].width, self.hori_name.texture.width) self.hori_name.draw(outline_color=ray.BLACK, x=(x + tex.skin_config["song_hori_name"].x) - (dest_width//2), y=y + tex.skin_config["song_hori_name"].y - self.open_anim.attribute, x2=dest_width-self.hori_name.texture.width, color=color) if self.shader is not None and self.texture_index == TextureIndex.BLANK: ray.end_shader_mode() if self.shader is not None and self.texture_index == TextureIndex.BLANK: ray.begin_shader_mode(self.shader) tex.draw_texture('box', 'folder_texture_left', frame=self.texture_index, x=x - self.open_anim.attribute) offset = 1 * tex.screen_scale if self.texture_index == 3 or self.texture_index >= 9 and self.texture_index not in {10,11,12} else 0 tex.draw_texture('box', 'folder_texture', frame=self.texture_index, x=x - self.open_anim.attribute, y=offset, x2=(self.open_anim.attribute*2)+tex.skin_config["song_box_bg"].width) tex.draw_texture('box', 'folder_texture_right', frame=self.texture_index, x=x + self.open_anim.attribute) if self.shader is not None and self.texture_index == TextureIndex.BLANK: ray.end_shader_mode() if self.texture_index == TextureIndex.DEFAULT: tex.draw_texture('box', 'genre_overlay_large', x=x, y=y, color=color) if self.genre_index == GenreIndex.DIFFICULTY: tex.draw_texture('box', 'diff_overlay_large', x=x, y=y, color=color) color = ray.WHITE if fade_override is not None: color = ray.fade(ray.WHITE, fade_override) if self.genre_index != GenreIndex.DIFFICULTY: tex.draw_texture('yellow_box', 'song_count_back', color=color, fade=0.5) tex.draw_texture('yellow_box', 'song_count_num', color=color) tex.draw_texture('yellow_box', 'song_count_songs', color=color) dest_width = min(tex.skin_config["song_tja_count"].width, self.tja_count_text.texture.width) self.tja_count_text.draw(outline_color=ray.BLACK, x=tex.skin_config["song_tja_count"].x - (dest_width//2), y=tex.skin_config["song_tja_count"].y, x2=dest_width-self.tja_count_text.texture.width, color=color) if self.texture_index != TextureIndex.DEFAULT and self.box_texture is None: tex.draw_texture('box', 'folder_graphic', color=color, frame=self.genre_index) tex.draw_texture('box', 'folder_text', color=color, frame=self.genre_index) elif self.box_texture is not None: scaled_width = self.box_texture.width * tex.screen_scale scaled_height = self.box_texture.height * tex.screen_scale max_width = 344 * tex.screen_scale max_height = 424 * tex.screen_scale if scaled_width > max_width or scaled_height > max_height: width_scale = max_width / scaled_width height_scale = max_height / scaled_height scale_factor = min(width_scale, height_scale) scaled_width *= scale_factor scaled_height *= scale_factor x = int((x + tex.skin_config["box_texture"].x) - (scaled_width // 2)) y = int((y + tex.skin_config["box_texture"].y) - (scaled_height // 2)) src = ray.Rectangle(0, 0, self.box_texture.width, self.box_texture.height) dest = ray.Rectangle(x, y, scaled_width, scaled_height) ray.draw_texture_pro(self.box_texture, src, dest, ray.Vector2(0, 0), 0, color) class YellowBox: """A song box when it is opened.""" def __init__(self, is_back: bool, tja: Optional[TJAParser | OsuParser] = None, is_dan: bool = False): self.is_diff_select = False self.is_back = is_back self.tja = tja if self.tja is not None: subtitle_text = self.tja.metadata.subtitle.get(global_data.config['general']['language'], self.tja.metadata.subtitle.get('en', '')) font_size = tex.skin_config["yb_subtitle"].font_size if len(subtitle_text) < 30 else tex.skin_config["yb_subtitle"].font_size - int(10 * tex.screen_scale) self.subtitle = OutlinedText(subtitle_text, font_size, ray.WHITE, outline_thickness=5, vertical=True) else: self.subtitle = None self.is_dan = is_dan self.left_out = tex.get_animation(9) self.right_out = tex.get_animation(10) self.center_out = tex.get_animation(11) self.fade = tex.get_animation(12) self.left_out.reset() self.right_out.reset() self.center_out.reset() self.fade.reset() self.left_out_2 = tex.get_animation(13) self.right_out_2 = tex.get_animation(14) self.center_out_2 = tex.get_animation(15) self.top_y_out = tex.get_animation(16) self.center_h_out = tex.get_animation(17) self.fade_in = tex.get_animation(18) self.right_out_2.reset() self.top_y_out.reset() self.center_h_out.reset() self.right_x = self.right_out.attribute self.left_x = self.left_out.attribute self.center_width = self.center_out.attribute self.top_y = self.top_y_out.attribute self.center_height = self.center_h_out.attribute self.bottom_y = tex.textures['yellow_box']['yellow_box_bottom_right'].y[0] self.edge_height = tex.textures['yellow_box']['yellow_box_bottom_right'].height def create_anim(self): self.right_out_2.reset() self.top_y_out.reset() self.center_h_out.reset() self.left_out.start() self.right_out.start() self.center_out.start() self.fade.start() def create_anim_2(self): self.left_out_2.start() self.right_out_2.start() self.center_out_2.start() self.top_y_out.start() self.center_h_out.start() self.fade_in.start() def update(self, is_diff_select: bool): current_time = get_current_ms() self.left_out.update(current_time) self.right_out.update(current_time) self.center_out.update(current_time) self.fade.update(current_time) self.fade_in.update(current_time) self.left_out_2.update(current_time) self.right_out_2.update(current_time) self.center_out_2.update(current_time) self.top_y_out.update(current_time) self.center_h_out.update(current_time) if is_diff_select and not self.is_diff_select: self.create_anim_2() if self.is_diff_select: self.right_x = self.right_out_2.attribute self.left_x = self.left_out_2.attribute self.top_y = self.top_y_out.attribute self.center_width = self.center_out_2.attribute self.center_height = self.center_h_out.attribute else: self.right_x = self.right_out.attribute self.left_x = self.left_out.attribute self.center_width = self.center_out.attribute self.top_y = self.top_y_out.attribute self.center_height = self.center_h_out.attribute self.is_diff_select = is_diff_select def _draw_tja_data(self, song_box: SongBox, color: ray.Color, fade: float): if not self.tja: return offset = tex.skin_config['yb_diff_offset'].x for diff in self.tja.metadata.course_data: if diff >= Difficulty.URA: continue if diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] == Crown.DFC: tex.draw_texture('yellow_box', 's_crown_dfc', x=(diff*offset), color=color) elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] == Crown.FC: tex.draw_texture('yellow_box', 's_crown_fc', x=(diff*offset), color=color) elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] >= Crown.CLEAR: tex.draw_texture('yellow_box', 's_crown_clear', x=(diff*offset), color=color) tex.draw_texture('yellow_box', 's_crown_outline', x=(diff*offset), fade=min(fade, 0.25)) if self.tja.ex_data.new_audio: tex.draw_texture('yellow_box', 'ex_data_new_audio', color=color) elif self.tja.ex_data.old_audio: tex.draw_texture('yellow_box', 'ex_data_old_audio', color=color) elif self.tja.ex_data.limited_time: tex.draw_texture('yellow_box', 'ex_data_limited_time', color=color) elif self.tja.ex_data.new: tex.draw_texture('yellow_box', 'ex_data_new_song', color=color) if song_box.is_favorite: tex.draw_texture('yellow_box', f'favorite_{global_data.player_num}p', color=color) for i in range(4): tex.draw_texture('yellow_box', 'difficulty_bar', frame=i, x=(i*offset), color=color) if i not in self.tja.metadata.course_data: tex.draw_texture('yellow_box', 'difficulty_bar_shadow', frame=i, x=(i*offset), fade=min(fade, 0.25)) for diff in self.tja.metadata.course_data: if diff >= Difficulty.URA: continue for j in range(self.tja.metadata.course_data[diff].level): tex.draw_texture('yellow_box', 'star', x=(diff*offset), y=(j*tex.skin_config['yb_diff_offset'].y), color=color) if self.tja.metadata.course_data[diff].is_branching and (get_current_ms() // 1000) % 2 == 0: tex.draw_texture('yellow_box', 'branch_indicator', x=(diff*offset), color=color) def _draw_tja_data_diff(self, is_ura: bool, song_box: SongBox): if not self.tja: return tex.draw_texture('diff_select', 'back', fade=self.fade_in.attribute) tex.draw_texture('diff_select', 'option', fade=self.fade_in.attribute) tex.draw_texture('diff_select', 'neiro', fade=self.fade_in.attribute) offset_x = tex.skin_config['yb_diff_offset_diff_select'].x offset_y = tex.skin_config['yb_diff_offset_diff_select'].y for diff in self.tja.metadata.course_data: if diff >= Difficulty.URA: continue elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] == Crown.DFC: tex.draw_texture('yellow_box', 's_crown_dfc', x=(diff*offset_x)+tex.skin_config['yb_diff_offset_crown'].x, y=offset_y, fade=self.fade_in.attribute) elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] == Crown.FC: tex.draw_texture('yellow_box', 's_crown_fc', x=(diff*offset_x)+tex.skin_config['yb_diff_offset_crown'].x, y=offset_y, fade=self.fade_in.attribute) elif diff in song_box.scores and song_box.scores[diff] is not None and song_box.scores[diff][5] is not None and song_box.scores[diff][5] >= Crown.CLEAR: tex.draw_texture('yellow_box', 's_crown_clear', x=(diff*offset_x)+tex.skin_config['yb_diff_offset_crown'].x, y=offset_y, fade=self.fade_in.attribute) tex.draw_texture('yellow_box', 's_crown_outline', x=(diff*offset_x)+tex.skin_config['yb_diff_offset_crown'].x, y=offset_y, fade=min(self.fade_in.attribute, 0.25)) for i in range(4): if i == Difficulty.ONI and is_ura: tex.draw_texture('diff_select', 'diff_tower', frame=4, x=(i*offset_x), fade=self.fade_in.attribute) tex.draw_texture('diff_select', 'ura_oni_plate', fade=self.fade_in.attribute) else: tex.draw_texture('diff_select', 'diff_tower', frame=i, x=(i*offset_x), fade=self.fade_in.attribute) if i not in self.tja.metadata.course_data: tex.draw_texture('diff_select', 'diff_tower_shadow', frame=i, x=(i*offset_x), fade=min(self.fade_in.attribute, 0.25)) for course in self.tja.metadata.course_data: if (course == Difficulty.URA and not is_ura) or (course == Difficulty.ONI and is_ura): continue for j in range(self.tja.metadata.course_data[course].level): tex.draw_texture('yellow_box', 'star_ura', x=min(course, Difficulty.ONI)*offset_x, y=(j*tex.skin_config["yb_diff_offset_crown"].y), fade=self.fade_in.attribute) if self.tja.metadata.course_data[course].is_branching and (get_current_ms() // 1000) % 2 == 0: if course == Difficulty.URA: name = 'branch_indicator_ura' else: name = 'branch_indicator_diff' tex.draw_texture('yellow_box', name, x=min(course, Difficulty.ONI)*offset_x, fade=self.fade_in.attribute) def _draw_text(self, song_box, name: OutlinedText): if not isinstance(self.right_out, MoveAnimation): return if not isinstance(self.right_out_2, MoveAnimation): return if not isinstance(self.top_y_out, MoveAnimation): return x = song_box.position + (self.right_out.attribute*0.85 - (self.right_out.start_position*0.85)) + self.right_out_2.attribute - self.right_out_2.start_position if self.is_back: tex.draw_texture('box', 'back_text_highlight', x=x) else: texture = name.texture name.draw(outline_color=ray.BLACK, x=x + tex.skin_config["yb_name"].x, y=tex.skin_config["yb_name"].y + self.top_y_out.attribute, y2=min(texture.height, tex.skin_config["yb_name"].height)-texture.height, color=ray.WHITE) if self.subtitle is not None: texture = self.subtitle.texture y = self.bottom_y - min(texture.height, tex.skin_config["yb_subtitle"].height) + tex.skin_config["yb_subtitle"].y + self.top_y_out.attribute - self.top_y_out.start_position self.subtitle.draw(outline_color=ray.BLACK, x=x+tex.skin_config["yb_subtitle"].x, y=y, y2=min(texture.height, tex.skin_config["yb_subtitle"].height)-texture.height) def _draw_yellow_box(self): tex.draw_texture('yellow_box', 'yellow_box_bottom_right', x=self.right_x) tex.draw_texture('yellow_box', 'yellow_box_bottom_left', x=self.left_x, y=self.bottom_y) tex.draw_texture('yellow_box', 'yellow_box_top_right', x=self.right_x, y=self.top_y) tex.draw_texture('yellow_box', 'yellow_box_top_left', x=self.left_x, y=self.top_y) tex.draw_texture('yellow_box', 'yellow_box_bottom', x=self.left_x + self.edge_height, y=self.bottom_y, x2=self.center_width) tex.draw_texture('yellow_box', 'yellow_box_right', x=self.right_x, y=self.top_y + self.edge_height, y2=self.center_height) tex.draw_texture('yellow_box', 'yellow_box_left', x=self.left_x, y=self.top_y + self.edge_height, y2=self.center_height) tex.draw_texture('yellow_box', 'yellow_box_top', x=self.left_x + self.edge_height, y=self.top_y, x2=self.center_width) tex.draw_texture('yellow_box', 'yellow_box_center', x=self.left_x + self.edge_height, y=self.top_y + self.edge_height, x2=self.center_width, y2=self.center_height) def draw(self, song_box: Optional[SongBox | BackBox], fade_override: Optional[float], is_ura: bool, name: OutlinedText): self._draw_yellow_box() fade = self.fade.attribute if fade_override is not None: fade = min(self.fade.attribute, fade_override) if self.is_dan: return if self.is_back: tex.draw_texture('box', 'back_graphic', fade=fade) return if self.is_diff_select and isinstance(song_box, SongBox): self._draw_tja_data_diff(is_ura, song_box) elif isinstance(song_box, SongBox): self._draw_tja_data(song_box, ray.fade(ray.WHITE, fade), fade) self._draw_text(song_box, name) class DanBox(BaseBox): def __init__(self, name, color: TextureIndex, songs: list[tuple[TJAParser, int, int, int]], exams: list['Exam']): super().__init__(name, None, None, color) self.songs = songs self.exams = exams self.song_text: list[tuple[OutlinedText, OutlinedText]] = [] self.total_notes = 0 self.yellow_box = None for song, genre_index, difficulty, level in self.songs: notes, branch_m, branch_e, branch_n = song.notes_to_position(difficulty) self.total_notes += sum(1 for note in notes.play_notes if note.type < 5) for branch in branch_m: self.total_notes += sum(1 for note in branch.play_notes if note.type < 5) for branch in branch_e: self.total_notes += sum(1 for note in branch.play_notes if note.type < 5) for branch in branch_n: self.total_notes += sum(1 for note in branch.play_notes if note.type < 5) def load_text(self): super().load_text() self.hori_name = OutlinedText(self.text_name, tex.skin_config["dan_title"].font_size, ray.WHITE) for song, genre, difficulty, level in self.songs: title = song.metadata.title.get(global_data.config["general"]["language"], song.metadata.title["en"]) subtitle = song.metadata.subtitle.get(global_data.config["general"]["language"], "") title_text = OutlinedText(title, tex.skin_config["dan_title"].font_size, ray.WHITE, vertical=True) font_size = tex.skin_config["dan_subtitle"].font_size if len(subtitle) < 30 else tex.skin_config["dan_subtitle"].font_size - int(10 * tex.screen_scale) subtitle_text = OutlinedText(subtitle, font_size, ray.WHITE, vertical=True) self.song_text.append((title_text, subtitle_text)) self.text_loaded = True def update(self, current_time: float, is_diff_select: bool): super().update(current_time, is_diff_select) is_open_prev = self.is_open self.is_open = self.position == BOX_CENTER if not is_open_prev and self.is_open: self.yellow_box = YellowBox(False, is_dan=True) self.yellow_box.create_anim() if self.yellow_box is not None: self.yellow_box.update(True) def _draw_exam_box(self): tex.draw_texture('yellow_box', 'exam_box_bottom_right') tex.draw_texture('yellow_box', 'exam_box_bottom_left') tex.draw_texture('yellow_box', 'exam_box_top_right') tex.draw_texture('yellow_box', 'exam_box_top_left') tex.draw_texture('yellow_box', 'exam_box_bottom') tex.draw_texture('yellow_box', 'exam_box_right') tex.draw_texture('yellow_box', 'exam_box_left') tex.draw_texture('yellow_box', 'exam_box_top') tex.draw_texture('yellow_box', 'exam_box_center') tex.draw_texture('yellow_box', 'exam_header') offset = tex.skin_config["exam_box_offset"].y for i, exam in enumerate(self.exams): tex.draw_texture('yellow_box', 'judge_box', y=(i*offset)) tex.draw_texture('yellow_box', 'exam_' + exam.type, y=(i*offset)) counter = str(exam.red) margin = tex.skin_config["exam_counter_margin"].x if exam.type == 'gauge': tex.draw_texture('yellow_box', 'exam_percent', y=(i*offset)) x_offset = tex.skin_config["exam_gauge_offset"].x else: x_offset = 0 for j in range(len(counter)): tex.draw_texture('yellow_box', 'judge_num', frame=int(counter[j]), x=x_offset-(len(counter) - j) * margin, y=(i*offset)) if exam.range == 'more': tex.draw_texture('yellow_box', 'exam_more', x=(x_offset*-1.7), y=(i*offset)) elif exam.range == 'less': tex.draw_texture('yellow_box', 'exam_less', x=(x_offset*-1.7), y=(i*offset)) def _draw_closed(self, x: float, y: float, outer_fade_override: float): tex.draw_texture('box', 'folder', frame=self.texture_index, x=x, fade=outer_fade_override) if self.name is not None: self.name.draw(outline_color=ray.BLACK, x=x + tex.skin_config["song_box_name"].x - int(self.name.texture.width / 2), y=y+(tex.skin_config["song_box_name"].height//2), y2=min(self.name.texture.height, tex.skin_config["song_box_name"].height)-self.name.texture.height, fade=outer_fade_override) def _draw_open(self, x: float, y: float, fade_override: Optional[float], is_ura: bool): if fade_override is not None: fade = fade_override else: fade = 1.0 if self.yellow_box is not None: self.yellow_box.draw(None, None, False, self.name) for i, song in enumerate(self.song_text): title, subtitle = song x = i * tex.skin_config["dan_yellow_box_offset"].x tex.draw_texture('yellow_box', 'genre_banner', x=x, frame=self.songs[i][1], fade=fade) tex.draw_texture('yellow_box', 'difficulty', x=x, frame=self.songs[i][2], fade=fade) tex.draw_texture('yellow_box', 'difficulty_x', x=x, fade=fade) tex.draw_texture('yellow_box', 'difficulty_star', x=x, fade=fade) level = self.songs[i][0].metadata.course_data[self.songs[i][2]].level counter = str(level) margin = tex.skin_config["dan_level_counter_margin"].x total_width = len(counter) * margin for i in range(len(counter)): tex.draw_texture('yellow_box', 'difficulty_num', frame=int(counter[i]), x=x-(total_width // 2) + (i * margin), fade=fade) title_data = tex.skin_config["dan_title"] subtitle_data = tex.skin_config["dan_subtitle"] title.draw(outline_color=ray.BLACK, x=title_data.x+x, y=title_data.y, y2=min(title.texture.height, title_data.height)-title.texture.height, fade=fade) subtitle.draw(outline_color=ray.BLACK, x=subtitle_data.x+x, y=subtitle_data.y-min(subtitle.texture.height, subtitle_data.height), y2=min(subtitle.texture.height, subtitle_data.height)-subtitle.texture.height, fade=fade) tex.draw_texture('yellow_box', 'total_notes_bg', fade=fade) tex.draw_texture('yellow_box', 'total_notes', fade=fade) counter = str(self.total_notes) for i in range(len(counter)): tex.draw_texture('yellow_box', 'total_notes_counter', frame=int(counter[i]), x=(i * tex.skin_config["total_notes_counter_margin"].x), fade=fade) tex.draw_texture('yellow_box', 'frame', frame=self.texture_index, fade=fade) if self.hori_name is not None: self.hori_name.draw(outline_color=ray.BLACK, x=tex.skin_config["dan_hori_name"].x - (self.hori_name.texture.width//2), y=tex.skin_config["dan_hori_name"].y, x2=min(self.hori_name.texture.width, tex.skin_config["dan_hori_name"].width)-self.hori_name.texture.width, fade=fade) self._draw_exam_box() class GenreBG: """The background for a genre box.""" def __init__(self, start_box: BaseBox, end_box: BaseBox, title: OutlinedText, diff_sort: Optional[int]): self.title = title self.start_box = start_box self.end_box = end_box self.start_position = start_box.position self.end_position_final = end_box.position self.fade_in = Animation.create_fade(133, initial_opacity=0.0, final_opacity=1.0, ease_in='quadratic', delay=50) self.move = Animation.create_move(316, delay=self.fade_in.duration/2, total_distance=abs(self.end_position_final - self.start_position), ease_in='quadratic') self.box_fade_in = Animation.create_fade(66.67*2, delay=self.move.duration, initial_opacity=0.0, final_opacity=1.0) self.fade_in.start() self.move.start() self.box_fade_in.start() self.end_position = self.start_position + self.move.attribute self.diff_num = diff_sort self.color = self.end_box.back_color self.shader = None self.shader_loaded = False def load_shader(self): if self.color is not None: self.shader = ray.load_shader('shader/dummy.vs', 'shader/colortransform.fs') source_rgb = (142, 212, 30) target_rgb = self.color source_color = ray.ffi.new('float[3]', [source_rgb[0]/255.0, source_rgb[1]/255.0, source_rgb[2]/255.0]) target_color = ray.ffi.new('float[3]', [target_rgb[0]/255.0, target_rgb[1]/255.0, target_rgb[2]/255.0]) source_loc = ray.get_shader_location(self.shader, 'sourceColor') target_loc = ray.get_shader_location(self.shader, 'targetColor') ray.set_shader_value(self.shader, source_loc, source_color, SHADER_UNIFORM_VEC3) ray.set_shader_value(self.shader, target_loc, target_color, SHADER_UNIFORM_VEC3) self.shader_loaded = True def update(self, current_ms): self.start_position = self.start_box.position self.end_position = self.start_position + self.move.attribute if self.move.is_finished: self.end_position = self.end_box.position self.box_fade_in.update(current_ms) self.fade_in.update(current_ms) self.move.update(current_ms) def draw(self, y): if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK: ray.begin_shader_mode(self.shader) offset = (tex.skin_config["genre_bg_offset"].x * -1) if self.start_box.is_open else 0 if (344 * tex.screen_scale < self.start_box.position < 594 * tex.screen_scale): offset = -self.start_position + 444 * tex.screen_scale tex.draw_texture('box', 'folder_background_edge', frame=self.end_box.texture_index, x=self.start_position+offset, y=y, mirror="horizontal", fade=self.fade_in.attribute) extra_distance = tex.skin_config["genre_bg_extra_distance"].x if self.end_box.is_open or (self.start_box.is_open and (844 * tex.screen_scale) <= self.end_position <= (1144 * tex.screen_scale)) else 0 if self.start_position >= tex.skin_config["genre_bg_left_max"].x and self.end_position < self.start_position: x2 = self.start_position + tex.skin_config["genre_bg_offset_2"].x x = self.start_position+offset elif (self.start_position <= tex.skin_config["genre_bg_left_max"].x) and (self.end_position < self.start_position): x = 0 x2 = tex.screen_width else: x2 = abs(self.end_position) - self.start_position + extra_distance + (-1 * tex.skin_config["genre_bg_left_max"].x + (1 * tex.screen_scale)) x = self.start_position+offset tex.draw_texture('box', 'folder_background', x=x, y=y, x2=x2, frame=self.end_box.texture_index) if self.end_position < self.start_position and self.end_position >= tex.skin_config["genre_bg_left_max"].x: x2 = min(self.end_position+tex.skin_config["genre_bg_folder_background"].width, tex.screen_width) + extra_distance tex.draw_texture('box', 'folder_background', x=tex.skin_config["genre_bg_folder_background"].x, y=y, x2=x2, frame=self.end_box.texture_index) if (594 * tex.screen_scale < self.end_box.position < 844 * tex.screen_scale): offset = -self.end_position + 674 * tex.screen_scale offset = tex.skin_config["genre_bg_offset"].x if self.end_box.is_open else 0 tex.draw_texture('box', 'folder_background_edge', x=self.end_position+tex.skin_config["genre_bg_folder_edge"].x+offset, y=y, fade=self.fade_in.attribute, frame=self.end_box.texture_index) if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK: ray.end_shader_mode() if ((self.start_position <= BOX_CENTER and self.end_position >= BOX_CENTER) or ((self.start_position <= BOX_CENTER or self.end_position >= BOX_CENTER) and (self.start_position > self.end_position))): offset = tex.skin_config["genre_bg_offset_3"].x if self.diff_num is not None else 0 dest_width = min(tex.skin_config["genre_bg_title"].width, self.title.texture.width) if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK: ray.begin_shader_mode(self.shader) tex.draw_texture('box', 'folder_background_folder', x=-((offset+dest_width)//2), y=y+tex.skin_config["genre_bg_folder_background_folder"].y, x2=dest_width+offset++tex.skin_config["genre_bg_folder_background_folder"].width, fade=self.fade_in.attribute, frame=self.end_box.texture_index) tex.draw_texture('box', 'folder_background_folder_edge', x=-((offset+dest_width)//2), y=y+tex.skin_config["genre_bg_folder_background_folder"].y, fade=self.fade_in.attribute, frame=self.end_box.texture_index, mirror="horizontal") tex.draw_texture('box', 'folder_background_folder_edge', x=((offset+dest_width)//2)+tex.skin_config["genre_bg_folder_background_folder"].x, y=y+tex.skin_config["genre_bg_folder_background_folder"].y, fade=self.fade_in.attribute, frame=self.end_box.texture_index) if self.shader is not None and self.end_box.texture_index == TextureIndex.BLANK: ray.end_shader_mode() if self.diff_num is not None: tex.draw_texture('diff_sort', 'star_num', frame=self.diff_num, x=(tex.skin_config["genre_bg_offset"].x * -1) + (dest_width//2), y=tex.skin_config["diff_sort_star_num"].y) self.title.draw(outline_color=ray.BLACK, x=(tex.screen_width//2) - (dest_width//2)-(offset//2), y=y+tex.skin_config["genre_bg_title"].y, x2=dest_width - self.title.texture.width, color=ray.fade(ray.WHITE, self.fade_in.attribute)) class ScoreHistory: """The score information that appears while hovering over a song""" def __init__(self, scores: dict[int, tuple[int, int, int, int]], current_ms): """ Initialize the score history with the given scores and current time. Args: scores (dict[int, tuple[int, int, int, int]]): A dictionary of scores for each difficulty level. current_ms (int): The current time in milliseconds. """ self.scores = {k: v for k, v in scores.items() if v is not None} self.difficulty_keys = list(self.scores.keys()) self.curr_difficulty_index = 0 self.curr_difficulty_index = (self.curr_difficulty_index + 1) % len(self.difficulty_keys) self.curr_difficulty = self.difficulty_keys[self.curr_difficulty_index] self.curr_score = self.scores[self.curr_difficulty][0] self.curr_score_su = self.scores[self.curr_difficulty][0] self.last_ms = current_ms self.long = True def update(self, current_ms): if current_ms >= self.last_ms + 1000: self.last_ms = current_ms self.curr_difficulty_index = (self.curr_difficulty_index + 1) % len(self.difficulty_keys) self.curr_difficulty = self.difficulty_keys[self.curr_difficulty_index] self.curr_score = self.scores[self.curr_difficulty][0] self.curr_score_su = self.scores[self.curr_difficulty][0] def draw_long(self): tex.draw_texture('leaderboard','background_2') tex.draw_texture('leaderboard','title', index=self.long) match global_data.config["general"]["score_method"]: case ScoreMethod.SHINUCHI: if self.curr_difficulty == Difficulty.URA: tex.draw_texture('leaderboard', 'shinuchi_ura', index=self.long) else: tex.draw_texture('leaderboard', 'shinuchi', index=self.long) tex.draw_texture('leaderboard', 'pts', color=ray.WHITE, index=self.long) case ScoreMethod.GEN3: tex.draw_texture('leaderboard', 'normal', index=self.long) tex.draw_texture('leaderboard', 'pts', color=ray.BLACK, index=self.long) tex.draw_texture('leaderboard', 'difficulty', frame=self.curr_difficulty, index=self.long) for i in range(4): tex.draw_texture('leaderboard', 'normal', index=self.long, y=tex.skin_config["score_info_bg_offset"].y+(i*tex.skin_config["score_info_bg_offset"].y)) tex.draw_texture('leaderboard', 'judge_good') tex.draw_texture('leaderboard', 'judge_ok') tex.draw_texture('leaderboard', 'judge_bad') tex.draw_texture('leaderboard', 'judge_drumroll') for j, counter in enumerate(self.scores[self.curr_difficulty]): if j == Difficulty.TOWER: continue if counter is None: continue counter = str(counter) margin = tex.skin_config["score_info_counter_margin"].x for i in range(len(counter)): if j == 0: match global_data.config["general"]["score_method"]: case ScoreMethod.SHINUCHI: tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-((len(counter) * tex.skin_config["score_info_counter_margin"].width) // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=ray.WHITE, index=self.long) case ScoreMethod.GEN3: tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-((len(counter) * tex.skin_config["score_info_counter_margin"].width) // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=ray.BLACK, index=self.long) else: tex.draw_texture('leaderboard', 'judge_num', frame=int(counter[i]), x=-(len(counter) - i) * margin, y=j*tex.skin_config["score_info_bg_offset"].y) def draw(self): if self.long: self.draw_long() return tex.draw_texture('leaderboard','background') tex.draw_texture('leaderboard','title') if self.curr_difficulty == Difficulty.URA: tex.draw_texture('leaderboard', 'normal_ura') tex.draw_texture('leaderboard', 'shinuchi_ura') else: tex.draw_texture('leaderboard', 'normal') tex.draw_texture('leaderboard', 'shinuchi') color = ray.BLACK if self.curr_difficulty == Difficulty.URA: color = ray.WHITE tex.draw_texture('leaderboard','ura') tex.draw_texture('leaderboard', 'pts', color=color) tex.draw_texture('leaderboard', 'pts', y=tex.skin_config["score_info_bg_offset"].y) tex.draw_texture('leaderboard', 'difficulty', frame=self.curr_difficulty) counter = str(self.curr_score) total_width = len(counter) * tex.skin_config["score_info_counter_margin"].width for i in range(len(counter)): tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-(total_width // 2) + (i * tex.skin_config["score_info_counter_margin"].width), color=color) counter = str(self.curr_score_su) total_width = len(counter) * tex.skin_config["score_info_counter_margin"].width for i in range(len(counter)): tex.draw_texture('leaderboard', 'counter', frame=int(counter[i]), x=-(total_width // 2) + (i * tex.skin_config["score_info_counter_margin"].width), y=tex.skin_config["score_info_bg_offset"].y, color=ray.WHITE) def parse_hex_color(color) -> tuple[int, int, int]: """Parse hex color to RGB tuple""" color = color.lstrip('#') if len(color) == 3: color = ''.join([c*2 for c in color]) res = tuple(int(color[i:i+2], 16) for i in (0, 2, 4)) return (res[0], res[1], res[2]) def get_genre_index(genre_string: str) -> GenreIndex: genre_upper = genre_string.upper() for genre_index, genre_set in FileSystemItem.GENRE_MAP.items(): if genre_upper in genre_set: return genre_index return GenreIndex.DEFAULT DEFAULT_COLORS = { GenreIndex.JPOP: [(32, 160, 186), (0, 77, 104)], GenreIndex.ANIME: [(255, 152, 0), (156, 64, 2)], GenreIndex.VOCALOID: [None, (84, 101, 126)], GenreIndex.CHILDREN: [(255, 82, 134), (153, 4, 46)], GenreIndex.VARIETY: [(142, 212, 30), (60, 104, 0)], GenreIndex.CLASSICAL: [(209, 162, 19), (134, 88, 0)], GenreIndex.GAME: [(156, 117, 189), (79, 40, 134)], GenreIndex.NAMCO: [(255, 90, 19), (148, 24, 0)], GenreIndex.DEFAULT: [None, (101, 0, 82)], GenreIndex.RECOMMENDED: [None, (140, 39, 92)], GenreIndex.FAVORITE: [None, (151, 57, 30)], GenreIndex.RECENT: [None, (35, 123, 103)], GenreIndex.DAN: [(35, 102, 170), (25, 68, 137)], GenreIndex.DIFFICULTY: [(255, 85, 95), (157, 13, 31)] } def parse_box_def(path: Path): """Parse box.def file for directory metadata""" name = path.name genre = '' texture_index = TextureIndex.DEFAULT genre_index = GenreIndex.DEFAULT collection = None back_color = None fore_color = None encoding = test_encodings(path / "box.def") with open(path / "box.def", 'r', encoding=encoding) as box_def: for line in box_def: line = line.strip() if line.startswith("#GENRE:"): genre = line.split(":", 1)[1].strip() texture_index = FileSystemItem.TEXTURE_MAP.get(genre, texture_index) genre_index = get_genre_index(genre) elif line.startswith("#TITLE:"): name = line.split(":", 1)[1].strip() elif line.startswith("#TITLEJA:"): if global_data.config['general']['language'] == 'ja': name = line.split(":", 1)[1].strip() elif line.startswith("#COLLECTION:"): collection = line.split(":", 1)[1].strip() texture_index = FileSystemItem.TEXTURE_MAP.get(collection, texture_index) genre_index = get_genre_index(collection) elif line.startswith("#BACKCOLOR:"): back_color = parse_hex_color(line.split(":", 1)[1].strip()) texture_index = TextureIndex.BLANK elif line.startswith("#FORECOLOR:"): fore_color = parse_hex_color(line.split(":", 1)[1].strip()) if name == '': if genre: name = genre else: name = path.name if back_color is None and fore_color is None and genre_index in DEFAULT_COLORS: back_color, fore_color = DEFAULT_COLORS[genre_index] if genre_index != GenreIndex.DEFAULT: texture_index = TextureIndex.BLANK return name, texture_index, genre_index, collection, back_color, fore_color class FileSystemItem: TEXTURE_MAP = { 'VOCALOID': TextureIndex.VOCALOID, 'ボーカロイド': TextureIndex.VOCALOID, 'RECOMMENDED': TextureIndex.RECOMMENDED, 'FAVORITE': TextureIndex.FAVORITE, 'RECENT': TextureIndex.RECENT, } GENRE_MAP = { GenreIndex.TUTORIAL: {"TUTORIAL"}, GenreIndex.JPOP: {"J-POP"}, GenreIndex.ANIME: {"ANIME", "アニメ"}, GenreIndex.CHILDREN: {"CHILDREN", "どうよう"}, GenreIndex.VOCALOID: {"VOCALOID", "ボーカロイド"}, GenreIndex.VARIETY: {"VARIETY", "バラエティー", "バラエティ"}, GenreIndex.CLASSICAL: {"CLASSICAL", "クラシック"}, GenreIndex.GAME: {"GAME", "ゲームミュージック"}, GenreIndex.NAMCO: {"NAMCO", "ナムコオリジナル"}, GenreIndex.RECOMMENDED: {"RECOMMENDED"}, GenreIndex.FAVORITE: {"FAVORITE"}, GenreIndex.RECENT: {"RECENT"}, GenreIndex.DAN: {"DAN", "段位道場"}, GenreIndex.DIFFICULTY: {"DIFFICULTY"}, } """Base class for files and directories in the navigation system""" def __init__(self, path: Path, name: str): self.path = path self.name = name class Directory(FileSystemItem): """Represents a directory in the navigation system""" COLLECTIONS = [ 'NEW', 'RECENT', 'FAVORITE', 'DIFFICULTY', '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) self.has_box_def = has_box_def self.to_root = to_root self.back = back self.tja_count = tja_count self.collection = None if collection in Directory.COLLECTIONS: self.collection = collection if self.back: self.box = BackBox(name) else: self.box = FolderBox(name, back_color, fore_color, texture_index, genre_index, tja_count=tja_count, box_texture=box_texture) class SongFile(FileSystemItem): """Represents a song file (TJA) in the navigation system""" def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex): super().__init__(path, name) self.is_recent = (datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)) <= timedelta(days=7) self.parser = TJAParser(path) if self.is_recent: self.parser.ex_data.new = True title = self.parser.metadata.title.get(global_data.config['general']['language'].lower(), self.parser.metadata.title['en']) self.hash = global_data.song_paths[path] self.box = SongBox(title, back_color, fore_color, texture_index, self.parser) self.box.hash = global_data.song_hashes[self.hash][0]["diff_hashes"] self.box.get_scores() class SongFileOsu(FileSystemItem): def __init__(self, path: Path, name: str, back_color: Optional[tuple[int, int, int]], fore_color: Optional[tuple[int, int, int]], texture_index: TextureIndex): super().__init__(path, name) self.is_recent = (datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)) <= timedelta(days=7) self.parser = OsuParser(path) title = self.parser.osu_metadata["Version"] self.hash = global_data.song_paths[path] self.box = SongBoxOsu(title, back_color, fore_color, texture_index, self.parser) self.box.hash = global_data.song_hashes[self.hash][0]["diff_hashes"] self.box.get_scores() @dataclass class Exam: type: str red: int gold: int range: str class DanCourse(FileSystemItem): def __init__(self, path: Path, name: str): super().__init__(path, name) if name != "dan.json": logger.error(f"Invalid dan course file: {path}") with open(path, 'r', encoding='utf-8') as f: data = json.load(f) self.title = data["title"] self.color = data["color"] self.charts: list[tuple[TJAParser, int, int, int]] = [] for chart in data["charts"]: hash = chart["hash"] chart_title = chart["title"] chart_subtitle = chart["subtitle"] difficulty = chart["difficulty"] if hash in global_data.song_hashes: path = Path(global_data.song_hashes[hash][0]["file_path"]) else: for key, value in global_data.song_hashes.items(): for i in range(len(value)): song = value[i] if (song["title"]["en"].strip() == chart_title and song["subtitle"]["en"].strip() == chart_subtitle.removeprefix('--') and Path(song["file_path"]).exists()): hash_val = key path = Path(global_data.song_hashes[hash_val][i]["file_path"]) break if (path.parent.parent / "box.def").exists(): genre_index = parse_box_def(path.parent.parent)[2] else: genre_index = GenreIndex.NAMCO tja = TJAParser(path) self.charts.append((tja, genre_index, difficulty, tja.metadata.course_data[difficulty].level)) self.exams = [] for exam in data["exams"]: self.exams.append(Exam(exam["type"], exam["value"][0], exam["value"][1], exam["range"])) self.box = DanBox(self.title, self.color, self.charts, self.exams) class FileNavigator: """Manages navigation through pre-generated Directory and SongFile objects""" def __init__(self): # Pre-generated objects storage self.all_directories: dict[str, Directory] = {} # path -> Directory self.all_song_files: dict[str, Union[SongFile, DanCourse, SongFileOsu]] = {} # path -> SongFile self.directory_contents: dict[str, list[Union[Directory, SongFile]]] = {} # path -> list of items # OPTION 2: Lazy crown calculation with caching self.directory_crowns: dict[str, dict] = dict() # path -> crown list self.crown_cache_dirty: set[str] = set() # directories that need crown recalculation # Navigation state - simplified without root-specific state self.current_dir = Path() # Empty path represents virtual root self.items: list[Directory | SongFile] = [] self.new_items: list[Directory | SongFile] = [] self.favorite_folder: Optional[Directory] = None self.recent_folder: Optional[Directory] = None self.selected_index = 0 self.diff_sort_diff = Difficulty.URA self.diff_sort_level = 10 self.diff_sort_statistics = dict() self.history = [] self.box_open = False 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]): self.root_dirs = [Path(p) if not isinstance(p, Path) else p for p in root_dirs] self._generate_all_objects() self._create_virtual_root() self.load_current_directory() logger.info(f"FileNavigator initialized with root_dirs: {self.root_dirs}") def _create_virtual_root(self): """Create a virtual root directory containing all root directories""" virtual_root_items = [] for root_path in self.root_dirs: if not root_path.exists(): continue root_key = str(root_path) if root_key in self.all_directories: # Root has box.def, add the directory itself virtual_root_items.append(self.all_directories[root_key]) else: # Root doesn't have box.def, add its immediate children with box.def for child_path in sorted(root_path.iterdir()): if child_path.is_dir(): child_key = str(child_path) if child_key in self.all_directories: virtual_root_items.append(self.all_directories[child_key]) # Also add direct TJA files from root all_tja_files = self._find_tja_files_recursive(root_path) for tja_path in sorted(all_tja_files): song_key = str(tja_path) if song_key in self.all_song_files: virtual_root_items.append(self.all_song_files[song_key]) # Store virtual root contents (empty path key represents root) self.directory_contents["."] = virtual_root_items def _generate_all_objects(self): """Generate all Directory and SongFile objects in advance""" logging.info("Generating all Directory and SongFile objects...") # Generate objects for each root directory for root_path in self.root_dirs: if not root_path.exists(): logging.warning(f"Root directory does not exist: {root_path}") continue self._generate_objects_recursive(root_path) if self.favorite_folder is not None and self.favorite_folder.path.exists(): song_list = self._read_song_list(self.favorite_folder.path) for song_obj in song_list: if str(song_obj) in self.all_song_files: box = self.all_song_files[str(song_obj)].box if isinstance(box, DanBox): logger.warning(f"Cannot favorite DanCourse: {song_obj}") else: box.is_favorite = True logging.info(f"Object generation complete. " f"Directories: {len(self.all_directories)}, " f"Songs: {len(self.all_song_files)}") def _generate_objects_recursive(self, dir_path: Path): """Recursively generate Directory and SongFile objects for a directory""" if not dir_path.is_dir(): return dir_key = str(dir_path) # Check for box.def has_box_def = (dir_path / "box.def").exists() # Only create Directory objects for directories with box.def if has_box_def: # Parse box.def if it exists name = dir_path.name if dir_path.name else str(dir_path) box_texture = None collection = None back_color = None fore_color = None name, texture_index, genre_index, collection, back_color, fore_color = parse_box_def(dir_path) box_png_path = dir_path / "box.png" if box_png_path.exists(): box_texture = str(box_png_path) # Count TJA files for this directory tja_count = self._count_tja_files(dir_path) if collection == Directory.COLLECTIONS[4]: tja_count = 10 elif collection == Directory.COLLECTIONS[0]: tja_count = len(self.new_items) # Create Directory object directory_obj = Directory( dir_path, name, back_color, fore_color, texture_index, genre_index, has_box_def=has_box_def, tja_count=tja_count, box_texture=box_texture, collection=collection, ) if directory_obj.collection == Directory.COLLECTIONS[2]: self.favorite_folder = directory_obj elif directory_obj.collection == Directory.COLLECTIONS[1]: self.recent_folder = directory_obj self.all_directories[dir_key] = directory_obj # Generate content list for this directory content_items = [] # Add child directories that have box.def child_dirs = [] for item_path in dir_path.iterdir(): if item_path.is_dir(): child_has_osu = any(item_path.glob("*.osu")) if child_has_osu: child_dirs.append(item_path) self.process_osz(item_path) child_has_box_def = (item_path / "box.def").exists() if child_has_box_def: child_dirs.append(item_path) # Recursively generate objects for child directory self._generate_objects_recursive(item_path) # Sort and add child directories for child_path in sorted(child_dirs): child_key = str(child_path) if child_key in self.all_directories: content_items.append(self.all_directories[child_key]) # Get TJA files for this directory tja_files = self._get_tja_files_for_directory(dir_path) # Create SongFile objects for tja_path in sorted(tja_files): song_key = str(tja_path) if song_key not in self.all_song_files and tja_path.name == "dan.json": try: song_obj = DanCourse(tja_path, tja_path.name) self.all_song_files[song_key] = song_obj except Exception as e: logger.error(f"Error creating DanCourse object for {tja_path}: {e}") elif song_key not in self.all_song_files and tja_path in global_data.song_paths: song_obj = SongFile(tja_path, tja_path.name, back_color, fore_color, texture_index) song_obj.box.get_scores() for course in song_obj.parser.metadata.course_data: level = song_obj.parser.metadata.course_data[course].level scores = song_obj.box.scores.get(course) if scores is not None: is_cleared = scores[4] >= Crown.CLEAR if scores[4] is not None else False is_full_combo = scores[4] == Crown.FC if scores[4] is not None else False else: is_cleared = False is_full_combo = False if course not in self.diff_sort_statistics: self.diff_sort_statistics[course] = {} if level not in self.diff_sort_statistics[course]: self.diff_sort_statistics[course][level] = [1, int(is_full_combo), int(is_cleared)] else: self.diff_sort_statistics[course][level][0] += 1 if is_full_combo: self.diff_sort_statistics[course][level][1] += 1 elif is_cleared: self.diff_sort_statistics[course][level][2] += 1 if song_obj.is_recent: self.new_items.append(SongFile(tja_path, tja_path.name, back_color, fore_color, texture_index)) self.song_count += 1 global_data.song_progress = self.song_count / global_data.total_songs self.all_song_files[song_key] = song_obj if song_key in self.all_song_files: content_items.append(self.all_song_files[song_key]) self.directory_contents[dir_key] = content_items else: # For directories without box.def, still process their children for item_path in dir_path.iterdir(): if item_path.is_dir(): self._generate_objects_recursive(item_path) # Create SongFile objects for TJA files in non-boxed directories tja_files = self._find_tja_files_in_directory_only(dir_path) for tja_path in tja_files: song_key = str(tja_path) if song_key not in self.all_song_files: try: song_obj = SongFile(tja_path, tja_path.name, None, None, TextureIndex.DEFAULT) self.song_count += 1 global_data.song_progress = self.song_count / global_data.total_songs self.all_song_files[song_key] = song_obj except Exception as e: logger.error(f"Error creating SongFile for {tja_path}: {e}") continue def process_osz(self, dir_path: Path): dir_key = str(dir_path) if dir_path.iterdir(): name = dir_path.name for file in dir_path.iterdir(): if file.name.endswith('.osu'): with open(file, 'r', encoding='utf-8') as f: content = f.readlines() for line in content: if line.startswith('TitleUnicode:'): title_unicode = line.split(':', 1)[1].strip() name = title_unicode break else: name = dir_path.name if dir_path.name else str(dir_path) box_texture = None collection = None back_color = None fore_color = None texture_index = TextureIndex.DEFAULT genre_index = GenreIndex.DEFAULT for file in dir_path.iterdir(): if file.name.endswith('.jpg') or file.name.endswith('.png'): box_texture = str(file) # Create Directory object file_count = len([file for file in dir_path.glob("*.osu")]) directory_obj = Directory( dir_path, name, back_color, fore_color, texture_index, genre_index, tja_count=file_count, box_texture=box_texture, collection=collection, ) self.all_directories[dir_key] = directory_obj content_items = [] osu_files = [file for file in dir_path.glob("*.osu")] # Create SongFile objects for osu_path in sorted(osu_files): song_key = str(osu_path) if song_key not in self.all_song_files and osu_path in global_data.song_paths: song_obj = SongFileOsu(osu_path, osu_path.name, back_color, fore_color, texture_index) song_obj.box.get_scores() self.song_count += 1 global_data.song_progress = self.song_count / global_data.total_songs self.all_song_files[song_key] = song_obj if song_key in self.all_song_files: content_items.append(self.all_song_files[song_key]) self.directory_contents[dir_key] = content_items def is_at_root(self) -> bool: """Check if currently at the virtual root""" return self.current_dir == Path() def load_new_items(self, selected_item, dir_key: str): return self.new_items def load_recent_items(self, selected_item, dir_key: str): if self.recent_folder is None: raise Exception("tried to enter recent folder without recents") self._generate_objects_recursive(self.recent_folder.path) if not isinstance(selected_item.box, BackBox): selected_item.box.tja_count = self._count_tja_files(self.recent_folder.path) return self.directory_contents[dir_key] def load_favorite_items(self, selected_item, dir_key: str): if self.favorite_folder is None: raise Exception("tried to enter favorite folder without favorites") self._generate_objects_recursive(self.favorite_folder.path) tja_files = self._get_tja_files_for_directory(self.favorite_folder.path) self._calculate_directory_crowns(dir_key, tja_files) if not isinstance(selected_item.box, BackBox): selected_item.box.tja_count = self._count_tja_files(self.favorite_folder.path) self.in_favorites = True return self.directory_contents[dir_key] def load_diff_sort_items(self, selected_item, dir_key: str): content_items = [] parent_dir = selected_item.path.parent for sibling_path in parent_dir.iterdir(): if sibling_path.is_dir() and sibling_path != selected_item.path: sibling_key = str(sibling_path) if sibling_key in self.directory_contents: for item in self.directory_contents[sibling_key]: if isinstance(item, SongFile) and item: if self.diff_sort_diff in item.parser.metadata.course_data and item.parser.metadata.course_data[self.diff_sort_diff].level == self.diff_sort_level: if item not in content_items: content_items.append(item) return content_items def load_recommended_items(self, selected_item, dir_key: str): parent_dir = selected_item.path.parent temp_items = [] for sibling_path in parent_dir.iterdir(): if sibling_path.is_dir() and sibling_path != selected_item.path: sibling_key = str(sibling_path) if sibling_key in self.directory_contents: for item in self.directory_contents[sibling_key]: if not isinstance(item, Directory) and isinstance(item, SongFile): temp_items.append(item) return random.sample(temp_items, min(10, len(temp_items))) def _levenshtein_distance(self, s1: str, s2: str): # Create a matrix to store distances m, n = len(s1), len(s2) dp = [[0] * (n + 1) for _ in range(m + 1)] # Initialize base cases for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j # Fill the matrix for i in range(1, m + 1): for j in range(1, n + 1): if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1] # No operation needed else: dp[i][j] = 1 + min( dp[i-1][j], # Deletion dp[i][j-1], # Insertion dp[i-1][j-1] # Substitution ) return dp[m][n] def search_song(self, search_name: str): items = [] for path, song in self.all_song_files.items(): if self._levenshtein_distance(song.name[:-4].lower(), search_name.lower()) < 2: items.append(song) if isinstance(song, SongFile): if self._levenshtein_distance(song.parser.metadata.subtitle["en"].lower(), search_name.lower()) < 2: items.append(song) return items def load_current_directory(self, selected_item: Optional[Directory] = None): """Load pre-generated items for the current directory (unified for root and subdirs)""" dir_key = str(self.current_dir) # Determine if current directory has child directories with box.def has_children = False if self.is_at_root() or selected_item and selected_item.box.genre_index == GenreIndex.DAN: has_children = True # Root always has "children" (the root directories) else: has_children = any(item.is_dir() and (item / "box.def").exists() for item in self.current_dir.iterdir()) self.genre_bg = None self.in_favorites = False if has_children: self.items = [] if not self.box_open: self.selected_index = 0 start_box = None end_box = None # Add back navigation item (only if not at root) if not self.is_at_root(): back_dir = Directory(self.current_dir.parent, "", BackBox.COLOR, BackBox.COLOR, TextureIndex.BLANK, GenreIndex.DEFAULT, back=True) if not has_children: start_box = back_dir.box self.items.insert(self.selected_index, back_dir) # Add pre-generated content for this directory if dir_key in self.directory_contents: content_items = self.directory_contents[dir_key] # Handle special collections (same logic as before) if isinstance(selected_item, Directory): if selected_item.collection == Directory.COLLECTIONS[0]: content_items = self.load_new_items(selected_item, dir_key) elif selected_item.collection == Directory.COLLECTIONS[1]: content_items = self.load_recent_items(selected_item, dir_key) elif selected_item.collection == Directory.COLLECTIONS[2]: content_items = self.load_favorite_items(selected_item, dir_key) elif selected_item.collection == Directory.COLLECTIONS[3]: 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() return i = 1 for item in content_items: if isinstance(item, SongFile) and not has_children: if i % 10 == 0 and i != 0: back_dir = Directory(self.current_dir.parent, "", BackBox.COLOR, BackBox.COLOR, TextureIndex.BLANK, GenreIndex.DEFAULT, back=True) self.items.insert(self.selected_index+i, back_dir) i += 1 if not has_children: if selected_item is not None: item.box.back_color = selected_item.box.back_color item.box.genre_index = selected_item.box.genre_index item.box.texture_index = selected_item.box.texture_index self.items.insert(self.selected_index+i, item) else: self.items.append(item) i += 1 if not has_children: self.box_open = True end_box = content_items[-1].box if selected_item in self.items: self.items.remove(selected_item) # Calculate crowns for directories for item in self.items: if isinstance(item, Directory): item_key = str(item.path) if isinstance(item.box, FolderBox): item.box.crown = self._get_directory_crowns_cached(item_key) self.calculate_box_positions() if selected_item and isinstance(selected_item.box, FolderBox): if (not has_children and start_box is not None and end_box is not None and selected_item is not None and selected_item.box.hori_name is not None): hori_name = selected_item.box.hori_name diff_sort = None if selected_item.collection == Directory.COLLECTIONS[3]: diff_sort = self.diff_sort_level diffs = ['かんたん', 'ふつう', 'むずかしい', 'おに'] hori_name = OutlinedText(diffs[min(Difficulty.ONI, self.diff_sort_diff)], tex.skin_config["song_hori_name"].font_size, ray.WHITE, outline_thickness=5) self.genre_bg = GenreBG(start_box, end_box, hori_name, diff_sort) def select_current_item(self): """Select the currently highlighted item""" if not self.items or self.selected_index >= len(self.items): return selected_item = self.items[self.selected_index] if isinstance(selected_item, Directory): if self.box_open: self.go_back() if selected_item.back: # Handle back navigation if self.current_dir.parent == Path(): # Going back to root self.current_dir = Path() else: self.current_dir = self.current_dir.parent else: # Save current state to history self.history.append((self.current_dir, self.selected_index)) self.current_dir = selected_item.path logger.info(f"Entered Directory {selected_item.path} at index {self.selected_index}") self.load_current_directory(selected_item=selected_item) return selected_item def go_back(self): """Navigate back to the previous directory""" if self.history: previous_dir, previous_index = self.history.pop() self.current_dir = previous_dir self.selected_index = previous_index self.load_current_directory() self.box_open = False def _count_tja_files(self, folder_path: Path): """Count TJA files in directory""" tja_count = 0 # Find all song_list.txt files recursively song_list_files = list(folder_path.rglob("song_list.txt")) if song_list_files: # Process all song_list.txt files found for song_list_path in song_list_files: with open(song_list_path, 'r', encoding='utf-8-sig') as song_list_file: tja_count += len([line for line in song_list_file.readlines() if line.strip()]) # Fallback: Use recursive counting of .tja or dan.json files tja_count += sum(1 for _ in list(folder_path.rglob("*.tja")) + list(folder_path.rglob("dan.json"))) return tja_count def _get_directory_crowns_cached(self, dir_key: str) -> dict: """Get crowns for a directory, calculating only if needed""" if dir_key in self.crown_cache_dirty or dir_key not in self.directory_crowns: # Calculate crowns on-demand tja_files = self.directory_contents[dir_key] self._calculate_directory_crowns(dir_key, tja_files) self.crown_cache_dirty.discard(dir_key) return self.directory_crowns.get(dir_key, dict()) def _calculate_directory_crowns(self, dir_key: str, tja_files: list): """Pre-calculate crowns for a directory""" all_scores = dict() child_has_any_crown = [] # Track if each child has been played at all for item in tja_files: has_crown = False if isinstance(item, SongFile): has_crown = any((item.box.scores.get(d) or (None,)*6)[5] is not None for d in [Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD, Difficulty.ONI]) for diff in item.box.scores: if diff not in all_scores: all_scores[diff] = [] all_scores[diff].append(item.box.scores[diff]) elif isinstance(item, Directory): child_key = str(item.path) child_crowns = self._get_directory_crowns_cached(child_key) has_crown = bool(child_crowns) # Directory is "played" if it has any crowns if not child_crowns: # Unplayed directory - add None for all difficulties for diff in [Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD, Difficulty.ONI]: if diff not in all_scores: all_scores[diff] = [] all_scores[diff].append((None, None, None, None, None, None)) else: # Played directory - add its crowns for diff in [Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD, Difficulty.ONI]: if diff not in all_scores: all_scores[diff] = [] if diff in child_crowns: all_scores[diff].append((None, None, None, None, None, child_crowns[diff])) else: # This directory doesn't have this difficulty, but it's been played # Don't add anything - this child doesn't count for this difficulty pass child_has_any_crown.append(has_crown) # If ANY child is completely unplayed, no crowns at all if not all(child_has_any_crown): self.directory_crowns[dir_key] = {} return crowns = {} for diff in all_scores: if any(score is None or score[5] is None for score in all_scores[diff]): continue if all(score[5] == Crown.DFC for score in all_scores[diff]): crowns[diff] = Crown.DFC elif all(score[5] == Crown.FC for score in all_scores[diff]): crowns[diff] = Crown.FC elif all(score[5] >= Crown.CLEAR for score in all_scores[diff]): crowns[diff] = Crown.CLEAR self.directory_crowns[dir_key] = crowns def _get_tja_files_for_directory(self, directory: Path): """Get TJA files for a specific directory""" if (directory / 'song_list.txt').exists(): return self._read_song_list(directory) else: return self._find_tja_files_in_directory_only(directory) def _find_tja_files_in_directory_only(self, directory: Path): """Find TJA files only in the specified directory, not recursively in subdirectories with box.def""" tja_files: list[Path] = [] for path in directory.iterdir(): if (path.is_file() and path.suffix.lower() == ".tja") or path.name == "dan.json": tja_files.append(path) elif path.is_dir(): # Only recurse into subdirectories that don't have box.def sub_dir_has_box_def = (path / "box.def").exists() if not sub_dir_has_box_def: tja_files.extend(self._find_tja_files_in_directory_only(path)) return tja_files def _find_tja_files_recursive(self, directory: Path, box_def_dirs_only=True): tja_files: list[Path] = [] has_box_def = (directory / "box.def").exists() if box_def_dirs_only and has_box_def and directory != self.current_dir: return [] for path in directory.iterdir(): if path.is_file() and path.suffix.lower() == ".tja": tja_files.append(path) elif path.is_dir(): sub_dir_has_box_def = (path / "box.def").exists() if not sub_dir_has_box_def: tja_files.extend(self._find_tja_files_recursive(path, box_def_dirs_only)) return tja_files def _read_song_list(self, path: Path): """Read and process song_list.txt file""" tja_files: list[Path] = [] updated_lines = [] file_updated = False with open(path / 'song_list.txt', 'r', encoding='utf-8-sig') as song_list: for line in song_list: line = line.strip() if not line: continue parts = line.split('|') if len(parts) < 3: continue hash_val, title, subtitle = parts[0], parts[1], parts[2] original_hash = hash_val if hash_val in global_data.song_hashes: for entry in global_data.song_hashes[hash_val]: file_path = Path(entry["file_path"]) if file_path.exists() and file_path not in tja_files: tja_files.append(file_path) else: # Try to find by title and subtitle for key, value in global_data.song_hashes.items(): for i in range(len(value)): song = value[i] if (song["title"]["en"].strip() == title and song["subtitle"]["en"].strip() == subtitle.removeprefix('--') and Path(song["file_path"]).exists()): hash_val = key tja_files.append(Path(global_data.song_hashes[hash_val][i]["file_path"])) break if hash_val != original_hash: file_updated = True updated_lines.append(f"{hash_val}|{title}|{subtitle.removeprefix('--')}") # Write back updated song list if needed if file_updated: with open(path / 'song_list.txt', 'w', encoding='utf-8-sig') as song_list: for line in updated_lines: logger.info(f"updated: {line}") song_list.write(line + '\n') return tja_files def calculate_box_positions(self): """Dynamically calculate box positions based on current selection with wrap-around support""" if not self.items: return for i, item in enumerate(self.items): offset = i - self.selected_index if offset > len(self.items) // 2: offset -= len(self.items) elif offset < -len(self.items) // 2: offset += len(self.items) # Adjust spacing based on dan select mode base_spacing = 100 * tex.screen_scale center_offset = 150 * tex.screen_scale side_offset_l = 0 * tex.screen_scale side_offset_r = 300 * tex.screen_scale if self.in_dan_select: base_spacing = 150 * tex.screen_scale side_offset_l = 200 * tex.screen_scale side_offset_r = 500 * tex.screen_scale position = (BOX_CENTER - center_offset) + (base_spacing * offset) if position == BOX_CENTER - center_offset: position += center_offset elif position > BOX_CENTER - center_offset: position += side_offset_r else: position -= side_offset_l if item.box.position == float('inf'): item.box.position = position item.box.target_position = position else: item.box.target_position = position def draw_boxes(self, move_away_attribute: float, is_ura: bool, diff_fade_out_attribute: float): for item in self.items: box = item.box fade = 1.0 if self.genre_bg and self.genre_bg.start_position <= box.position <= self.genre_bg.end_position_final: fade = self.genre_bg.box_fade_in.attribute if (-156 * tex.screen_scale) <= box.position <= (tex.screen_width + 144) * tex.screen_scale: if box.position <= (500 * tex.screen_scale): box.draw(box.position - int(move_away_attribute), tex.skin_config["boxes"].y, is_ura, inner_fade_override=diff_fade_out_attribute, outer_fade_override=fade) else: box.draw(box.position + int(move_away_attribute), tex.skin_config["boxes"].y, is_ura, inner_fade_override=diff_fade_out_attribute, outer_fade_override=fade) def mark_crowns_dirty_for_song(self, song_file: SongFile | SongFileOsu): """Mark directories as needing crown recalculation when a song's score changes""" song_path = song_file.path # Find all directories that contain this song and mark them as dirty for dir_key, content_items in self.directory_contents.items(): for item in content_items: if isinstance(item, SongFile) and item.path == song_path: self.crown_cache_dirty.add(dir_key) break def navigate_left(self): """Move selection left with wrap-around""" if self.items: self.selected_index = (self.selected_index - 1) % len(self.items) self.calculate_box_positions() for item in self.items: item.box.move_box(1) logger.info(f"Moved Left to {self.items[self.selected_index].path}") def navigate_right(self): """Move selection right with wrap-around""" if self.items: self.selected_index = (self.selected_index + 1) % len(self.items) self.calculate_box_positions() for item in self.items: item.box.move_box(-1) logger.info(f"Moved Right to {self.items[self.selected_index].path}") def skip_left(self): if self.items: self.selected_index = (self.selected_index - 10) % len(self.items) self.calculate_box_positions() logger.info(f"Skipped Left to {self.items[self.selected_index].path}") def skip_right(self): if self.items: self.selected_index = (self.selected_index + 10) % len(self.items) self.calculate_box_positions() logger.info(f"Skipped Right to {self.items[self.selected_index].path}") def get_current_item(self): """Get the currently selected item""" if self.items and 0 <= self.selected_index < len(self.items): return self.items[self.selected_index] raise Exception("No current item available") def reset_items(self): """Reset the items in the song select scene""" song = self.get_current_item() if isinstance(song.box, SongBox): if song.box.yellow_box is not None: song.box.yellow_box.create_anim() def add_recent(self): """Add the current song to the recent list""" song = self.get_current_item() if isinstance(song, Directory): return if self.recent_folder is None: return recents_path = self.recent_folder.path / 'song_list.txt' new_entry = f'{song.hash}|{song.parser.metadata.title["en"]}|{song.parser.metadata.subtitle["en"]}\n' existing_entries = [] if recents_path.exists(): with open(recents_path, 'r', encoding='utf-8-sig') as song_list: existing_entries = song_list.readlines() existing_entries = [entry for entry in existing_entries if not entry.startswith(f'{song.hash}|')] all_entries = [new_entry] + existing_entries recent_entries = all_entries[:25] with open(recents_path, 'w', encoding='utf-8-sig') as song_list: song_list.writelines(recent_entries) logger.info(f"Added Recent: {song.hash} {song.parser.metadata.title['en']} {song.parser.metadata.subtitle['en']}") def add_favorite(self) -> bool: """Add the current song to the favorites list""" song = self.get_current_item() if isinstance(song, Directory): return False if self.favorite_folder is None: return False favorites_path = self.favorite_folder.path / 'song_list.txt' lines = [] if not Path(favorites_path).exists(): Path(favorites_path).touch() with open(favorites_path, 'r', encoding='utf-8-sig') as song_list: for line in song_list: line = line.strip() if not line: # Skip empty lines continue hash, title, subtitle = line.split('|') if song.hash == hash or (song.parser.metadata.title['en'] == title and song.parser.metadata.subtitle['en'] == subtitle): if not self.in_favorites: return False else: lines.append(line) if self.in_favorites: with open(favorites_path, 'w', encoding='utf-8-sig') as song_list: for line in lines: song_list.write(line + '\n') logger.info(f"Removed Favorite: {song.hash} {song.parser.metadata.title['en']} {song.parser.metadata.subtitle['en']}") else: with open(favorites_path, 'a', encoding='utf-8-sig') as song_list: song_list.write(f'{song.hash}|{song.parser.metadata.title["en"]}|{song.parser.metadata.subtitle["en"]}\n') logger.info(f"Added Favorite: {song.hash} {song.parser.metadata.title['en']} {song.parser.metadata.subtitle['en']}") return True navigator = FileNavigator()