Files
PyTaiko/libs/file_navigator.py
2025-11-21 00:05:20 -05:00

1594 lines
79 KiB
Python

from dataclasses import dataclass
import json
import logging
from pathlib import Path
import random
from typing import Optional, Union
from libs.audio import audio
from libs.animation import Animation, MoveAnimation
from libs.global_data import Crown, Difficulty
from libs.tja import TJAParser, test_encodings
from libs.texture import tex
from libs.utils import OutlinedText, get_current_ms, global_data
from datetime import datetime, timedelta
import sqlite3
import pyray as ray
BOX_CENTER = 594 * tex.screen_scale
logger = logging.getLogger(__name__)
class BaseBox():
OUTLINE_MAP = {
1: ray.Color(0, 77, 104, 255),
2: ray.Color(156, 64, 2, 255),
3: ray.Color(84, 101, 126, 255),
4: ray.Color(153, 4, 46, 255),
5: ray.Color(60, 104, 0, 255),
6: ray.Color(134, 88, 0, 255),
7: ray.Color(79, 40, 134, 255),
8: ray.Color(148, 24, 0, 255),
9: ray.Color(101, 0, 82, 255),
10: ray.Color(140, 39, 92, 255),
11: ray.Color(151, 57, 30, 255),
12: ray.Color(35, 123, 103, 255),
13: ray.Color(25, 68, 137, 255),
14: ray.Color(157, 13, 31, 255)
}
BACK_INDEX = 17
DEFAULT_INDEX = 9
DIFFICULTY_SORT_INDEX = 14
"""Base class for all box types in the song select screen."""
def __init__(self, name: str, texture_index: int):
self.text_name = name
self.texture_index = texture_index
self.position = float('inf')
self.start_position: float = -1
self.target_position: float = -1
self.open_anim = Animation.create_move(133, total_distance=150*tex.screen_scale, delay=83.33)
self.open_fade = Animation.create_fade(200, initial_opacity=0, final_opacity=1.0)
self.move = None
self.is_open = False
self.text_loaded = False
self.wait = 0
def load_text(self):
self.name = OutlinedText(self.text_name, tex.skin_config["song_box_name"].font_size, ray.WHITE, outline_thickness=5, vertical=True)
def move_box(self, current_time: float):
if self.position != self.target_position and self.move is None:
if self.position < self.target_position:
direction = 1
else:
direction = -1
if abs(self.target_position - self.position) > 250 * tex.screen_scale:
direction *= -1
self.move = Animation.create_move(83.3, total_distance=100 * direction * tex.screen_scale, ease_out='cubic')
self.move.start()
if self.is_open or self.target_position == BOX_CENTER:
self.move.total_distance = int(250 * direction * tex.screen_scale)
self.start_position = self.position
if self.move is not None:
self.move.update(current_time)
self.position = self.start_position + int(self.move.attribute)
if self.move.is_finished:
self.position = self.target_position
self.move = None
def update(self, current_time: float, is_diff_select: bool):
self.is_diff_select = is_diff_select
self.open_anim.update(current_time)
self.open_fade.update(current_time)
def _draw_closed(self, x: float, y: float):
tex.draw_texture('box', 'folder_texture_left', frame=self.texture_index, x=x)
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, x2=tex.skin_config["song_box_bg"].width, y=offset)
tex.draw_texture('box', 'folder_texture_right', frame=self.texture_index, x=x)
if self.texture_index == BaseBox.DEFAULT_INDEX:
tex.draw_texture('box', 'genre_overlay', x=x, y=y)
elif self.texture_index == BaseBox.DIFFICULTY_SORT_INDEX:
tex.draw_texture('box', 'diff_overlay', x=x, y=y)
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, fade_override: Optional[float] = None):
if self.is_open and get_current_ms() >= self.wait + 83.33:
self._draw_open(x, y, fade_override, is_ura)
else:
self._draw_closed(x, y)
class BackBox(BaseBox):
def __init__(self, name: str, texture_index: int):
super().__init__(name, texture_index)
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.move_box(current_time)
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):
super()._draw_closed(x, y)
tex.draw_texture('box', 'back_text', x=x, y=y)
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, texture_index: int, tja: TJAParser, name_texture_index: Optional[int] = None):
super().__init__(name, texture_index)
if name_texture_index is None:
self.name_texture_index = texture_index
else:
self.name_texture_index = name_texture_index
self.scores = dict()
self.hash = dict()
self.score_history = None
self.history_wait = 0
self.tja = 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('scores.db') as con:
cursor = con.cursor()
# Batch database query for all diffs at once
if self.tja.metadata.course_data:
hash_values = [self.hash[diff] for diff in self.tja.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.tja.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.move_box(current_time)
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.tja)
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):
super()._draw_closed(x, y)
self.name.draw(outline_color=SongBox.OUTLINE_MAP.get(self.name_texture_index, ray.Color(101, 0, 82, 255)), 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)
if self.tja.ex_data.new:
tex.draw_texture('yellow_box', 'ex_data_new_song_balloon', x=x, y=y)
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))
elif score and score[5] == Crown.FC:
tex.draw_texture('yellow_box', 'crown_fc', x=x, y=y, frame=min(Difficulty.URA, highest_key))
elif score and score[5] >= Crown.CLEAR:
tex.draw_texture('yellow_box', 'crown_clear', x=x, y=y, frame=min(Difficulty.URA, highest_key))
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 FolderBox(BaseBox):
def __init__(self, name: str, texture_index: int, tja_count: int = 0,
box_texture: Optional[str] = None):
super().__init__(name, texture_index)
self.box_texture_path = Path(box_texture) if box_texture else None
self.is_back = self.texture_index == SongBox.BACK_INDEX
self.tja_count = tja_count
self.crown = dict()
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
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.move_box(current_time)
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.texture_index != SongBox.BACK_INDEX and not audio.is_sound_playing('voice_enter'):
audio.play_sound(f'genre_voice_{self.texture_index}', 'voice')
elif not self.is_open and is_open_prev and self.texture_index != 17 and audio.is_sound_playing(f'genre_voice_{self.texture_index}'):
audio.stop_sound(f'genre_voice_{self.texture_index}')
def _draw_closed(self, x: float, y: float):
super()._draw_closed(x, y)
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_clip', frame=self.texture_index, x=x - ((1 * tex.screen_scale) - offset), y=y)
self.name.draw(outline_color=SongBox.OUTLINE_MAP.get(self.texture_index, ray.Color(101, 0, 82, 255)), 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)
if self.crown: #Folder lamp
highest_crown = max(self.crown)
if self.crown[highest_crown] == 'DFC':
tex.draw_texture('yellow_box', 'crown_dfc', x=x, y=y, frame=min(Difficulty.URA, highest_crown))
elif self.crown[highest_crown] == 'FC':
tex.draw_texture('yellow_box', 'crown_fc', x=x, y=y, frame=min(Difficulty.URA, highest_crown))
else:
tex.draw_texture('yellow_box', 'crown_clear', x=x, y=y, frame=min(Difficulty.URA, highest_crown))
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):
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)
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.texture_index == BaseBox.DEFAULT_INDEX:
tex.draw_texture('box', 'genre_overlay_large', x=x, y=y, color=color)
elif self.texture_index == BaseBox.DIFFICULTY_SORT_INDEX:
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.texture_index != BaseBox.DIFFICULTY_SORT_INDEX:
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 != SongBox.DEFAULT_INDEX:
tex.draw_texture('box', 'folder_graphic', color=color, frame=self.texture_index)
tex.draw_texture('box', 'folder_text', color=color, frame=self.texture_index)
elif self.box_texture is not None:
ray.draw_texture(self.box_texture, int((x+tex.skin_config["box_texture"].x) - (self.box_texture.width//2)), int((y+tex.skin_config["box_texture"].y) - (self.box_texture.height//2)), color)
class YellowBox:
"""A song box when it is opened."""
def __init__(self, is_back: bool, tja: Optional[TJAParser] = 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'], '')
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)
self.is_dan = is_dan
self.subtitle = None
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
print(song_box.scores)
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: int, songs: list[tuple[TJAParser, int, int, int]], exams: list['Exam']):
super().__init__(name, 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.move_box(current_time)
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):
tex.draw_texture('box', 'folder', frame=self.texture_index, x=x)
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)
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.start_box = start_box
self.end_box = end_box
self.start_position = start_box.position
self.end_position = end_box.position
self.title = title
self.fade_in = Animation.create_fade(116, initial_opacity=0.0, final_opacity=1.0, ease_in='quadratic', delay=50)
self.fade_in.start()
self.diff_num = diff_sort
def update(self, current_ms):
self.start_position = self.start_box.position
self.end_position = self.end_box.position
self.fade_in.update(current_ms)
def draw(self, y):
offset = (tex.skin_config["genre_bg_offset"].x * -1) if self.start_box.is_open else 0
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)
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.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)
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.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)
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)
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:
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)
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_box_def(path: Path):
"""Parse box.def file for directory metadata"""
texture_index = SongBox.DEFAULT_INDEX
name = path.name
genre = ''
collection = None
encoding = test_encodings(path / "box.def")
try:
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.GENRE_MAP.get(genre, SongBox.DEFAULT_INDEX)
if texture_index == SongBox.DEFAULT_INDEX:
texture_index = FileSystemItem.GENRE_MAP_2.get(genre, SongBox.DEFAULT_INDEX)
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()
if name == '':
if genre:
name = genre
else:
name = path.name
except Exception as e:
logger.error(f"Error parsing box.def in {path}: {e}")
return name, texture_index, collection
class FileSystemItem:
GENRE_MAP = {
'J-POP': 1,
'アニメ': 2,
'VOCALOID': 3,
'どうよう': 4,
'バラエティー': 5,
'クラシック': 6,
'ゲームミュージック': 7,
'ナムコオリジナル': 8,
'RECOMMENDED': 10,
'FAVORITE': 11,
'RECENT': 12,
'段位道場': 13,
'DIFFICULTY': 14
}
GENRE_MAP_2 = {
'ボーカロイド': 3,
'バラエティ': 5
}
"""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'
]
def __init__(self, path: Path, name: str, texture_index: int, 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 collection in FileSystemItem.GENRE_MAP:
texture_index = FileSystemItem.GENRE_MAP[collection]
elif self.to_root or self.back:
texture_index = SongBox.BACK_INDEX
if self.back:
self.box = BackBox(name, texture_index)
else:
self.box = FolderBox(name, texture_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, texture_index: int, tja=None, name_texture_index: Optional[int]=None):
super().__init__(path, name)
self.is_recent = (datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)) <= timedelta(days=7)
self.tja = tja or TJAParser(path)
if self.is_recent:
self.tja.ex_data.new = True
title = self.tja.metadata.title.get(global_data.config['general']['language'].lower(), self.tja.metadata.title['en'])
self.hash = global_data.song_paths[path]
self.box = SongBox(title, texture_index, self.tja, name_texture_index=name_texture_index if name_texture_index is not None else texture_index)
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"])
if (path.parent.parent / "box.def").exists():
_, genre_index, _ = parse_box_def(path.parent.parent)
else:
genre_index = 9
tja = TJAParser(path)
self.charts.append((tja, genre_index, difficulty, tja.metadata.course_data[difficulty].level))
else:
pass
#do something with song_title, song_subtitle
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]] = {} # 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
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:
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)
texture_index = SongBox.DEFAULT_INDEX
box_texture = None
collection = None
name, texture_index, collection = 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, texture_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_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":
valid_dan = True
with open(tja_path, 'r', encoding='utf-8') as file:
dan_data = json.load(file)
for chart in dan_data["charts"]:
hash = chart["hash"]
if hash not in global_data.song_hashes:
valid_dan = False
if valid_dan:
song_obj = DanCourse(tja_path, tja_path.name)
self.all_song_files[song_key] = song_obj
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, texture_index)
song_obj.box.get_scores()
for course in song_obj.tja.metadata.course_data:
level = song_obj.tja.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, SongBox.DEFAULT_INDEX, name_texture_index=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
self.crown_cache_dirty.add(dir_key)
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, SongBox.DEFAULT_INDEX)
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 is_at_root(self) -> bool:
"""Check if currently at the virtual root"""
return self.current_dir == Path()
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.texture_index == 13:
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, "", SongBox.BACK_INDEX, 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.new_items
elif selected_item.collection == Directory.COLLECTIONS[1]:
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)
content_items = self.directory_contents[dir_key]
elif selected_item.collection == Directory.COLLECTIONS[2]:
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)
content_items = self.directory_contents[dir_key]
self.in_favorites = True
elif selected_item.collection == Directory.COLLECTIONS[3]:
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.tja.metadata.course_data and item.tja.metadata.course_data[self.diff_sort_diff].level == self.diff_sort_level:
if item not in content_items:
content_items.append(item)
elif selected_item.collection == Directory.COLLECTIONS[4]:
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):
temp_items.append(item)
content_items = random.sample(temp_items, min(10, len(temp_items)))
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, "", SongBox.BACK_INDEX, 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.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}")
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 files
tja_count += sum(1 for _ in folder_path.rglob("*.tja"))
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()
crowns = dict()
for tja_path in tja_files:
song_key = str(tja_path)
if song_key in self.all_song_files:
song_obj = self.all_song_files[song_key]
if not isinstance(song_obj, SongFile):
continue
for diff in song_obj.box.scores:
if diff not in all_scores:
all_scores[diff] = []
all_scores[diff].append(song_obj.box.scores[diff])
for diff in all_scores:
if all(score is not None and score[5] == Crown.DFC for score in all_scores[diff]):
crowns[diff] = 'DFC'
elif all(score is not None and score[5] == Crown.FC for score in all_scores[diff]):
crowns[diff] = 'FC'
elif all(score is not None and score[5] >= Crown.CLEAR for score in all_scores[diff]):
crowns[diff] = '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:
file_path = Path(global_data.song_hashes[hash_val][0]["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"] == title and
song["subtitle"]["en"] == subtitle 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}")
# 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 mark_crowns_dirty_for_song(self, song_file: SongFile):
"""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()
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()
logger.info(f"Moved 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.tja.metadata.title["en"]}|{song.tja.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.tja.metadata.title['en']} {song.tja.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.tja.metadata.title['en'] == title and song.tja.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.tja.metadata.title['en']} {song.tja.metadata.subtitle['en']}")
else:
with open(favorites_path, 'a', encoding='utf-8-sig') as song_list:
song_list.write(f'{song.hash}|{song.tja.metadata.title["en"]}|{song.tja.metadata.subtitle["en"]}\n')
logger.info(f"Added Favorite: {song.hash} {song.tja.metadata.title['en']} {song.tja.metadata.subtitle['en']}")
return True
navigator = FileNavigator()