Repository navigation
Expand file tree
/
Copy pathchart_visualizer.py
More file actions
1085 lines (983 loc) · 49.9 KB
/
Copy pathchart_visualizer.py
File metadata and controls
1085 lines (983 loc) · 49.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
r"""chart_visualizer.py -- ChartSight: native 3D rhythm-chart visualizer (Ursina / Panda3D).
Auto-detects the chart type and shows the right PLAYFIELD -- same window, audio,
timeline, and feedback either way:
* Beat Saber (folder has info.dat): lit 3D blocks fly at you (cut arrows, red/blue).
* StepMania / DDR (folder has a .sm): 4 directional arrows on 4 lanes, coloured by
rhythm subdivision (red 1/4, blue 1/8, green 1/12, yellow 1/16, ...) with freeze holds.
Synced to streamed audio so you can study a chart's flow/readability -- and drop
timestamped feedback -- without booting a headset or a pad.
python chart_visualizer.py "<song folder>" [Difficulty]
python chart_visualizer.py "out/Neon" Expert # a Beat Saber folder (-f beatsaber)
python chart_visualizer.py "../DDR Packs/Relentless/NPC" Medium # a StepMania .sm folder
Controls: Space / click empty space = play-pause . drag or click the bar = seek
. wheel = scrub . Diff -/+ buttons = difficulty . Speed -/+ = playback rate
. Look -/+ = scroll distance . Note button = save timestamped feedback . Esc = quit
"""
from __future__ import annotations
import argparse
import bisect
import glob
import json
import math
import os
import sys
import textwrap
from collections import Counter
from datetime import datetime
from time import perf_counter
# ----------------------------------------------------------------------------- data
CUTV = {0: (0, 1), 1: (0, -1), 2: (-1, 0), 3: (1, 0),
4: (-1, 1), 5: (1, 1), 6: (-1, -1), 7: (1, -1), 8: None} # dir on face; 8=dot
RED, BLUE, BOMB = 0, 1, 3
def load_song(folder):
info_p = None
for nm in ("info.dat", "Info.dat"):
if os.path.isfile(os.path.join(folder, nm)):
info_p = os.path.join(folder, nm)
break
if not info_p:
raise SystemExit(f"no info.dat in {folder}")
info = json.load(open(info_p, encoding="utf-8"))
bpm = float(info["_beatsPerMinute"])
audio_p = os.path.join(folder, info.get("_songFilename") or "")
if not os.path.isfile(audio_p):
cands = glob.glob(os.path.join(folder, "*.egg")) + glob.glob(os.path.join(folder, "*.ogg"))
audio_p = cands[0] if cands else ""
diffs = []
for bset in info.get("_difficultyBeatmapSets", []):
if bset.get("_beatmapCharacteristicName", "Standard") != "Standard":
continue
for b in bset.get("_difficultyBeatmaps", []):
p = os.path.join(folder, b["_beatmapFilename"])
if os.path.isfile(p):
diffs.append({"name": b["_difficulty"], "rank": b.get("_difficultyRank", 0),
"njs": b.get("_noteJumpMovementSpeed", 0), "path": p})
diffs.sort(key=lambda d: d["rank"])
return bpm, audio_p, info.get("_songName", os.path.basename(folder)), diffs
def load_notes(path, bpm):
dat = json.load(open(path, encoding="utf-8"))
out = []
for n in dat.get("_notes", []):
out.append((round(n["_time"] * 60.0 / bpm, 4), n.get("_lineIndex", 0),
n.get("_lineLayer", 0), n.get("_type", 0), n.get("_cutDirection", 8)))
out.sort(key=lambda x: x[0])
return out
def stats(notes, duration):
ts = [n[0] for n in notes if n[3] in (RED, BLUE)]
total = len(ts)
reds = sum(1 for n in notes if n[3] == RED)
blues = sum(1 for n in notes if n[3] == BLUE)
bombs = sum(1 for n in notes if n[3] == BOMB)
tc = Counter(round(t, 3) for t in ts)
doubles = sum(1 for c in tc.values() if c >= 2)
span = (ts[-1] - ts[0]) if total > 1 else (duration or 1.0)
avg = total / span if span > 0 else 0.0
peak, lo = 0, 0
for i in range(total):
while ts[i] - ts[lo] >= 1.0:
lo += 1
peak = max(peak, i - lo + 1)
return {"total": total, "reds": reds, "blues": blues, "bombs": bombs,
"doubles": doubles, "avg": avg, "peak": peak, "times": ts}
# --------------------------------------------------------------------- DDR / .sm
# The viewer auto-detects the chart type: a folder with info.dat is a Beat Saber map;
# a folder with a .sm is a StepMania/DDR song. Same window, audio, timeline, feedback --
# only the PLAYFIELD differs (BS = 3D blocks on a 4x3 grid; DDR = 4 directional arrows
# on 4 lanes, coloured by rhythm subdivision so the beat reads at a glance).
DDR_TAP, DDR_MINE = 0, 1
# dance-single column order L,D,U,R -> a CUTV arrow key (2=left,1=down,0=up,3=right).
PANEL_CUT = {0: 2, 1: 1, 2: 0, 3: 3}
# StepMania note-quantization colours (which subdivision of the measure a note lands on).
# EXACT colours of StepMania 5.1's "default" noteskin, read from its "_arrow 1x8" sprite
# pixels (the frames are indexed by quantization: 4th, 8th, 12th, 16th, 24th, 32nd, 48th, 64th).
# So the viewer matches the pad pixel-for-pixel: on-beat 4ths are the ITG red-orange, triplets
# (12ths) are GREEN, and yellow is 16ths (NOT 12ths).
QUANT_HEX = {1: "#ea2501", 2: "#ea2501", 4: "#ea2501", # 1/4 & coarser = red (on beat)
8: "#0170ea", # 1/8 = blue
3: "#58ea01", 6: "#58ea01", 12: "#58ea01", # 1/12 (triplets/3rds) = green
16: "#eac701", # 1/16 = yellow
24: "#6f01ea", # 1/24 = purple
32: "#01ea85", # 1/32 = teal
48: "#ea0164", 64: "#6d905a"} # 1/48 pink, 1/64 green
QUANT_DEFAULT = "#c8ccd8" # anything odd = grey
def _quant_hex(r, R):
"""Colour for a note at row r of R rows in a measure, by its rhythm subdivision."""
from math import gcd
den = R // gcd(r, R) if r else 1 # measure divided into `den` equal parts
return QUANT_HEX.get(den, QUANT_DEFAULT)
def _sm_helpers():
"""Import the .sm timing helpers from eval/smparse.py (kept out of the public
top-level imports so a BS-only run never needs the eval harness on the path)."""
here = os.path.dirname(os.path.abspath(__file__))
if os.path.join(here, "eval") not in sys.path:
sys.path.insert(0, os.path.join(here, "eval"))
from smparse import _split_tags, _parse_pairs, _tag_value, _elapsed # noqa
return _split_tags, _parse_pairs, _tag_value, _elapsed
def load_song_sm(folder):
"""Parse a StepMania song folder. Returns (bpm, audio_path, title, diffs) mirroring
load_song(); each diff carries its parsed DDR notes so the caller doesn't re-read."""
sm_files = sorted(glob.glob(os.path.join(folder, "*.sm"))
+ glob.glob(os.path.join(folder, "*.SM")))
if not sm_files:
raise SystemExit(f"no .sm in {folder}")
split_tags, parse_pairs, tag_value, elapsed = _sm_helpers()
text = open(sm_files[0], encoding="utf-8", errors="replace").read()
title, music, offset = os.path.basename(folder), "", 0.0
bpms, stops = [(0.0, 120.0)], []
charts = []
for name, raw in split_tags(text):
if name == "TITLE":
title = tag_value(raw) or title
elif name == "MUSIC":
music = tag_value(raw)
elif name == "OFFSET":
try:
offset = float(tag_value(raw))
except ValueError:
offset = 0.0
elif name == "BPMS":
bpms = parse_pairs(tag_value(raw)) or bpms
elif name == "STOPS":
stops = parse_pairs(tag_value(raw))
elif name == "NOTES":
try: # one malformed chart can't sink the song
c = _parse_sm_notes(raw, offset, bpms, stops, elapsed)
except Exception:
c = None
if c:
charts.append(c)
bpm = bpms[0][1] if bpms else 120.0
audio_p = os.path.join(folder, music) if music else ""
if not os.path.isfile(audio_p):
cands = (glob.glob(os.path.join(folder, "*.ogg")) + glob.glob(os.path.join(folder, "*.mp3"))
+ glob.glob(os.path.join(folder, "*.wav")))
audio_p = cands[0] if cands else ""
RANK = {"beginner": 0, "easy": 1, "medium": 2, "hard": 3, "challenge": 4, "edit": 5}
diffs = [{"name": c["difficulty"], "rank": RANK.get(c["difficulty"].lower(), 9),
"njs": c["meter"], "notes": c["notes"]} for c in charts]
diffs.sort(key=lambda d: d["rank"])
return bpm, audio_p, title, diffs
def _parse_sm_notes(raw, offset, bpms, stops, elapsed):
"""One #NOTES payload -> {difficulty, meter, notes}. notes are unified 7-tuples
(time, col, 0, kind, cut_dir, quant_hex, hold_end_time) for the DDR playfield."""
parts = raw.split(":", 5)
if len(parts) < 6:
return None
if parts[0].strip() != "dance-single":
return None
difficulty = parts[2].strip()
try:
meter = int(float(parts[3].strip()))
except ValueError:
meter = 0
import re as _re
body = _re.sub(r"//[^\n]*", "", parts[5])
notes, holds = [], {} # holds: col -> index into notes (open hold head)
for m_idx, measure in enumerate(body.split(",")):
lines = [ln.strip() for ln in measure.splitlines() if ln.strip()]
lines = [ln for ln in lines if set(ln) <= set("0123456789M")]
R = len(lines)
if R == 0:
continue
for r, line in enumerate(lines):
beat = 4.0 * m_idx + 4.0 * r / R
t = elapsed(beat, offset, bpms, stops)
qh = _quant_hex(r, R)
for col, ch in enumerate(line[:4]):
if ch in "124": # tap / hold-head / roll-head
if ch in "24":
holds[col] = len(notes)
notes.append([t, col, 0, DDR_TAP, PANEL_CUT[col], qh, None])
elif ch == "3" and col in holds: # hold/roll tail -> close its head
notes[holds.pop(col)][6] = t
elif ch == "M": # mine
notes.append([t, col, 0, DDR_MINE, PANEL_CUT[col], "#2a2c36", None])
notes.sort(key=lambda n: n[0])
return {"difficulty": difficulty, "meter": meter, "notes": [tuple(n) for n in notes]}
def stats_ddr(notes, duration):
"""DDR-side stats mirroring stats(): total onsets, jumps (>=2 same time), avg/peak NPS."""
ts = [n[0] for n in notes if n[3] == DDR_TAP]
total = len(ts)
tc = Counter(round(t, 3) for t in ts)
jumps = sum(1 for c in tc.values() if c >= 2)
distinct = sorted(tc) # NPS is on distinct rows (a jump = 1)
span = (distinct[-1] - distinct[0]) if len(distinct) > 1 else (duration or 1.0)
avg = len(distinct) / span if span > 0 else 0.0
peak, lo = 0, 0
for i in range(len(distinct)):
while distinct[i] - distinct[lo] >= 1.0:
lo += 1
peak = max(peak, i - lo + 1)
return {"total": total, "reds": 0, "blues": 0, "bombs": 0, "doubles": jumps,
"avg": avg, "peak": peak, "times": distinct}
def detect_mode(folder):
"""'bs' if the folder is a Beat Saber map, 'ddr' if a StepMania song, else None."""
if not folder or not os.path.isdir(folder):
return None
if (os.path.isfile(os.path.join(folder, "info.dat"))
or os.path.isfile(os.path.join(folder, "Info.dat"))):
return "bs"
if glob.glob(os.path.join(folder, "*.sm")) or glob.glob(os.path.join(folder, "*.SM")):
return "ddr"
return None
# ------------------------------------------------------------------------- 3D app
APP_NAME = "ChartSight" # the viewer's name (pairs with NoteSight; views BS + DDR)
VERSION = "0.1"
BUILD = datetime.fromtimestamp(os.path.getmtime(os.path.abspath(__file__))).strftime("%Y-%m-%d %H:%M")
COPYRIGHT = "(c) 2026 RelentlessOldMan"
FEEDBACK_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "feedback")
def compute_waveform(path, bins):
"""Peak-amplitude envelope over `bins` slices of the whole song, streamed block
by block (never loads the 30-min track into RAM). Normalized 0..1."""
env = [0.0] * bins
try:
import numpy as np
import soundfile as sf
total = sf.info(path).frames
per = max(1, total // bins)
with sf.SoundFile(path) as f:
for i in range(bins):
b = f.read(per, dtype="float32", always_2d=True)
if len(b) == 0:
break
env[i] = float(np.abs(b).max())
mx = max(env) or 1.0
env = [e / mx for e in env]
except Exception:
pass
return env
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".chartsight.json")
_OLD_CONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".sabersight.json")
def load_config():
for p in (CONFIG_PATH, _OLD_CONFIG): # fall back to the pre-rename config once
try:
return json.load(open(p, encoding="utf-8"))
except Exception:
continue
return {}
def save_config(d):
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(d, f)
except Exception:
pass
def _has_info(folder):
return bool(folder) and (os.path.isfile(os.path.join(folder, "info.dat"))
or os.path.isfile(os.path.join(folder, "Info.dat")))
def pick_folder(initial=None):
"""Native Win32 Open dialog via ctypes (comdlg32.GetOpenFileNameW) -- in-process,
no extra toolkit/process, no event loop to fight Panda3D. Pick info.dat / any .dat;
returns its folder."""
try:
import ctypes
from ctypes import wintypes
vp = ctypes.c_void_p
class OFN(ctypes.Structure):
_fields_ = [("lStructSize", wintypes.DWORD), ("hwndOwner", wintypes.HWND),
("hInstance", vp), ("lpstrFilter", wintypes.LPCWSTR),
("lpstrCustomFilter", wintypes.LPWSTR), ("nMaxCustFilter", wintypes.DWORD),
("nFilterIndex", wintypes.DWORD), ("lpstrFile", wintypes.LPWSTR),
("nMaxFile", wintypes.DWORD), ("lpstrFileTitle", wintypes.LPWSTR),
("nMaxFileTitle", wintypes.DWORD), ("lpstrInitialDir", wintypes.LPCWSTR),
("lpstrTitle", wintypes.LPCWSTR), ("Flags", wintypes.DWORD),
("nFileOffset", wintypes.WORD), ("nFileExtension", wintypes.WORD),
("lpstrDefExt", wintypes.LPCWSTR), ("lCustData", wintypes.LPARAM),
("lpfnHook", vp), ("lpTemplateName", wintypes.LPCWSTR),
("pvReserved", vp), ("dwReserved", wintypes.DWORD),
("FlagsEx", wintypes.DWORD)]
buf = ctypes.create_unicode_buffer(2048)
filt = ctypes.create_unicode_buffer(
"Chart (info.dat / *.sm)\0*.dat;*.sm\0Beat Saber (*.dat)\0*.dat\0"
"StepMania (*.sm)\0*.sm\0All files\0*.*\0\0")
ofn = OFN()
ofn.lStructSize = ctypes.sizeof(OFN)
ofn.lpstrFile = ctypes.cast(buf, wintypes.LPWSTR)
ofn.nMaxFile = 2048
ofn.lpstrFilter = ctypes.cast(filt, wintypes.LPCWSTR)
ofn.lpstrTitle = "Pick a chart (Beat Saber info.dat/.dat or StepMania .sm)"
ofn.lpstrInitialDir = initial or os.getcwd()
ofn.Flags = 0x00081800 # EXPLORER | FILEMUSTEXIST | PATHMUSTEXIST
if ctypes.windll.comdlg32.GetOpenFileNameW(ctypes.byref(ofn)):
p = buf.value
return os.path.dirname(p) if p and os.path.isfile(p) else None
return None
except Exception:
return None
COLW, ROWH, Y0 = 0.85, 0.85, 0.55 # column width, row height, bottom-row height
UPS = 20.0 # units/sec the notes travel toward you
NOTE = 0.5 # cube size (smaller vs the wider lanes)
def run(folder, want_diff, mode="bs"):
if mode == "ddr":
bpm, audio_p, title, diffs = load_song_sm(folder)
diff_notes = [d["notes"] for d in diffs]
else:
bpm, audio_p, title, diffs = load_song(folder)
diff_notes = [load_notes(d["path"], bpm) for d in diffs]
if not diffs:
raise SystemExit("no difficulties found")
if not audio_p or not os.path.isfile(audio_p):
raise SystemExit("no audio in the song folder")
from ursina import (Ursina, Entity, camera, color, Text, Button, InputField, window,
scene, mouse, held_keys, application, Vec2, Vec3, destroy, Mesh,
load_texture)
from ursina.shaders import lit_with_shadows_shader
# Set the window title + our icon BEFORE Ursina() creates the window, so it never
# flashes "ursina" / the default panda icon on startup.
_ico = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chartsight.ico")
try:
from panda3d.core import loadPrcFileData
loadPrcFileData("", f"window-title {APP_NAME}")
if os.path.isfile(_ico):
loadPrcFileData("", f"icon-filename {_ico.replace(chr(92), '/')}")
except Exception:
pass
app = Ursina(vsync=True)
wtitle = f"{APP_NAME} - {title}"
window.title = wtitle
window.color = color.hex("#12141a")
window.borderless = False
try:
from panda3d.core import WindowProperties, Filename # force titlebar text + icon
_wp = WindowProperties(); _wp.setTitle(wtitle)
if os.path.isfile(_ico):
_wp.setIconFilename(Filename.fromOsSpecific(_ico))
app.win.requestProperties(_wp)
except Exception:
pass
try:
application.development_mode = False # hide the entity/collider debug counters
except Exception:
pass
for attr in ("fps_counter", "entity_counter", "collider_counter",
"exit_button", "cog_menu"):
try:
getattr(window, attr).enabled = False
except Exception:
pass
ACC = color.hex("#4fd1ff"); ACC2 = color.hex("#8adcff"); GOLD = color.hex("#ffd24f")
TXT = color.hex("#e6e8ee"); DIM = color.hex("#9aa0b0")
# DDR arrow sprites: a rounded white FILL (tinted per note by rhythm colour) drawn over a
# matched, dilated OUTLINE mask (tinted ACC blue) -- both rendered at the SAME scale so the
# blue border is even all the way around, with softly rounded corners (see ddr_arrow_*.png).
from pathlib import Path as _Path
_here_tex = _Path(os.path.dirname(os.path.abspath(__file__)))
TEX_FILL = load_texture("ddr_arrow_fill", folder=_here_tex)
TEX_OUT = load_texture("ddr_arrow_outline", folder=_here_tex)
C_RED = color.hex("#e02531"); C_BLUE = color.hex("#2166e6"); C_BOMB = color.hex("#2a2c36")
E_RED = color.hex("#ff6b73"); E_BLUE = color.hex("#79abff") # brighter edge tints
PANEL = color.hex("#232734"); LINE = color.hex("#2c3040"); ARROWC = color.hex("#f2f6ff")
# bold Beat-Saber arrow (triangle head + stem), pointing +y. A SEPARATE mesh per
# note -- ursina reparents a Mesh to its entity, so one shared mesh only renders on
# the last entity (that was the "no arrows" bug).
def make_arrow():
return Mesh(
vertices=[(0, 0.55, 0), (-0.34, 0.06, 0), (0.34, 0.06, 0),
(-0.14, 0.06, 0), (0.14, 0.06, 0), (0.14, -0.5, 0), (-0.14, -0.5, 0)],
triangles=[(0, 1, 2), (3, 4, 5), (3, 5, 6)], mode="triangle")
# (DDR arrows are now textured quads -- ddr_arrow_fill/outline.png -- for even rounded outlines.)
# camera + lighting + atmosphere
camera.fov = 70
camera.position = Vec3(0, 1.62, -3.8) # close first-person, like standing in-game
camera.rotation = Vec3(6, 0, 0)
from ursina import DirectionalLight, AmbientLight, PointLight
sun = DirectionalLight(color=color.hex("#ffffff")); sun.look_at(Vec3(0.6, -1.0, 0.7))
DirectionalLight(color=color.hex("#4a5a86")).look_at(Vec3(-0.7, -0.3, 0.8)) # cool fill
AmbientLight(color=color.hex("#41495e"))
PointLight(parent=camera, color=ACC, position=Vec3(0, 1.6, 1))
scene.fog_color = color.hex("#12141a")
scene.fog_density = (26, 74) # linear fog: notes fade in with depth
# ---- BEAT SABER 3D track: the 4 note lanes with glowing side RAILS + speed-stripes
# flowing at you (BS grid is 4 wide x 3). All collected in bs_env -> hidden in DDR mode. --
ROAD_LEN, ROADY = 70.0, Y0 - 0.32
RAILX = 2 * COLW + 0.45
bs_env = []
bs_env.append(Entity(model="plane", scale=(60, 1, ROAD_LEN * 2.4),
position=(0, ROADY - 0.03, ROAD_LEN), color=color.hex("#0d0f15")))
bs_env.append(Entity(model="plane", scale=(2 * RAILX, 1, ROAD_LEN),
position=(0, ROADY, ROAD_LEN / 2), color=color.hex("#161a23")))
for sx in (-RAILX, RAILX):
bs_env.append(Entity(model="cube", scale=(0.06, 0.05, ROAD_LEN),
position=(sx, ROADY + 0.03, ROAD_LEN / 2), color=ACC))
for i in range(1, 4):
bs_env.append(Entity(model="cube", scale=(0.02, 0.011, ROAD_LEN),
position=((i - 2) * COLW, ROADY + 0.011, ROAD_LEN / 2),
color=color.hex("#243a48")))
stripes, SN, SGAP = [], 24, 3.2
for i in range(SN):
e = Entity(model="cube", scale=(2 * RAILX, 0.012, 0.09),
position=(0, ROADY + 0.014, i * SGAP), color=color.hex("#22364a"))
stripes.append(e); bs_env.append(e)
bs_env.append(Entity(model="wireframe_cube", color=color.hex("#2b4658"), # 4x3 hit frame
scale=(4 * COLW, 3 * ROWH, 0.02), position=(0, Y0 + ROWH, 0)))
# BS note pool: 3D cube + arrow(child) + dot(child) + road shadow + hold body.
pool = []
for _ in range(200):
cube = Entity(model="cube", color=C_BLUE, shader=lit_with_shadows_shader,
scale=NOTE, enabled=False)
edge = Entity(parent=cube, model="wireframe_cube", color=E_BLUE, scale=1.03)
arr = Entity(parent=cube, model=make_arrow(), color=ARROWC, double_sided=True,
scale=0.9, position=Vec3(0, 0, -0.56))
dot = Entity(parent=cube, model="circle", color=ARROWC, scale=0.34,
position=Vec3(0, 0, -0.56), enabled=False)
shadow = Entity(model="plane", color=color.hex("#05070c"), scale=NOTE * 1.05,
enabled=False)
shadow.alpha = 0.55
hold = Entity(model="cube", color=C_BLUE, enabled=False)
try:
hold.alpha = 0.45
except Exception:
pass
pool.append([cube, edge, arr, dot, shadow, hold])
# ---- DDR 2D PLAYFIELD (flat, on the UI overlay, like the game): 4 lanes, arrows scroll
# UP from the bottom to the receptor "timing arrows" at the top. Hidden in BS mode. The
# bottom UI (stats/timeline/toolbar/feedback) is shared -- only the playfield differs. --
DDR_TOP, DDR_BOT = 0.40, -0.26 # receptor y (top) .. field bottom (above timeline)
DDR_H = DDR_TOP - DDR_BOT
DDR_DX, DDR_ARR = 0.105, 0.07 # lane spacing, lane-geometry reference size
# The sprite quad is square (arrow fills ~60% of the canvas), so its on-screen scale is
# larger than the old mesh's -- this keeps the visible arrow about the same game-like size.
DDR_QS = DDR_ARR * 1.85
def _lane_x(col):
return (col - 1.5) * DDR_DX
def _panel_rot(col):
# The camera.ui overlay rotates clockwise for +z, so a straight atan2 of the cut vector
# (mirror-x only, NO half-turn) points the arrow the right way: L, D, U, R per column.
va = CUTV[PANEL_CUT[col]]
return Vec3(0, 0, math.degrees(math.atan2(va[0], va[1])))
ddr_field = []
_midy = (DDR_TOP + DDR_BOT) / 2
_pan = Entity(parent=camera.ui, model="quad", color=color.hex("#0c1016"),
scale=(4 * DDR_DX + 0.03, DDR_H + 0.04), position=(0, _midy, 0.06))
try:
_pan.alpha = 0.72
except Exception:
pass
ddr_field.append(_pan)
for _col in range(5): # 5 lane dividers (edges of the 4 lanes)
ddr_field.append(Entity(parent=camera.ui, model="quad", color=color.hex("#1b2430"),
scale=(0.005, DDR_H), position=(_lane_x(_col) - DDR_DX / 2, _midy, 0.05)))
for _col in range(4): # dim receptor arrows at the top (blue outline + fill)
_rot = _panel_rot(_col)
ro = Entity(parent=camera.ui, model="quad", texture=TEX_OUT, color=ACC,
scale=DDR_QS, position=(_lane_x(_col), DDR_TOP, 0.045))
ro.rotation = _rot
rc = Entity(parent=camera.ui, model="quad", texture=TEX_FILL, color=color.hex("#3a4a5e"),
scale=DDR_QS, position=(_lane_x(_col), DDR_TOP, 0.04))
rc.rotation = _rot
ddr_field.append(ro); ddr_field.append(rc)
# DDR 2D note pool per slot: ACC-blue outline quad (behind) + rhythm-coloured fill quad +
# hold bar (all on the UI overlay). Both arrow quads share one scale so the border stays even.
ddr_pool = []
for _ in range(240):
ao = Entity(parent=camera.ui, model="quad", texture=TEX_OUT, color=ACC,
scale=DDR_QS, enabled=False)
a = Entity(parent=camera.ui, model="quad", texture=TEX_FILL,
scale=DDR_QS, enabled=False)
hb = Entity(parent=camera.ui, model="quad", color=C_BLUE, enabled=False)
try:
hb.alpha = 0.55
except Exception:
pass
ddr_pool.append([ao, a, hb])
def set_playfield():
bs = (mode == "bs")
for e in bs_env:
e.enabled = bs
for e in ddr_field:
e.enabled = not bs
if bs: # clear the inactive mode's note entities
for ao, a, hb in ddr_pool:
ao.enabled = a.enabled = hb.enabled = False
else:
for it in pool:
it[0].enabled = it[4].enabled = it[5].enabled = False
set_playfield()
# ---- audio (Panda3D -> gives play-rate/speed) + smooth interpolated clock ----
from panda3d.core import Filename
def _load_music(path):
# Panda3D wants its own Unix-style Filename; a raw absolute Windows path
# (e.g. from the file picker) fails to open. fromOsSpecific converts it.
s = app.loader.loadMusic(Filename.fromOsSpecific(os.path.abspath(path)))
s.setLoop(False)
return s
snd = _load_music(audio_p)
dur = snd.length()
SPEEDS = [0.25, 0.5, 0.75, 1.0]
au = {"playing": False, "base": 0.0, "rate": 1.0, "si": 3, "est": 0.0, "lastpf": None}
def tick():
# advance a smooth monotonic clock and gently pull it toward the true audio
# time -> no jumps even at low play-rate (snd.getTime() updates coarsely).
if not au["playing"]:
return
if snd.status() != snd.PLAYING: # reached the end
au["playing"] = False
au["base"] = min(au["est"], dur)
return
pf = perf_counter()
if au["lastpf"] is None:
au["lastpf"] = pf
dt = pf - au["lastpf"]; au["lastpf"] = pf
# Clock runs PURELY on rate*dt -> perfectly smooth at any play-rate. We do NOT resync to
# snd.getTime() during playback: getTime updates coarsely / lags the audio buffer, so
# snapping to it yanked the notes (very visible on DDR's fast scroll). Seeks + _start set
# est directly, and end-of-song is caught by snd.status() above, so no resync is needed.
au["est"] = min(au["est"] + dt * au["rate"], dur)
def anow():
return au["est"] if au["playing"] else au["base"]
def _start(t):
t = max(0.0, min(t, dur - 0.05))
snd.setPlayRate(au["rate"])
snd.setTime(t)
snd.play()
au["playing"] = True; au["est"] = t; au["lastpf"] = None
def toggle():
if au["playing"]:
au["base"] = anow(); snd.stop(); au["playing"] = False
else:
if au["base"] >= dur - 0.05:
au["base"] = 0.0
_start(au["base"])
def seek(t):
t = max(0.0, min(t, dur - 0.05))
au["base"] = t; au["est"] = t
if au["playing"]:
_start(t)
# ---- UI ---- (ui space: y in [-.5,.5], x in [-aspect/2, aspect/2]) ----
# look-ahead seconds = how much chart is on screen. DDR wants a FAST scroll (arrows spread
# out, ~300bpm-style read) so it defaults short; BS keeps the longer fly-in. Look -/+ adjusts.
LOOK = {"v": 0.95 if mode == "ddr" else 2.2}
LX = -0.86 # left text anchor
RX = 0.86 # right text anchor
def panel(cx, cy, w, h):
p = Entity(parent=camera.ui, model="quad", color=PANEL,
position=(cx, cy, 0.05), scale=(w, h))
try:
p.alpha = 0.7
except Exception:
pass
return p
# left cluster: title / difficulty / stats (labels + values in aligned columns)
panel(-0.645, 0.345, 0.47, 0.30)
st_title = Text(title, parent=camera.ui, position=Vec2(LX, 0.472),
origin=(-0.5, 0.5), scale=0.95, color=TXT)
st_diff = Text("", parent=camera.ui, position=Vec2(LX, 0.427),
origin=(-0.5, 0.5), scale=0.78, color=ACC)
STY, VX = 0.375, LX + 0.18
Text(f"notes\n{'jumps' if mode == 'ddr' else 'doubles'}\navg NPS\npeak NPS\nnow NPS",
parent=camera.ui, position=Vec2(LX, STY), origin=(-0.5, 0.5), scale=0.72,
color=DIM, line_height=1.5)
st_vals = Text("", parent=camera.ui, position=Vec2(VX, STY),
origin=(-0.5, 0.5), scale=0.72, color=TXT, line_height=1.5)
# right cluster: timestamp / beat / status (panel mirrors the left for symmetry)
panel(0.645, 0.345, 0.47, 0.30)
st_time = Text("0:00.000", parent=camera.ui, position=Vec2(RX, 0.47),
origin=(0.5, 0.5), scale=1.3, color=TXT)
st_sub = Text("", parent=camera.ui, position=Vec2(RX, 0.408),
origin=(0.5, 0.5), scale=0.7, color=DIM)
st_status = Text("", parent=camera.ui, position=Vec2(RX, 0.34),
origin=(0.5, 0.5), scale=0.72, color=DIM, line_height=1.5)
st_paused = Text("PAUSED", parent=camera.ui, position=Vec2(0, 0.17),
origin=(0, 0), scale=1.4, color=GOLD)
# timeline: a dense filled song WAVEFORM (SubTap-style) with muted density behind it
BARW, BARY, BARH = 1.5, -0.32, 0.09
bar = Entity(parent=camera.ui, model="quad", color=color.hex("#0e1117"), collider="box",
scale=(BARW, BARH), position=(0, BARY, 0))
heat_cells = []
def build_heat():
for e in heat_cells:
destroy(e)
heat_cells.clear()
ts = cur["st"]["times"]
if not ts or not dur:
return
bins, bottom = 200, BARY - BARH / 2
arr = [0] * bins
for x in ts:
arr[min(bins - 1, int(x / dur * bins))] += 1
mx = max(arr) or 1
for i, val in enumerate(arr):
if not val:
continue
h = 0.008 + (val / mx) * (BARH - 0.01)
x = -BARW / 2 + (i + 0.5) / bins * BARW
heat_cells.append(Entity(parent=camera.ui, model="quad", color=color.hex("#171f29"),
scale=(BARW / bins + 0.001, h),
position=(x, bottom + h / 2, -0.02))) # muted, behind
# dense filled waveform (many edge-to-edge bars -> reads as a solid envelope)
WBINS = 420
W_DIM, W_HOT = color.hex("#3a6b82"), color.hex("#6fc2e8") # SubTap teal -> bright when played
wav = compute_waveform(audio_p, WBINS)
wav_bars = []
bw = BARW / WBINS * 1.08
for i, v in enumerate(wav):
h = max(0.004, v * (BARH - 0.006))
wav_bars.append(Entity(parent=camera.ui, model="quad", color=W_DIM, scale=(bw, h),
position=(-BARW / 2 + (i + 0.5) / WBINS * BARW, BARY, -0.035)))
wav_state = {"pi": 0}
phead = Entity(parent=camera.ui, model="quad", color=color.hex("#eaf6ff"),
scale=(0.006, BARH + 0.04), position=(-BARW / 2, BARY, -0.1))
Text("0:00", parent=camera.ui, position=Vec2(-BARW / 2, BARY - BARH / 2 - 0.016),
origin=(-0.5, 0.5), scale=0.58, color=DIM)
st_durlabel = Text(f"{int(dur // 60)}:{int(dur % 60):02d}", parent=camera.ui,
position=Vec2(BARW / 2, BARY - BARH / 2 - 0.016), origin=(0.5, 0.5),
scale=0.58, color=DIM)
cur = {"di": len(diffs) - 1, "notes": [], "times": [], "st": None, "vals4": ""}
if want_diff:
for i, d in enumerate(diffs):
if d["name"].lower() == want_diff.lower():
cur["di"] = i
def set_diff(i):
cur["di"] = i % len(diffs)
cur["notes"] = diff_notes[cur["di"]]
cur["times"] = [n[0] for n in cur["notes"]]
cur["st"] = (stats_ddr if mode == "ddr" else stats)(cur["notes"], dur)
d = diffs[cur["di"]]; s = cur["st"]
if mode == "ddr":
st_diff.text = f"{d['name']} meter {d['njs']:g} BPM {bpm:.1f}"
cur["vals4"] = f"{s['total']}\n{s['doubles']}\n{s['avg']:.2f}\n{s['peak']}"
else:
st_diff.text = f"{d['name']} NJS {d['njs']:g} BPM {bpm:.1f}"
cur["vals4"] = (f"{s['total']} (R {s['reds']}/B {s['blues']})\n"
f"{s['doubles']} ({100 * s['doubles'] / max(1, s['total']):.1f}%)\n"
f"{s['avg']:.2f}\n{s['peak']}")
st_vals.text = cur["vals4"] + "\n-"
build_heat()
save_config({"last_song": os.path.abspath(folder), "last_diff": d["name"], "mode": mode})
def refresh_status():
st_status.text = f"speed {au['rate']:g}x\nlook {LOOK['v']:.2f}s"
def set_rate(delta):
au["si"] = max(0, min(len(SPEEDS) - 1, au["si"] + delta))
au["rate"] = SPEEDS[au["si"]]
snd.setPlayRate(au["rate"])
if au["playing"]:
au["lastpf"] = None # avoid a dt spike at the rate change
refresh_status()
def setlook(delta):
LOOK["v"] = max(0.75, min(5.0, LOOK["v"] + delta))
refresh_status()
def load_song_into(newfolder, want_diff=None):
# swap the whole song IN-PLACE (no restart): audio, notes, waveform, title.
# Parse EVERYTHING into locals first and only COMMIT on success -- a malformed
# folder from the Load picker then just no-ops instead of killing the session.
nonlocal folder, bpm, audio_p, title, diffs, diff_notes, snd, dur, mode
newmode = detect_mode(newfolder) or mode
try:
if newmode == "ddr":
nbpm, naudio, ntitle, ndiffs = load_song_sm(newfolder)
ndiff_notes = [d["notes"] for d in ndiffs]
else:
nbpm, naudio, ntitle, ndiffs = load_song(newfolder)
ndiff_notes = [load_notes(d["path"], nbpm) for d in ndiffs]
if not ndiffs or not naudio or not os.path.isfile(naudio):
raise ValueError("no charts or audio in folder")
newsnd = _load_music(naudio)
except Exception as e:
print(f"[load skipped] {newfolder}: {e}")
return
try:
if snd is not None:
snd.stop()
except Exception:
pass
mode = newmode
folder, bpm, audio_p, title, diffs, diff_notes = \
newfolder, nbpm, naudio, ntitle, ndiffs, ndiff_notes
snd = newsnd
dur = snd.length()
set_playfield()
wt = f"{APP_NAME} - {title}"
window.title = wt
try:
from panda3d.core import WindowProperties
_wp = WindowProperties(); _wp.setTitle(wt); app.win.requestProperties(_wp)
except Exception:
pass
st_title.text = title
st_durlabel.text = f"{int(dur // 60)}:{int(dur % 60):02d}"
env = compute_waveform(audio_p, WBINS) # reuse the wav bars, re-shape them
for i in range(WBINS):
wav_bars[i].scale_y = max(0.004, env[i] * (BARH - 0.006))
wav_bars[i].color = W_DIM
wav_state["pi"] = 0
au.update(playing=False, base=0.0, est=0.0, lastpf=None)
nowstate["v"] = -1; pstate["v"] = None
di = len(diffs) - 1
if want_diff: # keep the same difficulty if it exists
for j, d in enumerate(diffs):
if d["name"].lower() == want_diff.lower():
di = j
set_diff(di)
def do_load():
newf = pick_folder(os.path.dirname(os.path.abspath(folder)))
if detect_mode(newf):
load_song_into(newf, diffs[cur["di"]]["name"])
# ---- feedback capture: screenshot + comment box, saved for mapper review ----
modal = {"on": False, "shot": "", "t": 0.0, "seq": 0}
modal_els = []
WRAP_COLS = 80 # chars/line -> symmetric L/R margin in the box
def _wrap_modal():
# Ursina's TextField is a code editor: it never auto-wraps, and its cursor tracks
# ONE logical line -- so a long comment marches the cursor off the box. Fix: each
# frame insert REAL newlines into the field text (wrap every paragraph, rejoin)
# and park the cursor at the end. Idempotent -- a wrapped line re-wraps to itself
# (drop_whitespace=False keeps typed spaces intact) -- so it only changes state
# when you actually type, and the cursor stays inside the box because it's now on
# a real short line.
if not modal["on"]:
return
try:
tf = m_field.text_field
out = []
for para in tf.text.split("\n"):
out.extend(textwrap.wrap(para, WRAP_COLS, drop_whitespace=False,
break_long_words=True, break_on_hyphens=False) or [""])
wrapped = "\n".join(out)
if wrapped != tf.text:
tf.text = wrapped
tf.cursor.y = len(out) - 1
tf.cursor.x = len(out[-1])
tf.render()
except Exception:
pass
def show_modal(v):
modal["on"] = v
for e in modal_els:
e.enabled = v
m_field.active = v
def capture():
if au["playing"]:
toggle() # freeze on this moment
t = anow()
d = diffs[cur["di"]]; s = cur["st"]
localn = (bisect.bisect_right(s["times"], t + 0.5) - bisect.bisect_left(s["times"], t - 0.5))
modal["seq"] += 1
safe = "".join(c for c in title if c.isalnum() or c in " -_").strip().replace(" ", "_")
os.makedirs(FEEDBACK_DIR, exist_ok=True)
shot = os.path.join(FEEDBACK_DIR,
f"{safe}_{d['name']}_{int(t * 1000):08d}ms_{modal['seq']:03d}.png")
try:
from panda3d.core import Filename
app.win.saveScreenshot(Filename.fromOsSpecific(shot))
except Exception:
shot = "(screenshot failed)"
modal["shot"] = shot; modal["t"] = t
m_info.text = (f"{title} | {d['name']} | {int(t // 60)}:{t % 60:06.3f}"
f" beat {t * bpm / 60:.1f} | {localn} nps here")
m_field.text = ""
show_modal(True)
def do_save():
t = modal["t"]; d = diffs[cur["di"]]; s = cur["st"]
localn = (bisect.bisect_right(s["times"], t + 0.5) - bisect.bisect_left(s["times"], t - 0.5))
comment = (m_field.text or "").strip()
rec = {"when": datetime.now().isoformat(timespec="seconds"), "song": title,
"difficulty": d["name"], "njs": d["njs"], "bpm": round(bpm, 2),
"time": f"{int(t // 60)}:{t % 60:06.3f}", "time_s": round(t, 3),
"beat": round(t * bpm / 60, 2), "now_nps": localn,
"avg_nps": round(s["avg"], 2), "peak_nps": s["peak"],
"comment": comment, "screenshot": os.path.basename(modal["shot"])}
os.makedirs(FEEDBACK_DIR, exist_ok=True)
with open(os.path.join(FEEDBACK_DIR, "feedback.jsonl"), "a", encoding="utf-8") as f:
f.write(json.dumps(rec) + "\n")
with open(os.path.join(FEEDBACK_DIR, "feedback.md"), "a", encoding="utf-8") as f:
f.write(f"## {rec['song']} - {rec['difficulty']} @ {rec['time']} (beat {rec['beat']})\n"
f"- {rec['when']} . {rec['now_nps']} nps here (avg {rec['avg_nps']}, "
f"peak {rec['peak_nps']})\n- shot: {rec['screenshot']}\n\n"
f"{comment or '(no comment)'}\n\n---\n\n")
show_modal(False)
m_dim = Entity(parent=camera.ui, model="quad", color=color.hex("#0a0d12"),
scale=(4, 2.2), position=Vec3(0, 0, -0.5), collider="box", enabled=False)
m_dim.alpha = 0.82
m_pan = Entity(parent=camera.ui, model="quad", color=color.hex("#232734"),
scale=(0.94, 0.46), position=Vec3(0, 0.02, -0.55), enabled=False)
m_lbl = Text("Feedback -- comment on this moment:", parent=camera.ui,
origin=(-0.5, 0.5), scale=0.9, color=TXT, enabled=False)
m_lbl.position = Vec2(-0.44, 0.21); m_lbl.z = -0.6
m_info = Text("", parent=camera.ui, origin=(-0.5, 0.5), scale=0.72, color=ACC, enabled=False)
m_info.position = Vec2(-0.44, 0.16); m_info.z = -0.6
m_field = InputField(parent=camera.ui, default_value="", max_lines=6, character_limit=500,
scale=(0.86, 0.17), enabled=False)
m_field.position = Vec2(0, 0.0); m_field.z = -0.6
try:
m_field.text_field.scale *= 0.5 # smaller text
except Exception:
pass
# NOTE: Ursina's TextField.render() rewrites text_entity.text (unwrapped) on every
# keystroke, so .wordwrap never sticks. We re-wrap the DISPLAY each frame in _update
# (see _wrap_modal) instead -- the only thing that reliably keeps text in the box.
m_save = Button(text="Save", parent=camera.ui, color=color.hex("#2b6e8c"),
highlight_color=ACC, scale=(0.16, 0.06), position=Vec3(0.15, -0.16, -0.6),
on_click=do_save, enabled=False)
m_cancel = Button(text="Cancel", parent=camera.ui, color=PANEL, highlight_color=ACC,
scale=(0.16, 0.06), position=Vec3(-0.15, -0.16, -0.6),
on_click=lambda: show_modal(False), enabled=False)
modal_els += [m_dim, m_pan, m_lbl, m_info, m_field, m_save, m_cancel]
show_modal(False)
# toolbar buttons -- evenly spaced + centered, consistent text size
bdefs = [("Load", do_load), ("Restart", lambda: seek(0)), ("Play", toggle),
("Diff -", lambda: set_diff(cur["di"] - 1)), ("Diff +", lambda: set_diff(cur["di"] + 1)),
("Speed -", lambda: set_rate(-1)), ("Speed +", lambda: set_rate(1)),
("Look -", lambda: setlook(-0.25)), ("Look +", lambda: setlook(0.25)),
("Note", capture)]
BW, BG, BTNY = 0.10, 0.013, -0.435
x0 = -(len(bdefs) * BW + (len(bdefs) - 1) * BG) / 2 + BW / 2
btns = {}
play_label = None
for i, (lab, fn) in enumerate(bdefs):
px = x0 + i * (BW + BG)
b = Button(parent=camera.ui, color=PANEL, highlight_color=ACC,
scale=(BW, 0.05), position=(px, BTNY), on_click=fn) # no built-in text
lt = Text(lab, parent=camera.ui, position=Vec2(px, BTNY), origin=(0, 0),
scale=0.55, color=TXT) # own label -> consistent size for all
lt.z = -0.2
btns[lab] = b
if lab == "Play":
play_label = lt
try:
btns["Note"].color = color.hex("#2b6e8c"); btns["Note"].highlight_color = ACC2
except Exception:
pass
refresh_status()
Text(f"{APP_NAME} v{VERSION} | built {BUILD} | {COPYRIGHT}", parent=camera.ui,
position=Vec2(-0.88, -0.487), origin=(-0.5, 0), scale=0.5, color=color.hex("#6a6f80"))
drag = {"on": False}
pstate = {"v": None} # last paused-state, to update Play/Pause label
nowstate = {"v": -1} # last now-NPS, to avoid rebuilding vals each frame
def bar_seek():
frac = (mouse.x + BARW / 2) / BARW
seek(max(0.0, min(1.0, frac)) * dur)
# ---- per-frame update + input, hosted on an entity so Ursina calls them ----
host = Entity()
def _update():
tick()
_wrap_modal()
t = anow()
notes, times = cur["notes"], cur["times"]
lo = bisect.bisect_left(times, t - 0.12)
hi = bisect.bisect_right(times, t + LOOK["v"])
k = 0
if mode == "ddr":
# DDR: flat arrows scroll UP from the bottom to the receptors at DDR_TOP.
scroll = DDR_H / max(0.1, LOOK["v"]) # ui-units/sec (Look -/+ sets speed)
for idx in range(lo, hi):
if k >= len(ddr_pool):
break
n = notes[idx]
ao, a, hb = ddr_pool[k]; k += 1
lx = _lane_x(n[1])
y = DDR_TOP - (n[0] - t) * scroll # future notes lower, rise to the top
rot = Vec3(0, 0, 0) if n[3] == DDR_MINE else _panel_rot(n[1])
a.enabled = True; a.position = (lx, y, 0.02); a.rotation = rot
ao.enabled = True; ao.position = (lx, y, 0.025); ao.rotation = rot # outline behind
if n[3] == DDR_MINE:
a.color = C_BOMB; hb.enabled = False
else:
a.color = color.hex(n[5]) # rhythm-subdivision colour
if n[6] is not None: # freeze body: head -> tail (tail lower)
ty = DDR_TOP - (n[6] - t) * scroll
top_y = min(y, DDR_TOP)
length = max(0.01, top_y - ty)
hb.enabled = True; hb.color = color.hex(n[5])
hb.scale = (DDR_ARR * 0.34, length) # thinner tail
hb.position = (lx, (top_y + ty) / 2, 0.035)
else:
hb.enabled = False
for j in range(k, len(ddr_pool)):
if ddr_pool[j][1].enabled:
ddr_pool[j][0].enabled = ddr_pool[j][1].enabled = ddr_pool[j][2].enabled = False
else:
# Beat Saber: lit 3D blocks fly at the camera.
for idx in range(lo, hi):
if k >= len(pool):
break
n = notes[idx]; z = (n[0] - t) * UPS
cube, edge, arr, dot, shadow, hold = pool[k]; k += 1
nx = (n[1] - 1.5) * COLW
cube.enabled = True
cube.position = (nx, Y0 + n[2] * ROWH, z)
shadow.enabled = True
shadow.position = (nx, ROADY + 0.014, z)
hold.enabled = False
typ = n[3]
if typ == BOMB:
cube.color = C_BOMB; edge.enabled = False
arr.enabled = False; dot.enabled = False
else:
cube.color = C_RED if typ == RED else C_BLUE