feat: support #BPMCHANGE for BMSCROLL, HBSCROLL

This commit is contained in:
mc08
2025-11-28 12:35:55 -08:00
parent 5a913f45f8
commit 021dcc276d
2 changed files with 111 additions and 11 deletions

View File

@@ -39,6 +39,11 @@ class NoteType(IntEnum):
TAIL = 8 TAIL = 8
KUSUDAMA = 9 KUSUDAMA = 9
class ScrollType(IntEnum):
NMSCROLL = 0
BMSCROLL = 1
HBSCROLL = 2
@dataclass() @dataclass()
class Note: class Note:
"""A note in a TJA file. """A note in a TJA file.
@@ -56,6 +61,7 @@ class Note:
moji (int): The text drawn below the note. moji (int): The text drawn below the note.
is_branch_start (bool): Whether the note is the start of a branch. is_branch_start (bool): Whether the note is the start of a branch.
branch_params (str): The parameters (requirements) of the 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) type: int = field(init=False)
hit_ms: float = field(init=False) hit_ms: float = field(init=False)
@@ -74,6 +80,7 @@ class Note:
sudden_moving_ms: float = field(init=False) sudden_moving_ms: float = field(init=False)
judge_pos_x: float = field(init=False) judge_pos_x: float = field(init=False)
judge_pos_y: float = field(init=False) judge_pos_y: float = field(init=False)
bpmchange: float = field(init=False)
def __lt__(self, other): def __lt__(self, other):
return self.hit_ms < other.hit_ms return self.hit_ms < other.hit_ms
@@ -509,6 +516,7 @@ class TJAParser:
# Use enumerate for single iteration # Use enumerate for single iteration
note_start = note_end = -1 note_start = note_end = -1
target_found = False target_found = False
scroll_type = ScrollType.NMSCROLL
# Find the section boundaries # Find the section boundaries
for i, line in enumerate(self.data): for i, line in enumerate(self.data):
@@ -521,6 +529,15 @@ class TJAParser:
elif line == "#END" and note_start != -1: elif line == "#END" and note_start != -1:
note_end = i note_end = i
break 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: if note_start == -1 or note_end == -1:
return [] return []
@@ -530,6 +547,14 @@ class TJAParser:
bar = [] bar = []
section_data = self.data[note_start:note_end] 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: for line in section_data:
if line.startswith("#"): if line.startswith("#"):
bar.append(line) bar.append(line)
@@ -656,6 +681,8 @@ class TJAParser:
is_section_start = False is_section_start = False
section_bar = None section_bar = None
lyric = "" lyric = ""
scroll_type = ScrollType.NMSCROLL
bpmchange_last_bpm = bpm
for bar in notes: for bar in notes:
#Length of the bar is determined by number of notes excluding commands #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) bar_length = sum(len(part) for part in bar if '#' not in part)
@@ -828,25 +855,60 @@ class TJAParser:
continue continue
elif '#NMSCROLL' in part: 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 continue
elif '#MEASURE' in part: elif '#MEASURE' in part:
divisor = part.find('/') divisor = part.find('/')
time_signature = float(part[9:divisor]) / float(part[divisor+1:]) time_signature = float(part[9:divisor]) / float(part[divisor+1:])
continue continue
elif '#SCROLL' in part: elif '#SCROLL' in part:
scroll_value = part[7:] if scroll_type != ScrollType.BMSCROLL:
if 'i' in scroll_value: scroll_value = part[7:]
normalized = scroll_value.replace('.i', 'j').replace('i', 'j') if 'i' in scroll_value:
normalized = normalized.replace(',', '') normalized = scroll_value.replace('.i', 'j').replace('i', 'j')
c = complex(normalized) normalized = normalized.replace(',', '')
x_scroll_modifier = c.real c = complex(normalized)
y_scroll_modifier = c.imag x_scroll_modifier = c.real
else: y_scroll_modifier = c.imag
x_scroll_modifier = float(scroll_value) else:
y_scroll_modifier = 0.0 x_scroll_modifier = float(scroll_value)
y_scroll_modifier = 0.0
continue continue
elif '#BPMCHANGE' in part: 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 continue
elif '#BARLINEOFF' in part: elif '#BARLINEOFF' in part:
barline_display = False barline_display = False

View File

@@ -6,6 +6,7 @@ import sqlite3
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from itertools import chain
import pyray as ray import pyray as ray
@@ -360,6 +361,7 @@ class Player:
self.combo_display = Combo(self.combo, 0, self.is_2p) self.combo_display = Combo(self.combo, 0, self.is_2p)
self.score_counter = ScoreCounter(self.score, self.is_2p) self.score_counter = ScoreCounter(self.score, self.is_2p)
self.gogo_time: Optional[GogoTime] = None self.gogo_time: Optional[GogoTime] = None
self.bpmchanges: deque[BPMChange]
self.combo_announce = ComboAnnounce(self.combo, 0, player_num, self.is_2p) 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.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 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.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.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.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 total_notes = notes
if self.branch_m: if self.branch_m:
for section in self.branch_m: for section in self.branch_m:
@@ -943,6 +951,28 @@ class Player:
self.balloon_manager(current_time) self.balloon_manager(current_time)
if self.gogo_time is not None: if self.gogo_time is not None:
self.gogo_time.update(current_time) 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: if self.lane_hit_effect is not None:
self.lane_hit_effect.update(current_time) self.lane_hit_effect.update(current_time)
self.animation_manager(self.draw_drum_hit_list, current_time) self.animation_manager(self.draw_drum_hit_list, current_time)
@@ -1932,6 +1962,14 @@ class GogoTime:
for i in range(5): for i in range(5):
tex.draw_texture('gogo_time', 'explosion', frame=self.explosion_anim.attribute, index=i) 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: class ComboAnnounce:
"""Displays the combo every 100 combos""" """Displays the combo every 100 combos"""
def __init__(self, combo: int, current_time_ms: float, player_num: PlayerNum, is_2p: bool): def __init__(self, combo: int, current_time_ms: float, player_num: PlayerNum, is_2p: bool):