From 34dd2adca747948194a82919fa1e983a712c3d3c Mon Sep 17 00:00:00 2001 From: Anthony Samms Date: Sat, 11 Oct 2025 18:49:55 -0400 Subject: [PATCH] Add branching. why not --- libs/song_hash.py | 26 ++-- libs/tja.py | 328 ++++++++++++++++++++++++++---------------- scenes/devtest.py | 14 +- scenes/game.py | 199 +++++++++++++++++++++++-- scenes/song_select.py | 31 ++-- 5 files changed, 432 insertions(+), 166 deletions(-) diff --git a/libs/song_hash.py b/libs/song_hash.py index a626212..d2cfe8e 100644 --- a/libs/song_hash.py +++ b/libs/song_hash.py @@ -4,10 +4,9 @@ import json import sqlite3 import sys import time -from collections import deque from pathlib import Path -from libs.tja import TJAParser +from libs.tja import NoteList, TJAParser from libs.utils import get_config, global_data @@ -113,20 +112,19 @@ def build_song_hashes(output_dir=Path("cache")): tja_path_str = str(tja_path) current_modified = tja_path.stat().st_mtime tja = TJAParser(tja_path) - all_notes = deque() - all_bars = deque() + all_notes = NoteList() diff_hashes = dict() for diff in tja.metadata.course_data: - diff_notes, _, diff_bars = TJAParser.notes_to_position(TJAParser(tja.file_path), diff) - diff_hashes[diff] = tja.hash_note_data(diff_notes, diff_bars) - all_notes.extend(diff_notes) - all_bars.extend(diff_bars) + diff_notes, _, _, _ = TJAParser.notes_to_position(TJAParser(tja.file_path), diff) + diff_hashes[diff] = tja.hash_note_data(diff_notes) + all_notes.play_notes.extend(diff_notes.play_notes) + all_notes.bars.extend(diff_notes.bars) if all_notes == []: continue - hash_val = tja.hash_note_data(all_notes, all_bars) + hash_val = tja.hash_note_data(all_notes) if hash_val not in song_hashes: song_hashes[hash_val] = [] @@ -222,14 +220,14 @@ def build_song_hashes(output_dir=Path("cache")): def process_tja_file(tja_file): """Process a single TJA file and return hash or None if error""" tja = TJAParser(tja_file) - all_notes = [] + all_notes = NoteList() for diff in tja.metadata.course_data: - all_notes.extend( - TJAParser.notes_to_position(TJAParser(tja.file_path), diff) - ) + notes, _, _, _ = TJAParser.notes_to_position(TJAParser(tja.file_path), diff) + all_notes.play_notes.extend(notes.play_notes) + all_notes.bars.extend(notes.bars) if all_notes == []: return '' - hash = tja.hash_note_data(all_notes[0], all_notes[2]) + hash = tja.hash_note_data(all_notes) return hash def get_japanese_songs_for_version(csv_file_path, version_column): diff --git a/libs/tja.py b/libs/tja.py index 1c41231..e0ad98c 100644 --- a/libs/tja.py +++ b/libs/tja.py @@ -33,10 +33,21 @@ class Note: bpm: float = field(init=False) gogo_time: bool = field(init=False) moji: int = field(init=False) + is_branch_start: bool = field(init=False) + branch_params: str = field(init=False) + + def __lt__(self, other): + return self.hit_ms < other.hit_ms def __le__(self, other): return self.hit_ms <= other.hit_ms + def __gt__(self, other): + return self.hit_ms > other.hit_ms + + def __ge__(self, other): + return self.hit_ms >= other.hit_ms + def __eq__(self, other): return self.hit_ms == other.hit_ms @@ -112,12 +123,32 @@ class Balloon(Note): hash_string = str(field_values) return hash_string.encode('utf-8') +@dataclass +class NoteList: + play_notes: list[Note | Drumroll | Balloon] = field(default_factory=lambda: []) + draw_notes: list[Note | Drumroll | Balloon] = field(default_factory=lambda: []) + bars: list[Note] = field(default_factory=lambda: []) + + def __add__(self, other: 'NoteList') -> 'NoteList': + return NoteList( + play_notes=self.play_notes + other.play_notes, + draw_notes=self.draw_notes + other.draw_notes, + bars=self.bars + other.bars + ) + + def __iadd__(self, other: 'NoteList') -> 'NoteList': + self.play_notes += other.play_notes + self.draw_notes += other.draw_notes + self.bars += other.bars + return self + @dataclass class CourseData: level: int = 0 balloon: list[int] = field(default_factory=lambda: []) scoreinit: list[int] = field(default_factory=lambda: []) scorediff: int = 0 + is_branching: bool = False @dataclass class TJAMetadata: @@ -141,16 +172,16 @@ class TJAEXData: new: bool = False -def calculate_base_score(play_notes: deque[Note | Drumroll | Balloon]) -> int: +def calculate_base_score(notes: NoteList) -> int: total_notes = 0 balloon_count = 0 drumroll_msec = 0 - for i in range(len(play_notes)): - note = play_notes[i] - if i < len(play_notes)-1: - next_note = play_notes[i+1] + for i in range(len(notes.play_notes)): + note = notes.play_notes[i] + if i < len(notes.play_notes)-1: + next_note = notes.play_notes[i+1] else: - next_note = play_notes[len(play_notes)-1] + next_note = notes.play_notes[len(notes.play_notes)-1] if isinstance(note, Drumroll): drumroll_msec += (next_note.hit_ms - note.hit_ms) elif isinstance(note, Balloon): @@ -198,7 +229,9 @@ class TJAParser: current_diff = None # Track which difficulty we're currently processing for item in self.data: - if item.startswith("#") or item[0].isdigit(): + if item.startswith('#BRANCH') and current_diff is not None: + self.metadata.course_data[current_diff].is_branching = True + elif item.startswith("#") or item[0].isdigit(): continue elif item.startswith('SUBTITLE'): region_code = 'en' @@ -407,9 +440,10 @@ class TJAParser: play_note_list[-3].moji = se_notes[1][2] def notes_to_position(self, diff: int): - play_note_list: list[Note | Drumroll | Balloon] = [] - draw_note_list: list[Note | Drumroll | Balloon] = [] - bar_list: list[Note] = [] + master_notes = NoteList() + branch_m: list[NoteList] = [] + branch_e: list[NoteList] = [] + branch_n: list[NoteList] = [] notes = self.data_to_notes(diff) balloon = self.metadata.course_data[diff].balloon.copy() count = 0 @@ -420,19 +454,127 @@ class TJAParser: y_scroll_modifier = 0 barline_display = True gogo_time = False - skip_branch = False + curr_note_list = master_notes.play_notes + curr_draw_list = master_notes.draw_notes + curr_bar_list = master_notes.bars + start_branch_ms = 0 + start_branch_bpm = bpm + start_branch_time_sig = time_signature + start_branch_x_scroll = x_scroll_modifier + start_branch_y_scroll = y_scroll_modifier + start_branch_barline = barline_display + start_branch_gogo = gogo_time + branch_balloon_count = 0 + is_branching = False for bar in notes: #Length of the bar is determined by number of notes excluding commands bar_length = sum(len(part) for part in bar if '#' not in part) barline_added = False for part in bar: if part.startswith('#BRANCHSTART'): - skip_branch = True + start_branch_ms = self.current_ms + start_branch_bpm = bpm + start_branch_time_sig = time_signature + start_branch_x_scroll = x_scroll_modifier + start_branch_y_scroll = y_scroll_modifier + start_branch_barline = barline_display + start_branch_gogo = gogo_time + branch_balloon_count = count + branch_params = part[13:] + + if branch_params[0] == 'r': + # Helper function to find and set drumroll branch params + def set_drumroll_branch_params(note_list, bar_list): + for i in range(len(note_list)-1, -1, -1): + if 5 <= note_list[i].type <= 7 or note_list[i].type == 9: + drumroll_ms = note_list[i].hit_ms + for bar_idx in range(len(bar_list)-1, -1, -1): + if bar_list[bar_idx].hit_ms <= drumroll_ms: + bar_list[bar_idx].branch_params = branch_params + return True + break + return False + + # Always try to set in master notes + set_drumroll_branch_params(master_notes.play_notes, master_notes.bars) + + # If we have existing branches, also apply to them + if branch_m and len(branch_m) > 0: + set_drumroll_branch_params(branch_m[-1].play_notes, branch_m[-1].bars) + if branch_e and len(branch_e) > 0: + set_drumroll_branch_params(branch_e[-1].play_notes, branch_e[-1].bars) + if branch_n and len(branch_n) > 0: + set_drumroll_branch_params(branch_n[-1].play_notes, branch_n[-1].bars) + else: + if len(curr_bar_list) > 1: + curr_bar_list[-2].branch_params = branch_params + elif len(curr_bar_list) > 0: + curr_bar_list[-1].branch_params = branch_params + + if branch_m and len(branch_m[-1].bars) > 1: + branch_m[-1].bars[-2].branch_params = branch_params + elif branch_m and len(branch_m[-1].bars) > 0: + branch_m[-1].bars[-1].branch_params = branch_params + if branch_e and len(branch_e[-1].bars) > 1: + branch_e[-1].bars[-2].branch_params = branch_params + elif branch_e and len(branch_e[-1].bars) > 0: + branch_e[-1].bars[-1].branch_params = branch_params + if branch_n and len(branch_n[-1].bars) > 1: + branch_n[-1].bars[-2].branch_params = branch_params + elif branch_n and len(branch_n[-1].bars) > 0: + branch_n[-1].bars[-1].branch_params = branch_params + if branch_m and len(branch_m[-1].bars) > 0: + branch_m[-1].bars[-1].branch_params = branch_params + continue + elif part.startswith('#BRANCHEND'): + curr_note_list = master_notes.play_notes + curr_draw_list = master_notes.draw_notes + curr_bar_list = master_notes.bars continue if part == '#M': - skip_branch = False + branch_m.append(NoteList()) + curr_note_list = branch_m[-1].play_notes + curr_draw_list = branch_m[-1].draw_notes + curr_bar_list = branch_m[-1].bars + self.current_ms = start_branch_ms + bpm = start_branch_bpm + time_signature = start_branch_time_sig + x_scroll_modifier = start_branch_x_scroll + y_scroll_modifier = start_branch_y_scroll + barline_display = start_branch_barline + gogo_time = start_branch_gogo + count = branch_balloon_count + is_branching = True continue - if skip_branch: + elif part == '#E': + branch_e.append(NoteList()) + curr_note_list = branch_e[-1].play_notes + curr_draw_list = branch_e[-1].draw_notes + curr_bar_list = branch_e[-1].bars + self.current_ms = start_branch_ms + bpm = start_branch_bpm + time_signature = start_branch_time_sig + x_scroll_modifier = start_branch_x_scroll + y_scroll_modifier = start_branch_y_scroll + barline_display = start_branch_barline + gogo_time = start_branch_gogo + count = branch_balloon_count + is_branching = True + continue + elif part == '#N': + branch_n.append(NoteList()) + curr_note_list = branch_n[-1].play_notes + curr_draw_list = branch_n[-1].draw_notes + curr_bar_list = branch_n[-1].bars + self.current_ms = start_branch_ms + bpm = start_branch_bpm + time_signature = start_branch_time_sig + x_scroll_modifier = start_branch_x_scroll + y_scroll_modifier = start_branch_y_scroll + barline_display = start_branch_barline + gogo_time = start_branch_gogo + count = branch_balloon_count + is_branching = True continue if '#LYRIC' in part: continue @@ -445,74 +587,15 @@ class TJAParser: time_signature = float(part[9:divisor]) / float(part[divisor+1:]) continue elif '#SCROLL' in part: - # Extract the value after '#SCROLL ' - scroll_value = part[7:].strip() # Remove '#SCROLL' and whitespace - - # Initialize default values - x_scroll_modifier = 0 - y_scroll_modifier = 0 - - # Handle empty value - if not scroll_value: - continue - - # Check if it's a complex number (contains 'i') + scroll_value = part[7:] if 'i' in scroll_value: - # Handle different imaginary number formats - if scroll_value == 'i': - x_scroll_modifier = 0 - y_scroll_modifier = 1 - elif scroll_value == '-i': - x_scroll_modifier = 0 - y_scroll_modifier = -1 - elif scroll_value.endswith('i') or scroll_value.endswith('.i'): - # Remove the 'i' or '.i' suffix - if scroll_value.endswith('.i'): - complex_part = scroll_value[:-2] - else: - complex_part = scroll_value[:-1] - - # Look for + or - that separates real and imaginary parts - # Find the rightmost + or - (excluding position 0 for negative numbers) - plus_pos = complex_part.rfind('+') - minus_pos = complex_part.rfind('-') - - separator_pos = -1 - if plus_pos > 0: # Ignore + at position 0 - separator_pos = plus_pos - if minus_pos > 0 and minus_pos > separator_pos: # Ignore - at position 0 - separator_pos = minus_pos - - if separator_pos > 0: - # Complex number like '1+i', '3+4i', '2-5i', '-1+2i', etc. - real_part = complex_part[:separator_pos] - imag_part = complex_part[separator_pos:] - - x_scroll_modifier = float(real_part) if real_part else 0 - - # Handle imaginary part - if imag_part == '+' or imag_part == '': - y_scroll_modifier = 1 - elif imag_part == '-': - y_scroll_modifier = -1 - else: - y_scroll_modifier = float(imag_part) - else: - # Pure imaginary like '5i', '-3i', '2.5i' - if complex_part == '' or complex_part == '+': - y_scroll_modifier = 1 - elif complex_part == '-': - y_scroll_modifier = -1 - else: - y_scroll_modifier = float(complex_part) - x_scroll_modifier = 0 - else: - # 'i' is somewhere in the middle - invalid format - continue + normalized = scroll_value.replace('.i', 'j').replace('i', 'j') + c = complex(normalized) + x_scroll_modifier = c.real + y_scroll_modifier = c.imag else: - # Pure real number x_scroll_modifier = float(scroll_value) - y_scroll_modifier = 0 + y_scroll_modifier = 0.0 continue elif '#BPMCHANGE' in part: bpm = float(part[11:]) @@ -555,7 +638,11 @@ class TJAParser: if barline_added: bar_line.display = False - bisect.insort(bar_list, bar_line, key=lambda x: x.load_ms) + if is_branching: + bar_line.is_branch_start = True + is_branching = False + + bisect.insort(curr_bar_list, bar_line, key=lambda x: x.load_ms) barline_added = True #Empty bar is still a bar, otherwise start increment @@ -571,7 +658,7 @@ class TJAParser: if item == '0' or (not item.isdigit()): self.current_ms += increment continue - if item == '9' and play_note_list and play_note_list[-1].type == 9: + if item == '9' and curr_note_list and curr_note_list[-1].type == 9: self.current_ms += increment continue note = Note() @@ -600,33 +687,29 @@ class TJAParser: note = Balloon(note) note.count = 1 if not balloon else balloon.pop(0) elif item == '8': - new_pixels_per_ms = play_note_list[-1].pixels_per_frame_x / (1000 / 60) + new_pixels_per_ms = curr_note_list[-1].pixels_per_frame_x / (1000 / 60) if new_pixels_per_ms == 0: note.load_ms = note.hit_ms else: note.load_ms = note.hit_ms - (self.distance / new_pixels_per_ms) - note.pixels_per_frame_x = play_note_list[-1].pixels_per_frame_x + note.pixels_per_frame_x = curr_note_list[-1].pixels_per_frame_x self.current_ms += increment - play_note_list.append(note) - bisect.insort(draw_note_list, note, key=lambda x: x.load_ms) - self.get_moji(play_note_list, ms_per_measure) + curr_note_list.append(note) + bisect.insort(curr_draw_list, note, key=lambda x: x.load_ms) + self.get_moji(curr_note_list, ms_per_measure) index += 1 - if len(play_note_list) > 3: - if isinstance(play_note_list[-2], Drumroll) and play_note_list[-1].type != 8: - print(self.file_path, diff) - print(bar) - continue - raise Exception(f"{play_note_list[-2]}") + if hasattr(curr_bar_list[-1], 'branch_params'): + print(curr_note_list[-1]) # https://stackoverflow.com/questions/72899/how-to-sort-a-list-of-dictionaries-by-a-value-of-the-dictionary-in-python # Sorting by load_ms is necessary for drawing, as some notes appear on the # screen slower regardless of when they reach the judge circle # Bars can be sorted like this because they don't need hit detection - return deque(play_note_list), deque(draw_note_list), deque(bar_list) + return master_notes, branch_m, branch_e, branch_n - def hash_note_data(self, play_notes: deque[Note | Drumroll | Balloon], bars: deque[Note]): + def hash_note_data(self, notes: NoteList): n = hashlib.sha256() - list1 = list(play_notes) - list2 = list(bars) + list1 = notes.play_notes + list2 = notes.bars merged: list[Note | Drumroll | Balloon] = [] i = 0 j = 0 @@ -644,46 +727,47 @@ class TJAParser: return n.hexdigest() -def modifier_speed(notes: deque[Note | Balloon | Drumroll], bars, value: float): - notes = notes.copy() - for note in notes: +def modifier_speed(notes: NoteList, value: float): + modded_notes = notes.draw_notes.copy() + modded_bars = notes.bars.copy() + for note in modded_notes: note.pixels_per_frame_x *= value note.load_ms = note.hit_ms - (866 / get_pixels_per_ms(note.pixels_per_frame_x)) - for bar in bars: + for bar in modded_bars: bar.pixels_per_frame_x *= value bar.load_ms = bar.hit_ms - (866 / get_pixels_per_ms(bar.pixels_per_frame_x)) - return notes, bars + return modded_notes, modded_bars -def modifier_display(notes: deque[Note | Balloon | Drumroll]): - notes = notes.copy() - for note in notes: +def modifier_display(notes: NoteList): + modded_notes = notes.draw_notes.copy() + for note in modded_notes: note.display = False - return notes + return modded_notes -def modifier_inverse(notes: deque[Note | Balloon | Drumroll]): - notes = notes.copy() +def modifier_inverse(notes: NoteList): + modded_notes = notes.play_notes.copy() type_mapping = {1: 2, 2: 1, 3: 4, 4: 3} - for note in notes: + for note in modded_notes: if note.type in type_mapping: note.type = type_mapping[note.type] - return notes + return modded_notes -def modifier_random(notes: deque[Note | Balloon | Drumroll], value: int): +def modifier_random(notes: NoteList, value: int): #value: 1 == kimagure, 2 == detarame - notes = notes.copy() - percentage = int(len(notes) / 5) * value - selected_notes = random.sample(range(len(notes)), percentage) + modded_notes = notes.play_notes.copy() + percentage = int(len(modded_notes) / 5) * value + selected_notes = random.sample(range(len(modded_notes)), percentage) type_mapping = {1: 2, 2: 1, 3: 4, 4: 3} for i in selected_notes: - if notes[i].type in type_mapping: - notes[i].type = type_mapping[notes[i].type] - return notes + if modded_notes[i].type in type_mapping: + modded_notes[i].type = type_mapping[modded_notes[i].type] + return modded_notes -def apply_modifiers(notes: deque[Note | Balloon | Drumroll], draw_notes: deque[Note | Balloon | Drumroll], bars: deque[Note]): +def apply_modifiers(notes: NoteList): if global_data.modifiers.display: - draw_notes = modifier_display(draw_notes) + draw_notes = modifier_display(notes) if global_data.modifiers.inverse: - notes = modifier_inverse(notes) - notes = modifier_random(notes, global_data.modifiers.random) - draw_notes, bars = modifier_speed(draw_notes, bars, global_data.modifiers.speed) - return notes, draw_notes, bars + play_notes = modifier_inverse(notes) + play_notes = modifier_random(notes, global_data.modifiers.random) + draw_notes, bars = modifier_speed(notes, global_data.modifiers.speed) + return deque(play_notes), deque(draw_notes), deque(bars) diff --git a/scenes/devtest.py b/scenes/devtest.py index d189758..0e71c9d 100644 --- a/scenes/devtest.py +++ b/scenes/devtest.py @@ -2,7 +2,7 @@ import pyray as ray from libs.utils import get_current_ms from libs.texture import tex -from scenes.game import ComboAnnounce +from scenes.game import BranchIndicator class DevScreen: @@ -16,7 +16,7 @@ class DevScreen: if not self.screen_init: self.screen_init = True tex.load_screen_textures('game') - self.obj = ComboAnnounce(0, get_current_ms()) + self.obj = BranchIndicator() def on_screen_end(self, next_screen: str): self.screen_init = False @@ -27,8 +27,14 @@ class DevScreen: self.obj.update(get_current_ms()) if ray.is_key_pressed(ray.KeyboardKey.KEY_ENTER): return self.on_screen_end('GAME') - elif ray.is_key_pressed(ray.KeyboardKey.KEY_SPACE): - self.obj = ComboAnnounce(100, get_current_ms()) + elif ray.is_key_pressed(ray.KeyboardKey.KEY_UP): + self.obj.level_up('master') + elif ray.is_key_pressed(ray.KeyboardKey.KEY_DOWN): + self.obj.level_down('expert') + elif ray.is_key_pressed(ray.KeyboardKey.KEY_LEFT): + self.obj.level_up('expert') + elif ray.is_key_pressed(ray.KeyboardKey.KEY_RIGHT): + self.obj.level_down('normal') def draw(self): ray.draw_rectangle(0, 0, 1280, 720, ray.GREEN) diff --git a/scenes/game.py b/scenes/game.py index 98502e8..beddc34 100644 --- a/scenes/game.py +++ b/scenes/game.py @@ -17,6 +17,7 @@ from libs.tja import ( Balloon, Drumroll, Note, + NoteList, TJAParser, apply_modifiers, calculate_base_score, @@ -127,8 +128,8 @@ class GameScreen: return with sqlite3.connect('scores.db') as con: cursor = con.cursor() - notes, _, bars = TJAParser.notes_to_position(TJAParser(self.tja.file_path), self.player_1.difficulty) - hash = self.tja.hash_note_data(notes, bars) + notes, _, _, _ = TJAParser.notes_to_position(TJAParser(self.tja.file_path), self.player_1.difficulty) + hash = self.tja.hash_note_data(notes) check_query = "SELECT score FROM Scores WHERE hash = ? LIMIT 1" cursor.execute(check_query, (hash,)) result = cursor.fetchone() @@ -231,16 +232,22 @@ class Player: self.visual_offset = global_data.config["general"]["visual_offset"] if tja is not None: - play_notes, self.draw_note_list, self.draw_bar_list = tja.notes_to_position(self.difficulty) - play_notes, self.draw_note_list, self.draw_bar_list = apply_modifiers(play_notes, self.draw_note_list, self.draw_bar_list) + notes, self.branch_m, self.branch_e, self.branch_n = tja.notes_to_position(self.difficulty) + self.play_notes, self.draw_note_list, self.draw_bar_list = apply_modifiers(notes) else: - play_notes, self.draw_note_list, self.draw_bar_list = deque(), deque(), deque() + self.play_notes, self.draw_note_list, self.draw_bar_list = deque(), deque(), deque() + notes = NoteList() - self.don_notes = deque([note for note in play_notes if note.type in {1, 3}]) - self.kat_notes = deque([note for note in play_notes if note.type in {2, 4}]) - self.other_notes = deque([note for note in play_notes if note.type not in {1, 2, 3, 4}]) - self.total_notes = len([note for note in play_notes if 0 < note.type < 5]) - self.base_score = calculate_base_score(play_notes) + self.don_notes = deque([note for note in self.play_notes if note.type in {1, 3}]) + self.kat_notes = deque([note for note in self.play_notes if note.type in {2, 4}]) + self.other_notes = deque([note for note in self.play_notes if note.type not in {1, 2, 3, 4}]) + self.total_notes = len([note for note in self.play_notes if 0 < note.type < 5]) + total_notes = notes + if self.branch_m: + for section in self.branch_m: + self.total_notes += len([note for note in section.play_notes if 0 < note.type < 5]) + total_notes += section + self.base_score = calculate_base_score(total_notes) #Note management self.current_bars: list[Note] = [] @@ -249,8 +256,12 @@ class Player: self.curr_drumroll_count = 0 self.is_balloon = False self.curr_balloon_count = 0 + self.is_branch = False + self.curr_branch_reqs = [] + self.branch_condition_count = 0 + self.branch_condition = '' self.balloon_index = 0 - self.bpm = play_notes[0].bpm if play_notes else 120 + self.bpm = self.play_notes[0].bpm if self.play_notes else 120 #Score management self.good_count = 0 @@ -275,7 +286,8 @@ class Player: self.score_counter = ScoreCounter(self.score) self.gogo_time: Optional[GogoTime] = None self.combo_announce = ComboAnnounce(self.combo, 0) - self.is_gogo_time = play_notes[0].gogo_time if play_notes else False + self.branch_indicator = BranchIndicator() if tja and tja.metadata.course_data[self.difficulty].is_branching else None + self.is_gogo_time = False plate_info = global_data.config['nameplate'] self.nameplate = Nameplate(plate_info['name'], plate_info['title'], global_data.player_num, plate_info['dan'], plate_info['gold']) self.chara = Chara2D(player_number - 1, self.bpm) @@ -292,6 +304,22 @@ class Player: self.autoplay_hit_side = 'L' self.last_subdivision = -1 + def merge_branch_section(self, branch_section: NoteList, current_ms: float): + self.play_notes.extend(branch_section.play_notes) + self.draw_note_list.extend(branch_section.draw_notes) + self.draw_bar_list.extend(branch_section.bars) + self.play_notes = deque(sorted(self.play_notes)) + self.draw_note_list = deque(sorted(self.draw_note_list, key=lambda x: x.load_ms)) + self.draw_bar_list = deque(sorted(self.draw_bar_list, key=lambda x: x.load_ms)) + timing_threshold = current_ms - Player.TIMING_BAD + total_don = [note for note in self.play_notes if note.type in {1, 3}] + total_kat = [note for note in self.play_notes if note.type in {2, 4}] + total_other = [note for note in self.play_notes if note.type not in {1, 2, 3, 4}] + + self.don_notes = deque([note for note in total_don if note.hit_ms > timing_threshold]) + self.kat_notes = deque([note for note in total_kat if note.hit_ms > timing_threshold]) + self.other_notes = deque([note for note in total_other if note.hit_ms > timing_threshold]) + def get_result_score(self): return self.score, self.good_count, self.ok_count, self.bad_count, self.max_combo, self.total_drumroll @@ -334,7 +362,53 @@ class Player: if position >= removal_threshold: bars_to_keep.append(bar) self.current_bars = bars_to_keep + if self.current_bars and hasattr(self.current_bars[-1], 'branch_params'): + self.branch_condition, e_req, m_req = self.current_bars[-1].branch_params.split(',') + delattr(self.current_bars[-1], 'branch_params') + e_req = int(e_req) + m_req = int(m_req) + if not self.is_branch: + self.is_branch = True + if self.branch_condition == 'r': + end_time = self.branch_m[0].bars[0].load_ms + end_roll = -1 + note_lists = [ + self.current_notes_draw, + self.branch_n[0].draw_notes if self.branch_n else [], + self.branch_e[0].draw_notes if self.branch_e else [], + self.branch_m[0].draw_notes if self.branch_m else [], + self.draw_note_list if self.draw_note_list else [] + ] + + end_roll = -1 + for notes in note_lists: + for i in range(len(notes)-1, -1, -1): + if notes[i].type == 8 and notes[i].hit_ms <= end_time: + end_roll = notes[i].hit_ms + break + if end_roll != -1: + break + self.curr_branch_reqs = [e_req, m_req, end_roll, 0] + elif self.branch_condition == 'p': + start_time = self.current_bars[0].hit_ms if self.current_bars else self.current_bars[-1].hit_ms + branch_start_time = self.branch_m[0].bars[0].load_ms + + note_lists = [ + self.current_notes_draw, + self.branch_n[0].draw_notes if self.branch_n else [], + self.branch_e[0].draw_notes if self.branch_e else [], + self.branch_m[0].draw_notes if self.branch_m else [], + self.draw_note_list if self.draw_note_list else [] + ] + + seen_notes = set() + for notes in note_lists: + for note in notes: + if note.type <= 4 and start_time <= note.hit_ms < branch_start_time: + seen_notes.add(note) + + self.curr_branch_reqs = [e_req, m_req, branch_start_time, len(seen_notes)] def play_note_manager(self, current_ms: float, background: Optional[Background]): if self.don_notes and self.don_notes[0].hit_ms + Player.TIMING_BAD < current_ms: self.combo = 0 @@ -343,6 +417,8 @@ class Player: self.bad_count += 1 self.gauge.add_bad() self.don_notes.popleft() + if self.is_branch and self.branch_condition == 'p': + self.branch_condition_count -= 1 if self.kat_notes and self.kat_notes[0].hit_ms + Player.TIMING_BAD < current_ms: self.combo = 0 @@ -351,6 +427,8 @@ class Player: self.bad_count += 1 self.gauge.add_bad() self.kat_notes.popleft() + if self.is_branch and self.branch_condition == 'p': + self.branch_condition_count -= 1 if not self.other_notes: return @@ -441,6 +519,8 @@ class Player: self.draw_arc_list.append(NoteArc(drum_type, current_time, 1, drum_type == 3 or drum_type == 4, False)) self.curr_drumroll_count += 1 self.total_drumroll += 1 + if self.is_branch and self.branch_condition == 'r': + self.branch_condition_count += 1 if background is not None: background.add_renda() self.score += 100 @@ -529,6 +609,8 @@ class Player: self.base_score_list.append(ScoreCounterAnimation(self.player_number, self.base_score)) self.note_correct(curr_note, current_time) self.gauge.add_good() + if self.is_branch and self.branch_condition == 'p': + self.branch_condition_count += 1 if game_screen.background is not None: game_screen.background.add_chibi(False) @@ -539,6 +621,8 @@ class Player: self.base_score_list.append(ScoreCounterAnimation(self.player_number, 10 * math.floor(self.base_score / 2 / 10))) self.note_correct(curr_note, current_time) self.gauge.add_ok() + if self.is_branch and self.branch_condition == 'p': + self.branch_condition_count += 0.5 if game_screen.background is not None: game_screen.background.add_chibi(False) @@ -640,6 +724,34 @@ class Player: audio.play_sound(game_screen.sound_kat) self.check_note(game_screen, 2, current_time) + def evaluate_branch(self, current_ms): + e_req, m_req, end_time, total_notes = self.curr_branch_reqs + if current_ms >= end_time: + self.is_branch = False + if self.branch_condition == 'p': + self.branch_condition_count = min(int((self.branch_condition_count/total_notes)*100), 100) + if self.branch_condition_count >= e_req and self.branch_condition_count < m_req: + self.merge_branch_section(self.branch_e.pop(0), current_ms) + if self.branch_indicator is not None and self.branch_indicator.difficulty != 'expert': + if self.branch_indicator.difficulty == 'master': + self.branch_indicator.level_down('expert') + else: + self.branch_indicator.level_up('expert') + self.branch_m.pop(0) + self.branch_n.pop(0) + elif self.branch_condition_count >= m_req: + self.merge_branch_section(self.branch_m.pop(0), current_ms) + if self.branch_indicator is not None and self.branch_indicator.difficulty != 'master': + self.branch_indicator.level_up('master') + self.branch_n.pop(0) + self.branch_e.pop(0) + else: + self.merge_branch_section(self.branch_n.pop(0), current_ms) + if self.branch_indicator is not None and self.branch_indicator.difficulty != 'normal': + self.branch_indicator.level_down('normal') + self.branch_m.pop(0) + self.branch_e.pop(0) + self.branch_condition_count = 0 def update(self, game_screen: GameScreen, current_time: float): self.note_manager(game_screen.current_ms, game_screen.background, current_time) @@ -671,6 +783,11 @@ class Player: self.handle_input(game_screen, current_time) self.nameplate.update(current_time) self.gauge.update(current_time) + if self.branch_indicator is not None: + self.branch_indicator.update(current_time) + + if self.is_branch: + self.evaluate_branch(game_screen.current_ms) # Get the next note from any of the three lists for BPM and gogo time updates next_note = None @@ -740,11 +857,15 @@ class Player: continue x_position = self.get_position_x(SCREEN_WIDTH, current_ms, bar.load_ms, bar.pixels_per_frame_x) y_position = self.get_position_y(current_ms, bar.load_ms, bar.pixels_per_frame_y, bar.pixels_per_frame_x) - bar_draws.append((str(bar.type), x_position+60, y_position+190)) + if hasattr(bar, 'is_branch_start'): + frame = 1 + else: + frame = 0 + bar_draws.append((str(bar.type), frame, x_position+60, y_position+190)) # Draw all bars in one batch - for bar_type, x, y in bar_draws: - tex.draw_texture('notes', bar_type, x=x, y=y) + for bar_type, frame, x, y in bar_draws: + tex.draw_texture('notes', bar_type, frame=frame, x=x, y=y) def draw_notes(self, current_ms: float, start_ms: float): if not self.current_notes_draw: @@ -807,6 +928,8 @@ class Player: # Group 1: Background and lane elements tex.draw_texture('lane', 'lane_background') + if self.branch_indicator is not None: + self.branch_indicator.draw() self.gauge.draw() if self.lane_hit_effect is not None: self.lane_hit_effect.draw() @@ -1576,6 +1699,52 @@ class ComboAnnounce: tex.draw_texture('combo', 'announce_number', frame=self.combo // 100 - 1, x=0, fade=fade) tex.draw_texture('combo', 'announce_text', x=-text_offset/2, fade=fade) +class BranchIndicator: + def __init__(self): + self.difficulty = 'normal' + self.diff_2 = self.difficulty + self.diff_down = Animation.create_move(100, total_distance=20, ease_out='quadratic') + self.diff_up = Animation.create_move(133, total_distance=70, delay=self.diff_down.duration, ease_out='quadratic') + self.diff_fade = Animation.create_fade(133, delay=self.diff_down.duration) + self.level_fade = Animation.create_fade(116, initial_opacity=0.0, final_opacity=1.0, reverse_delay=116*10) + self.level_scale = Animation.create_texture_resize(116, initial_size=1.0, final_size=1.2, reverse_delay=0) + self.direction = 1 + def update(self, current_time_ms): + self.diff_down.update(current_time_ms) + self.diff_up.update(current_time_ms) + self.diff_fade.update(current_time_ms) + self.level_fade.update(current_time_ms) + self.level_scale.update(current_time_ms) + def level_up(self, difficulty): + self.diff_2 = self.difficulty + self.difficulty = difficulty + self.diff_down.start() + self.diff_up.start() + self.diff_fade.start() + self.level_fade.start() + self.level_scale.start() + self.direction = 1 + def level_down(self, difficulty): + self.diff_2 = self.difficulty + self.difficulty = difficulty + self.diff_down.start() + self.diff_up.start() + self.diff_fade.start() + self.level_fade.start() + self.level_scale.start() + self.direction = -1 + def draw(self): + if self.difficulty == 'expert': + tex.draw_texture('branch', 'expert_bg', fade=min(0.5, 1 - self.diff_fade.attribute)) + if self.difficulty == 'master': + tex.draw_texture('branch', 'master_bg', fade=min(0.5, 1 - self.diff_fade.attribute)) + if self.direction == -1: + tex.draw_texture('branch', 'level_down', scale=self.level_scale.attribute, fade=self.level_fade.attribute, center=True) + else: + tex.draw_texture('branch', 'level_up', scale=self.level_scale.attribute, fade=self.level_fade.attribute, center=True) + tex.draw_texture('branch', self.diff_2, y=(self.diff_down.attribute - self.diff_up.attribute) * self.direction, fade=self.diff_fade.attribute) + tex.draw_texture('branch', self.difficulty, y=(self.diff_up.attribute * (self.direction*-1)) - (70*self.direction*-1), fade=1 - self.diff_fade.attribute) + class Gauge: def __init__(self, player_num: str, difficulty: int, level: int, total_notes: int): self.player_num = player_num diff --git a/scenes/song_select.py b/scenes/song_select.py index 959bd1c..e39cd10 100644 --- a/scenes/song_select.py +++ b/scenes/song_select.py @@ -838,16 +838,17 @@ class YellowBox: self.fade_in.start() def update(self, is_diff_select: bool): - self.left_out.update(get_current_ms()) - self.right_out.update(get_current_ms()) - self.center_out.update(get_current_ms()) - self.fade.update(get_current_ms()) - self.fade_in.update(get_current_ms()) - self.left_out_2.update(get_current_ms()) - self.right_out_2.update(get_current_ms()) - self.center_out_2.update(get_current_ms()) - self.top_y_out.update(get_current_ms()) - self.center_h_out.update(get_current_ms()) + 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: @@ -897,6 +898,8 @@ class YellowBox: continue for j in range(self.tja.metadata.course_data[diff].level): tex.draw_texture('yellow_box', 'star', x=(diff*60), y=(j*-17), 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*60), color=color) def _draw_tja_data_diff(self, is_ura: bool): if self.tja is None: @@ -919,6 +922,12 @@ class YellowBox: continue for j in range(self.tja.metadata.course_data[course].level): tex.draw_texture('yellow_box', 'star_ura', x=min(course, 3)*115, y=(j*-20), fade=self.fade_in.attribute) + if self.tja.metadata.course_data[course].is_branching and (get_current_ms() // 1000) % 2 == 0: + if course == 4: + name = 'branch_indicator_ura' + else: + name = 'branch_indicator_diff' + tex.draw_texture('yellow_box', name, x=min(course, 3)*115, fade=self.fade_in.attribute) def _draw_text(self, song_box): if not isinstance(self.right_out, MoveAnimation): @@ -2223,6 +2232,6 @@ class FileNavigator: print("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') + song_list.write(f'{song.hash}|{song.tja.metadata.title["en"]}|{song.tja.metadata.subtitle["en"]}\n') print("Added favorite: ", song.hash, song.tja.metadata.title['en'], song.tja.metadata.subtitle['en']) return True