From 021dcc276d9e0d5cc0e13ca75015736cbc4b8001 Mon Sep 17 00:00:00 2001 From: mc08 <118325498+splitlane@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:35:55 -0800 Subject: [PATCH] feat: support #BPMCHANGE for BMSCROLL, HBSCROLL --- libs/tja.py | 84 +++++++++++++++++++++++++++++++++++++++++++------- scenes/game.py | 38 +++++++++++++++++++++++ 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/libs/tja.py b/libs/tja.py index 4f23993..5617467 100644 --- a/libs/tja.py +++ b/libs/tja.py @@ -39,6 +39,11 @@ class NoteType(IntEnum): TAIL = 8 KUSUDAMA = 9 +class ScrollType(IntEnum): + NMSCROLL = 0 + BMSCROLL = 1 + HBSCROLL = 2 + @dataclass() class Note: """A note in a TJA file. @@ -56,6 +61,7 @@ class Note: moji (int): The text drawn below the note. is_branch_start (bool): Whether the note is the start of a branch. branch_params (str): The parameters (requirements) of the branch. + bpmchange (float): If it exists, the bpm will be multiplied by it when the note passes the judgement circle """ type: int = field(init=False) hit_ms: float = field(init=False) @@ -74,6 +80,7 @@ class Note: sudden_moving_ms: float = field(init=False) judge_pos_x: float = field(init=False) judge_pos_y: float = field(init=False) + bpmchange: float = field(init=False) def __lt__(self, other): return self.hit_ms < other.hit_ms @@ -509,6 +516,7 @@ class TJAParser: # Use enumerate for single iteration note_start = note_end = -1 target_found = False + scroll_type = ScrollType.NMSCROLL # Find the section boundaries for i, line in enumerate(self.data): @@ -521,6 +529,15 @@ class TJAParser: elif line == "#END" and note_start != -1: note_end = i break + elif '#NMSCROLL' in line: + scroll_type = ScrollType.NMSCROLL + continue + elif '#BMSCROLL' in line: + scroll_type = ScrollType.BMSCROLL + continue + elif '#HBSCROLL' in line: + scroll_type = ScrollType.HBSCROLL + continue if note_start == -1 or note_end == -1: return [] @@ -530,6 +547,14 @@ class TJAParser: bar = [] section_data = self.data[note_start:note_end] + # Prepend scroll type + if scroll_type == ScrollType.NMSCROLL: + bar.append('#NMSCROLL') + elif scroll_type == ScrollType.BMSCROLL: + bar.append('#BMSCROLL') + elif scroll_type == ScrollType.HBSCROLL: + bar.append('#HBSCROLL') + for line in section_data: if line.startswith("#"): bar.append(line) @@ -656,6 +681,8 @@ class TJAParser: is_section_start = False section_bar = None lyric = "" + scroll_type = ScrollType.NMSCROLL + bpmchange_last_bpm = bpm 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) @@ -828,25 +855,60 @@ class TJAParser: continue elif '#NMSCROLL' in part: + scroll_type = ScrollType.NMSCROLL + continue + elif '#BMSCROLL' in part: + scroll_type = ScrollType.BMSCROLL + continue + elif '#HBSCROLL' in part: + scroll_type = ScrollType.HBSCROLL continue elif '#MEASURE' in part: divisor = part.find('/') time_signature = float(part[9:divisor]) / float(part[divisor+1:]) continue elif '#SCROLL' in part: - scroll_value = part[7:] - if 'i' in scroll_value: - normalized = scroll_value.replace('.i', 'j').replace('i', 'j') - normalized = normalized.replace(',', '') - c = complex(normalized) - x_scroll_modifier = c.real - y_scroll_modifier = c.imag - else: - x_scroll_modifier = float(scroll_value) - y_scroll_modifier = 0.0 + if scroll_type != ScrollType.BMSCROLL: + scroll_value = part[7:] + if 'i' in scroll_value: + normalized = scroll_value.replace('.i', 'j').replace('i', 'j') + normalized = normalized.replace(',', '') + c = complex(normalized) + x_scroll_modifier = c.real + y_scroll_modifier = c.imag + else: + x_scroll_modifier = float(scroll_value) + y_scroll_modifier = 0.0 continue elif '#BPMCHANGE' in part: - bpm = float(part[11:]) + parsed_bpm = float(part[11:]) + if scroll_type == ScrollType.BMSCROLL or scroll_type == ScrollType.HBSCROLL: + # Do not modify bpm, it needs to be changed live by bpmchange + bpmchange = parsed_bpm / bpmchange_last_bpm + bpmchange_last_bpm = parsed_bpm + + bpmchange_bar = Note() + bpmchange_bar.pixels_per_frame_x = get_pixels_per_frame(bpm * time_signature * x_scroll_modifier, time_signature*4, self.distance) + bpmchange_bar.pixels_per_frame_y = get_pixels_per_frame(bpm * time_signature * y_scroll_modifier, time_signature*4, self.distance) + pixels_per_ms = get_pixels_per_ms(bpmchange_bar.pixels_per_frame_x) + + bpmchange_bar.hit_ms = self.current_ms + if pixels_per_ms == 0: + bpmchange_bar.load_ms = bpmchange_bar.hit_ms + else: + bpmchange_bar.load_ms = bpmchange_bar.hit_ms - (self.distance / pixels_per_ms) + bpmchange_bar.type = 0 + bpmchange_bar.display = False + bpmchange_bar.gogo_time = gogo_time + bpmchange_bar.bpm = bpm + + bpmchange_bar.bpmchange = bpmchange + + bisect.insort(curr_bar_list, bpmchange_bar, key=lambda x: x.load_ms) + + bpmchange = None + else: + bpm = parsed_bpm continue elif '#BARLINEOFF' in part: barline_display = False diff --git a/scenes/game.py b/scenes/game.py index 54f0017..1faea7b 100644 --- a/scenes/game.py +++ b/scenes/game.py @@ -6,6 +6,7 @@ import sqlite3 from collections import deque from pathlib import Path from typing import Optional +from itertools import chain import pyray as ray @@ -360,6 +361,7 @@ class Player: self.combo_display = Combo(self.combo, 0, self.is_2p) self.score_counter = ScoreCounter(self.score, self.is_2p) self.gogo_time: Optional[GogoTime] = None + self.bpmchanges: deque[BPMChange] self.combo_announce = ComboAnnounce(self.combo, 0, player_num, self.is_2p) self.branch_indicator = BranchIndicator(self.is_2p) if tja and tja.metadata.course_data[self.difficulty].is_branching else None self.ending_anim: Optional[FailAnimation | ClearAnimation | FCAnimation] = None @@ -403,6 +405,12 @@ class Player: self.kat_notes = deque([note for note in self.play_notes if note.type in {NoteType.KAT, NoteType.KAT_L}]) self.other_notes = deque([note for note in self.play_notes if note.type not in {NoteType.DON, NoteType.DON_L, NoteType.KAT, NoteType.KAT_L}]) self.total_notes = len([note for note in self.play_notes if 0 < note.type < 5]) + + self.bpmchanges = deque() + for note in self.draw_bar_list: + if hasattr(note, 'bpmchange'): + self.bpmchanges.append(BPMChange(note.hit_ms, note.bpmchange)) + total_notes = notes if self.branch_m: for section in self.branch_m: @@ -943,6 +951,28 @@ class Player: self.balloon_manager(current_time) if self.gogo_time is not None: self.gogo_time.update(current_time) + if len(self.bpmchanges) != 0: + bpmchange = self.bpmchanges[0] + bpmchange_success = bpmchange.is_ready(ms_from_start) + if bpmchange_success: + # Adjust notes + for note in chain(self.play_notes, self.current_bars, self.draw_bar_list): + note.bpm *= bpmchange.bpmchange + note.hit_ms = (note.hit_ms - bpmchange.hit_ms) / bpmchange.bpmchange + bpmchange.hit_ms + # time_diff * note.pixels_per_frame need to be the same before and after the adjustment + # that means time_diff should be divided by self.bpmchange.bpmchange + # current_ms = self.bpmchange.hit_ms + time_diff = note.load_ms - bpmchange.hit_ms + note.load_ms = time_diff / bpmchange.bpmchange + bpmchange.hit_ms + + note.pixels_per_frame_x *= bpmchange.bpmchange + note.pixels_per_frame_y *= bpmchange.bpmchange + + self.bpm *= bpmchange.bpmchange + self.bpmchanges.popleft() + # Adjust later bpmchanges too + for bpmchange_bar in self.bpmchanges: + bpmchange_bar.hit_ms = (bpmchange_bar.hit_ms - bpmchange.hit_ms) / bpmchange.bpmchange + bpmchange.hit_ms if self.lane_hit_effect is not None: self.lane_hit_effect.update(current_time) self.animation_manager(self.draw_drum_hit_list, current_time) @@ -1932,6 +1962,14 @@ class GogoTime: for i in range(5): tex.draw_texture('gogo_time', 'explosion', frame=self.explosion_anim.attribute, index=i) +class BPMChange: + """For BPM changes during HBSCROLL or BMSCROLL""" + def __init__(self, hit_ms: float, bpmchange: float): + self.hit_ms = hit_ms + self.bpmchange = bpmchange + def is_ready(self, ms_from_start: float): + return ms_from_start >= self.hit_ms + class ComboAnnounce: """Displays the combo every 100 combos""" def __init__(self, combo: int, current_time_ms: float, player_num: PlayerNum, is_2p: bool):