Merge pull request #167 from splitlane/main

Game: Add BMSCROLL, HBSCROLL
This commit is contained in:
Anthony Samms
2025-11-28 22:20:19 -05:00
committed by GitHub
3 changed files with 211 additions and 53 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ cache
dev-config.toml dev-config.toml
libaudio.so libaudio.so
latest.log latest.log
libaudio.dll
libaudio.dylib

View File

@@ -41,6 +41,11 @@ class NoteType(IntEnum):
TAIL = 8 TAIL = 8
KUSUDAMA = 9 KUSUDAMA = 9
class ScrollType(IntEnum):
NMSCROLL = 0
BMSCROLL = 1
HBSCROLL = 2
@dataclass() @dataclass()
class TimelineObject: class TimelineObject:
hit_ms: float = field(init=False) hit_ms: float = field(init=False)
@@ -57,6 +62,8 @@ class TimelineObject:
cam_rotation: float = field(init=False) cam_rotation: float = field(init=False)
bpm: float = field(init=False) bpm: float = field(init=False)
bpmchange: float = field(init=False)
delay: float = field(init=False)
''' '''
gogo_time: bool = field(init=False) gogo_time: bool = field(init=False)
branch_params: str = field(init=False) branch_params: str = field(init=False)
@@ -64,6 +71,8 @@ class TimelineObject:
is_section_marker: bool = False is_section_marker: bool = False
sudden_appear_ms: float = 0 sudden_appear_ms: float = 0
sudden_moving_ms: float = 0 sudden_moving_ms: float = 0
bpmchange (float): If it exists, the bpm will be multiplied by it when the note passes the judgement circle
delay (float): Milliseconds, if it exists, the delay will be added when the note passes the judgement circle
''' '''
def __lt__(self, other): def __lt__(self, other):
@@ -540,6 +549,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):
@@ -552,6 +562,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 []
@@ -561,6 +580,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)
@@ -775,6 +802,18 @@ class TJAParser:
is_section_start = False is_section_start = False
section_bar = None section_bar = None
lyric = "" lyric = ""
scroll_type = ScrollType.NMSCROLL
# Only used during BMSCROLL or HBSCROLL
bpmchange_last_bpm = bpm
delay_current = 0
delay_last_note_ms = self.current_ms
def add_delay_bar(hit_ms: float, delay: float):
delay_timeline = TimelineObject()
delay_timeline.hit_ms = hit_ms
delay_timeline.delay = delay
bisect.insort(curr_timeline, delay_timeline, key=lambda x: x.hit_ms)
for bar in notes: for bar in notes:
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)
@@ -1186,12 +1225,20 @@ class TJAParser:
judge_pos_y = judge_target_y judge_pos_y = judge_target_y
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:
if scroll_type != ScrollType.BMSCROLL:
scroll_value = part[7:] scroll_value = part[7:]
if 'i' in scroll_value: if 'i' in scroll_value:
normalized = scroll_value.replace('.i', 'j').replace('i', 'j') normalized = scroll_value.replace('.i', 'j').replace('i', 'j')
@@ -1204,10 +1251,20 @@ class TJAParser:
y_scroll_modifier = 0.0 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_timeline = TimelineObject()
bpmchange_timeline.hit_ms = self.current_ms
bpmchange_timeline.bpmchange = bpmchange
bisect.insort(curr_timeline, bpmchange_timeline, key=lambda x: x.hit_ms)
else:
timeline_obj = TimelineObject() timeline_obj = TimelineObject()
timeline_obj.hit_ms = self.current_ms timeline_obj.hit_ms = self.current_ms
timeline_obj.bpm = bpm timeline_obj.bpm = parsed_bpm
bisect.insort(curr_timeline, timeline_obj, key=lambda x: x.hit_ms) bisect.insort(curr_timeline, timeline_obj, key=lambda x: x.hit_ms)
continue continue
elif '#BARLINEOFF' in part: elif '#BARLINEOFF' in part:
@@ -1223,7 +1280,18 @@ class TJAParser:
gogo_time = False gogo_time = False
continue continue
elif part.startswith("#DELAY"): elif part.startswith("#DELAY"):
self.current_ms += float(part[6:]) * 1000 delay_ms = float(part[6:]) * 1000
if scroll_type == ScrollType.BMSCROLL or scroll_type == ScrollType.HBSCROLL:
if delay_ms <= 0:
# No changes if not positive
pass
else:
# Do not modify current_ms, it will be modified live
delay_current += delay_ms
# Delays will be combined between notes, and attached to previous note
else:
self.current_ms += delay_ms
continue continue
elif part.startswith("#SUDDEN"): elif part.startswith("#SUDDEN"):
parts = part.split() parts = part.split()
@@ -1282,13 +1350,22 @@ class TJAParser:
if item == '.': if item == '.':
continue continue
if item == '0' or (not item.isdigit()): if item == '0' or (not item.isdigit()):
delay_last_note_ms = self.current_ms
self.current_ms += increment self.current_ms += increment
continue continue
if item == '9' and curr_note_list and curr_note_list[-1].type == 9: if item == '9' and curr_note_list and curr_note_list[-1].type == 9:
delay_last_note_ms = self.current_ms
self.current_ms += increment self.current_ms += increment
continue continue
if delay_current != 0:
# logger.debug(delay_current)
add_delay_bar(delay_last_note_ms, delay_current)
delay_current = 0
note = Note() note = Note()
delay_last_note_ms = self.current_ms
note.hit_ms = self.current_ms note.hit_ms = self.current_ms
note.display = True note.display = True
note.pixels_per_frame_x = bar_line.pixels_per_frame_x note.pixels_per_frame_x = bar_line.pixels_per_frame_x

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,8 @@ 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.delay_start: Optional[float] = None
self.delay_end: Optional[float] = None
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
@@ -383,6 +386,57 @@ class Player:
def reset_chart(self): def reset_chart(self):
notes, self.branch_m, self.branch_e, self.branch_n = self.tja.notes_to_position(self.difficulty) notes, self.branch_m, self.branch_e, self.branch_n = self.tja.notes_to_position(self.difficulty)
self.play_notes, self.draw_note_list, self.draw_bar_list = apply_modifiers(notes, self.modifiers) self.play_notes, self.draw_note_list, self.draw_bar_list = apply_modifiers(notes, self.modifiers)
self.don_notes = deque([note for note in self.play_notes if note.type in {NoteType.DON, NoteType.DON_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.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.timeline = notes.timeline
self.timeline_index = 0 # Range: [0, len(timeline)]
self.current_bars: list[Note] = []
self.current_notes_draw: list[Note | Drumroll | Balloon] = []
self.is_drumroll = False
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 = 120
if self.timeline and hasattr(self.timeline[self.timeline_index], 'bpm'):
self.bpm = self.timeline[self.timeline_index].bpm
# Handle HBSCROLL, BMSCROLL (pre-modify hit_ms, so that notes can't be literally hit, but are still visually different) - basically it applies the transformations of #BPMCHANGE and #DELAY to hit_ms, so that notes can't be hit even if its visaulyl
for i, o in enumerate(self.timeline):
if hasattr(o, 'bpmchange'):
hit_ms = o.hit_ms
bpmchange = o.bpmchange
for note in chain(self.play_notes, self.current_bars, self.draw_bar_list):
if note.hit_ms > hit_ms:
note.hit_ms = (note.hit_ms - hit_ms) / bpmchange + hit_ms
for i2 in range(i + 1, len(self.timeline)):
o2 = self.timeline[i2]
o2.hit_ms = (o2.hit_ms - hit_ms) / bpmchange + hit_ms
elif hasattr(o, 'delay'):
hit_ms = o.hit_ms
delay = o.delay
for note in chain(self.play_notes, self.current_bars, self.draw_bar_list):
if note.hit_ms > hit_ms:
note.hit_ms += delay
for i2 in range(i + 1, len(self.timeline)):
o2 = self.timeline[i2]
o2.hit_ms += delay
# Decide end_time after all transforms have been applied
self.end_time = 0 self.end_time = 0
if self.play_notes: if self.play_notes:
self.end_time = self.play_notes[-1].hit_ms self.end_time = self.play_notes[-1].hit_ms
@@ -399,35 +453,6 @@ class Player:
if section.play_notes: if section.play_notes:
self.end_time = max(self.end_time, section.play_notes[-1].hit_ms) self.end_time = max(self.end_time, section.play_notes[-1].hit_ms)
self.don_notes = deque([note for note in self.play_notes if note.type in {NoteType.DON, NoteType.DON_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.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.timeline = notes.timeline
self.timeline_index = 0
self.current_bars: list[Note] = []
self.current_notes_draw: list[Note | Drumroll | Balloon] = []
self.is_drumroll = False
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 = 120
if self.timeline and hasattr(self.timeline[self.timeline_index], 'bpm'):
self.bpm = self.timeline[self.timeline_index].bpm
def merge_branch_section(self, branch_section: NoteList, current_ms: float): def merge_branch_section(self, branch_section: NoteList, current_ms: float):
"""Merges the branch notes into the current notes""" """Merges the branch notes into the current notes"""
self.play_notes.extend(branch_section.play_notes) self.play_notes.extend(branch_section.play_notes)
@@ -450,18 +475,24 @@ class Player:
def get_position_x(self, width: int, current_ms: float, load_ms: float, pixels_per_frame: float) -> int: def get_position_x(self, width: int, current_ms: float, load_ms: float, pixels_per_frame: float) -> int:
"""Calculates the x-coordinate of a note based on its load time and current time""" """Calculates the x-coordinate of a note based on its load time and current time"""
# Override if delay active
if self.delay_start:
current_ms = self.delay_start
time_diff = load_ms - current_ms time_diff = load_ms - current_ms
return int(width + pixels_per_frame * 0.06 * time_diff - (tex.textures["notes"]["1"].width//2)) - self.visual_offset return int(width + pixels_per_frame * 0.06 * time_diff - (tex.textures["notes"]["1"].width//2)) - self.visual_offset
def get_position_y(self, current_ms: float, load_ms: float, pixels_per_frame: float, pixels_per_frame_x) -> int: def get_position_y(self, current_ms: float, load_ms: float, pixels_per_frame: float, pixels_per_frame_x) -> int:
"""Calculates the y-coordinate of a note based on its load time and current time""" """Calculates the y-coordinate of a note based on its load time and current time"""
# Override if delay active
if self.delay_start:
current_ms = self.delay_start
time_diff = load_ms - current_ms time_diff = load_ms - current_ms
if pixels_per_frame_x == 0: if pixels_per_frame_x == 0:
return int(pixels_per_frame * 0.06 * time_diff) return int(pixels_per_frame * 0.06 * time_diff)
return int((pixels_per_frame * 0.06 * time_diff) + ((self.tja.distance * pixels_per_frame) / pixels_per_frame_x)) return int((pixels_per_frame * 0.06 * time_diff) + ((self.tja.distance * pixels_per_frame) / pixels_per_frame_x))
def handle_tjap3_extended_commands(self, current_ms: float): def handle_tjap3_extended_commands(self, current_ms: float):
if not self.timeline: if not self.timeline or self.timeline_index >= len(self.timeline):
return return
timeline_object = self.timeline[self.timeline_index] timeline_object = self.timeline[self.timeline_index]
@@ -497,27 +528,65 @@ class Player:
global_data.camera.rotation = timeline_object.cam_rotation global_data.camera.rotation = timeline_object.cam_rotation
should_advance = True should_advance = True
if should_advance and self.timeline_index < len(self.timeline) - 1: if should_advance:
self.timeline_index += 1 self.timeline_index += 1
def get_judge_position(self, current_ms: float): def get_judge_position(self, current_ms: float):
"""Get the current judgment circle position based on bar data""" """Get the current judgment circle position based on bar data"""
if not self.timeline: if not self.timeline or self.timeline_index >= len(self.timeline):
return return
timeline_object = self.timeline[self.timeline_index] timeline_object = self.timeline[self.timeline_index]
if hasattr(timeline_object, 'judge_pos_x') and timeline_object.hit_ms <= current_ms: if hasattr(timeline_object, 'judge_pos_x') and timeline_object.hit_ms <= current_ms:
self.judge_x = timeline_object.judge_pos_x * tex.screen_scale self.judge_x = timeline_object.judge_pos_x * tex.screen_scale
self.judge_y = timeline_object.judge_pos_y * tex.screen_scale self.judge_y = timeline_object.judge_pos_y * tex.screen_scale
if self.timeline_index < len(self.timeline) - 1: self.timeline_index += 1
def handle_scroll_type_commands(self, current_ms: float):
if not self.timeline or self.timeline_index >= len(self.timeline):
return
timeline_object = self.timeline[self.timeline_index]
should_advance = False
if hasattr(timeline_object, 'bpmchange') and timeline_object.hit_ms <= current_ms:
hit_ms = timeline_object.hit_ms
bpmchange = timeline_object.bpmchange
# Adjust notes (visually)
for note in chain(self.play_notes, self.current_bars, self.draw_bar_list):
# Already modified
# note.hit_ms = (note.hit_ms - hit_ms) / 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 - hit_ms
note.load_ms = time_diff / bpmchange + hit_ms
note.pixels_per_frame_x *= bpmchange
note.pixels_per_frame_y *= bpmchange
self.bpm *= bpmchange
should_advance = True
if hasattr(timeline_object, 'delay') and timeline_object.hit_ms <= current_ms:
hit_ms = timeline_object.hit_ms
delay = timeline_object.delay
if self.delay_start is not None:
logger.error('Needs fix: delay is currently active, but another delay is being activated')
else:
# Turn on delay visual
self.delay_start = hit_ms
self.delay_end = hit_ms + delay
should_advance = True
if should_advance:
self.timeline_index += 1 self.timeline_index += 1
def update_bpm(self, current_ms: float): def update_bpm(self, current_ms: float):
if not self.timeline: if not self.timeline or self.timeline_index >= len(self.timeline):
return return
timeline_object = self.timeline[self.timeline_index] timeline_object = self.timeline[self.timeline_index]
if hasattr(timeline_object, 'bpm') and timeline_object.hit_ms <= current_ms: if hasattr(timeline_object, 'bpm') and timeline_object.hit_ms <= current_ms:
self.bpm = timeline_object.bpm self.bpm = timeline_object.bpm
if self.timeline_index < len(self.timeline) - 1:
self.timeline_index += 1 self.timeline_index += 1
def animation_manager(self, animation_list: list, current_time: float): def animation_manager(self, animation_list: list, current_time: float):
@@ -992,6 +1061,16 @@ class Player:
self.animation_manager(self.draw_drum_hit_list, current_time) self.animation_manager(self.draw_drum_hit_list, current_time)
self.get_judge_position(ms_from_start) self.get_judge_position(ms_from_start)
self.handle_tjap3_extended_commands(ms_from_start) self.handle_tjap3_extended_commands(ms_from_start)
self.handle_scroll_type_commands(ms_from_start)
if self.delay_start is not None and self.delay_end is not None:
# Currently, a delay is active: notes should be frozen at ms = delay_start
# Check if it ended
if ms_from_start >= self.delay_end:
delay = self.delay_end - self.delay_start
for note in chain(self.play_notes, self.current_bars, self.draw_bar_list):
note.load_ms += delay
self.delay_start = None
self.delay_end = None
self.update_bpm(ms_from_start) self.update_bpm(ms_from_start)
# More efficient arc management # More efficient arc management