osu-beatmap-renderer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1819 @@
1
+ import { Application, Assets } from "pixi.js";
2
+ import * as PIXI from "pixi.js";
3
+ import { fetchAndParseOsu, parseOsuText } from "./parser.js";
4
+ import { HitsoundPlayer } from "./HitsoundPlayer.js";
5
+ import {
6
+ bezierCurve,
7
+ perfectCurve,
8
+ getReverseArrowAngle,
9
+ } from "./SliderMath.js";
10
+ import { DEFAULT_BUNDLED_ASSET_BASE } from "./bundledAssetUrls.js";
11
+ import { getGlobalAudioOffsetMs } from "./AudioTimingConfig.js";
12
+ import {
13
+ getFollowPosition,
14
+ getTimingPointAt,
15
+ } from "./utils.js";
16
+
17
+ const BASE_WIDTH = 1280;
18
+ const BASE_HEIGHT = 720;
19
+ const BASE_ASPECT_RATIO = BASE_WIDTH / BASE_HEIGHT;
20
+ const LOOKBACK_MS = 10000;
21
+ const LOOKAHEAD_MS = 200;
22
+ const FADE_OUT_MS = 250;
23
+ const AUDIO_SYNC_SAMPLE_INTERVAL_MS = 200;
24
+ const SLIDER_BODY_ALPHA = 0.9;
25
+ const APPROACH_CIRCLE_Z_BASE = 1_000_000_000;
26
+ const SLIDER_BODY_DARKEN_FACTOR = 0.7;
27
+ const SLIDER_RT_OVERSAMPLE = 2;
28
+
29
+ export class BeatmapEngine {
30
+ /**
31
+ * @param {HTMLElement|string} container - DOM element or element ID to mount the renderer into.
32
+ * @param {object} [options]
33
+ * @param {string} [options.baseUrl=''] - Base URL prefix for legacy updateBeatmap/updateBeatmapBypass
34
+ * token-based endpoints (e.g. '' for same-origin, 'https://example.com').
35
+ * @param {string} [options.assetBaseUrl] - Base URL for bundled skin/hitsound assets (default: GitHub raw `assets/`).
36
+ * @param {number} [options.audioOffsetMs] - Initial audio offset in ms (default: global value from AudioTimingConfig).
37
+ */
38
+ constructor(container, options = {}) {
39
+ if (typeof container === "string") {
40
+ this.containerEl = document.getElementById(container);
41
+ if (!this.containerEl) {
42
+ throw new Error(`Container element not found: ${container}`);
43
+ }
44
+ } else if (container instanceof HTMLElement) {
45
+ this.containerEl = container;
46
+ } else {
47
+ throw new Error(
48
+ "BeatmapEngine requires an HTMLElement or element ID as first argument",
49
+ );
50
+ }
51
+
52
+ this.baseUrl = options.baseUrl ?? "";
53
+ this.assetBaseUrl = options.assetBaseUrl ?? DEFAULT_BUNDLED_ASSET_BASE;
54
+
55
+ this.app = null;
56
+ this.textures = null;
57
+ this.backgroundSprite = null;
58
+ this.resizeHandler = () => this.resize();
59
+
60
+ this.beatmap = null;
61
+ this.hitObjectEntries = [];
62
+ this.hitObjectTimes = [];
63
+ this.activeHitObjects = new Set();
64
+ this.timingPointCache = new Map();
65
+ this.currentLayout = null;
66
+ this.hitsoundPlayer = null;
67
+ this.audioContext = null;
68
+ this.musicVolume = 0.3;
69
+ this.hitsoundVolume = 0.3;
70
+ this.transportStartMs = 0;
71
+ this.transportStartPerfTime = 0;
72
+ this.transportStartAudioTime = 0;
73
+ this.currentTime = 0;
74
+ this.isPlaying = false;
75
+ this.resumeOnGestureListenerAttached = false;
76
+ this.lastTickTime = null;
77
+ this.previewAudio = null;
78
+ this.beatmapDurationMs = 0;
79
+ this.previewTimeMs = 0;
80
+
81
+ this.audioOffsetMs =
82
+ options.audioOffsetMs !== undefined
83
+ ? Number(options.audioOffsetMs) || 0
84
+ : getGlobalAudioOffsetMs();
85
+
86
+ this.audioSyncOffsetMs = 0;
87
+ this.lastAudioSyncSamplePerfMs = 0;
88
+ }
89
+
90
+ async init() {
91
+ const { width, height } = this.getDisplayDimensions();
92
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
93
+ this.app = new Application();
94
+ await this.app.init({
95
+ background: "#000000",
96
+ width,
97
+ height,
98
+ antialias: true,
99
+ resolution: dpr,
100
+ autoDensity: true,
101
+ });
102
+ this.app.stage.sortableChildren = true;
103
+
104
+ this.containerEl.innerHTML = "";
105
+ this.containerEl.appendChild(this.app.canvas);
106
+
107
+ this.backgroundSprite = new PIXI.Sprite();
108
+ this.backgroundSprite.zIndex = -999999;
109
+ this.app.stage.addChild(this.backgroundSprite);
110
+
111
+ this.textures = await this.loadAssets();
112
+ this.hitsoundPlayer = new HitsoundPlayer({
113
+ hitsoundVolume: this.hitsoundVolume,
114
+ assetBaseUrl: this.assetBaseUrl,
115
+ });
116
+ await this.hitsoundPlayer.init();
117
+ this.audioContext = this.hitsoundPlayer.audioContext;
118
+ window.addEventListener("resize", this.resizeHandler);
119
+ this.app.ticker.add(() => this.updateFrame());
120
+ this.resize();
121
+ }
122
+
123
+ destroy() {
124
+ window.removeEventListener("resize", this.resizeHandler);
125
+ this.pause();
126
+ this.hitsoundPlayer?.destroy();
127
+ this.hitsoundPlayer = null;
128
+
129
+ if (this.previewAudio) {
130
+ this.previewAudio.pause();
131
+ this.previewAudio.src = "";
132
+ this.previewAudio = null;
133
+ }
134
+
135
+ if (this.app) {
136
+ this.app.ticker.stop();
137
+ this.app.destroy(true, { children: true, texture: false });
138
+ this.app = null;
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Beatmap loading
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Load a beatmap from various sources.
148
+ *
149
+ * @param {object} params
150
+ * @param {string} [params.osuText] - Raw .osu file content (preferred).
151
+ * @param {string} [params.osuUrl] - URL to fetch the .osu file from.
152
+ * @param {string} [params.audioUrl] - URL for the audio track.
153
+ * @param {string} [params.backgroundUrl] - URL for the background image.
154
+ */
155
+ async loadBeatmap({ osuText, osuUrl, audioUrl, backgroundUrl } = {}) {
156
+ this.pause();
157
+ this.setCurrentTime(0);
158
+
159
+ if (osuText) {
160
+ this.beatmap = parseOsuText(osuText);
161
+ } else if (osuUrl) {
162
+ this.beatmap = await fetchAndParseOsu(osuUrl);
163
+ } else {
164
+ throw new Error("Either osuText or osuUrl must be provided");
165
+ }
166
+
167
+ this.rebuildHitObjectCache();
168
+
169
+ if (backgroundUrl) {
170
+ await this.updateBackgroundFromUrl(backgroundUrl);
171
+ }
172
+ if (audioUrl) {
173
+ this.updateAudioTrackFromUrl(audioUrl);
174
+ }
175
+
176
+ const previewTime = Number.parseInt(
177
+ this.beatmap?.General?.PreviewTime || "0",
178
+ 10,
179
+ );
180
+ const safePreviewTime = Number.isFinite(previewTime)
181
+ ? Math.max(0, previewTime)
182
+ : 0;
183
+ this.previewTimeMs = safePreviewTime;
184
+ this.setCurrentTime(safePreviewTime);
185
+ }
186
+
187
+ /**
188
+ * Legacy method for omqWeb: load beatmap using token-based endpoints.
189
+ * Requires baseUrl to be set in constructor options if running cross-origin.
190
+ */
191
+ async updateBeatmap(
192
+ beatmapId,
193
+ imageToken = beatmapId,
194
+ audioToken = beatmapId,
195
+ ) {
196
+ this.pause();
197
+ this.setCurrentTime(0);
198
+ this.beatmap = await fetchAndParseOsu(
199
+ `${this.baseUrl}/beatmap/${beatmapId}`,
200
+ );
201
+ this.rebuildHitObjectCache();
202
+ await this.updateBackgroundFromUrl(
203
+ `${this.baseUrl}/image/${imageToken}`,
204
+ );
205
+ if (audioToken) {
206
+ this.updateAudioTrackFromUrl(`${this.baseUrl}/audio/${audioToken}`);
207
+ }
208
+ const previewTime = Number.parseInt(
209
+ this.beatmap?.General?.PreviewTime || "0",
210
+ 10,
211
+ );
212
+ const safePreviewTime = Number.isFinite(previewTime)
213
+ ? Math.max(0, previewTime)
214
+ : 0;
215
+ this.previewTimeMs = safePreviewTime;
216
+ this.setCurrentTime(safePreviewTime);
217
+ }
218
+
219
+ /**
220
+ * Legacy method for omqWeb debug: load beatmap bypassing normal endpoint.
221
+ */
222
+ async updateBeatmapBypass(beatmapId) {
223
+ this.pause();
224
+ this.setCurrentTime(0);
225
+ this.beatmap = await fetchAndParseOsu(
226
+ `${this.baseUrl}/beatmapbp/${beatmapId}`,
227
+ );
228
+ this.rebuildHitObjectCache();
229
+ const previewTime = Number.parseInt(
230
+ this.beatmap?.General?.PreviewTime || "0",
231
+ 10,
232
+ );
233
+ const safePreviewTime = Number.isFinite(previewTime)
234
+ ? Math.max(0, previewTime)
235
+ : 0;
236
+ this.previewTimeMs = safePreviewTime;
237
+ this.setCurrentTime(safePreviewTime);
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Audio
242
+ // ---------------------------------------------------------------------------
243
+
244
+ updateAudioTrackFromUrl(audioUrl) {
245
+ if (this.previewAudio) {
246
+ this.previewAudio.pause();
247
+ this.previewAudio.src = "";
248
+ }
249
+ this.previewAudio = new Audio(audioUrl);
250
+ this.previewAudio.preload = "auto";
251
+ this.previewAudio.volume = this.musicVolume;
252
+ this.previewAudio.crossOrigin = "anonymous";
253
+ }
254
+
255
+ /** @deprecated Use updateAudioTrackFromUrl */
256
+ updateAudioTrack(audioToken) {
257
+ this.updateAudioTrackFromUrl(`${this.baseUrl}/audio/${audioToken}`);
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Background
262
+ // ---------------------------------------------------------------------------
263
+
264
+ async updateBackgroundFromUrl(imageUrl) {
265
+ if (!this.app || !this.backgroundSprite) return;
266
+ if (!imageUrl) {
267
+ this.backgroundSprite.texture = PIXI.Texture.EMPTY;
268
+ this.backgroundSprite.scale.set(1);
269
+ this.backgroundSprite.x = 0;
270
+ this.backgroundSprite.y = 0;
271
+ return;
272
+ }
273
+ try {
274
+ const img = new Image();
275
+ img.crossOrigin = "anonymous";
276
+ await new Promise((resolve, reject) => {
277
+ img.onload = resolve;
278
+ img.onerror = reject;
279
+ img.src = imageUrl;
280
+ });
281
+
282
+ const texture = PIXI.Texture.from(img);
283
+ this.applyLinearTextureFiltering(texture);
284
+ const { width, height } = this.getDisplayDimensions();
285
+ const scaleX = width / texture.width;
286
+ const scaleY = height / texture.height;
287
+ const scale = Math.max(scaleX, scaleY);
288
+ this.backgroundSprite.texture = texture;
289
+ this.backgroundSprite.scale.set(scale);
290
+ this.backgroundSprite.x = (width - texture.width * scale) / 2;
291
+ this.backgroundSprite.y = (height - texture.height * scale) / 2;
292
+ } catch (_error) {
293
+ this.backgroundSprite.texture = null;
294
+ }
295
+ }
296
+
297
+ /** @deprecated Use updateBackgroundFromUrl */
298
+ async updateBackground(imageToken) {
299
+ await this.updateBackgroundFromUrl(
300
+ `${this.baseUrl}/image/${imageToken}`,
301
+ );
302
+ }
303
+
304
+ async setBackground(imageUrl) {
305
+ await this.updateBackgroundFromUrl(imageUrl);
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Playback controls
310
+ // ---------------------------------------------------------------------------
311
+
312
+ play(options = {}) {
313
+ if (this.isPlaying) {
314
+ return;
315
+ }
316
+ const enableMusic =
317
+ options.enableAudio ?? options.audio ?? options.withAudio ?? true;
318
+ const enableHitsounds = options.enableHitsounds ?? true;
319
+
320
+ this.transportStartMs = this.currentTime;
321
+ this.transportStartPerfTime = performance.now();
322
+ this.transportStartAudioTime = this.audioContext
323
+ ? this.audioContext.currentTime
324
+ : 0;
325
+ this.isPlaying = true;
326
+ this.audioSyncOffsetMs = 0;
327
+ this.lastAudioSyncSamplePerfMs = 0;
328
+ this.lastTickTime = performance.now();
329
+ const shouldResumeAudioContext = enableMusic || enableHitsounds;
330
+
331
+ if (shouldResumeAudioContext && this.audioContext?.state === "suspended") {
332
+ this.audioContext.resume().catch(() => {});
333
+
334
+ if (!this.resumeOnGestureListenerAttached) {
335
+ this.resumeOnGestureListenerAttached = true;
336
+ const resumeOnGesture = () => {
337
+ this.resumeOnGestureListenerAttached = false;
338
+ this.audioContext?.resume().catch(() => {});
339
+ if (enableMusic) {
340
+ this.previewAudio?.play().catch(() => {});
341
+ }
342
+ };
343
+
344
+ document.addEventListener("pointerdown", resumeOnGesture, {
345
+ once: true,
346
+ });
347
+ }
348
+ }
349
+
350
+ if (this.previewAudio) {
351
+ const shiftedMs = this.getPreviewAudioTimeMsForBeatmapTime(
352
+ this.currentTime,
353
+ );
354
+ this.previewAudio.currentTime = Math.max(0, shiftedMs) / 1000;
355
+ if (enableMusic) {
356
+ this.previewAudio.play().catch(() => {});
357
+ } else {
358
+ this.previewAudio.pause();
359
+ }
360
+ }
361
+
362
+ if (enableHitsounds) {
363
+ this.hitsoundPlayer?.startScheduler({
364
+ isPlaying: () => this.isPlaying,
365
+ getTransportCurrentTimeMs: () => this.getTransportCurrentTimeMs(),
366
+ });
367
+ } else {
368
+ this.hitsoundPlayer?.stopScheduler();
369
+ this.hitsoundPlayer?.clearScheduledSources();
370
+ }
371
+ }
372
+
373
+ pause() {
374
+ if (!this.isPlaying) {
375
+ return;
376
+ }
377
+ this.currentTime = this.getTransportCurrentTimeMs();
378
+ this.isPlaying = false;
379
+ this.hitsoundPlayer?.stopScheduler();
380
+ this.hitsoundPlayer?.clearScheduledSources();
381
+ if (this.previewAudio) {
382
+ this.previewAudio.pause();
383
+ }
384
+ }
385
+
386
+ getCurrentTime() {
387
+ if (this.isPlaying) {
388
+ return this.getTransportCurrentTimeMs();
389
+ }
390
+ return this.currentTime;
391
+ }
392
+
393
+ getDuration() {
394
+ if (
395
+ this.previewAudio &&
396
+ Number.isFinite(this.previewAudio.duration) &&
397
+ this.previewAudio.duration > 0
398
+ ) {
399
+ return Math.max(
400
+ this.beatmapDurationMs,
401
+ this.previewAudio.duration * 1000,
402
+ );
403
+ }
404
+ return Math.max(0, this.beatmapDurationMs);
405
+ }
406
+
407
+ setCurrentTime(ms) {
408
+ if (!Number.isFinite(ms)) {
409
+ return;
410
+ }
411
+ this.currentTime = Math.max(0, ms);
412
+ this.transportStartMs = this.currentTime;
413
+ this.transportStartPerfTime = performance.now();
414
+ this.transportStartAudioTime = this.audioContext
415
+ ? this.audioContext.currentTime
416
+ : 0;
417
+ this.lastAudioSyncSamplePerfMs = 0;
418
+ if (this.previewAudio) {
419
+ const shiftedMs = this.getPreviewAudioTimeMsForBeatmapTime(
420
+ this.currentTime,
421
+ );
422
+ this.previewAudio.currentTime = Math.max(0, shiftedMs) / 1000;
423
+
424
+ if (this.isPlaying && !this.previewAudio.paused) {
425
+ const audioDerivedVisualMs = this.getBeatmapTimeMsForPreviewAudioTime(
426
+ this.previewAudio.currentTime * 1000,
427
+ );
428
+ if (Number.isFinite(audioDerivedVisualMs)) {
429
+ this.currentTime = Math.max(0, audioDerivedVisualMs);
430
+ this.transportStartMs = this.currentTime;
431
+ this.transportStartPerfTime = performance.now();
432
+ this.transportStartAudioTime = this.audioContext
433
+ ? this.audioContext.currentTime
434
+ : 0;
435
+ this.audioSyncOffsetMs = 0;
436
+ this.lastAudioSyncSamplePerfMs = 0;
437
+ }
438
+ }
439
+ }
440
+ this.resetHitsoundPlaybackFlags();
441
+ this.hitsoundPlayer?.resetFromCurrentTime(this.currentTime);
442
+ this.resetActiveObjects();
443
+ }
444
+
445
+ seek(ms) {
446
+ this.setCurrentTime(ms);
447
+ }
448
+
449
+ seekToPreview() {
450
+ this.seek(this.previewTimeMs || 0);
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Volume
455
+ // ---------------------------------------------------------------------------
456
+
457
+ setVolume(volume) {
458
+ const v = Math.max(0, Math.min(1, Number(volume) || 0));
459
+ this.musicVolume = v;
460
+ this.hitsoundVolume = v;
461
+ if (this.previewAudio) {
462
+ this.previewAudio.volume = v;
463
+ }
464
+ this.hitsoundPlayer?.setVolume(v);
465
+ }
466
+
467
+ setMusicVolume(volume) {
468
+ const v = Math.max(0, Math.min(1, Number(volume) || 0));
469
+ this.musicVolume = v;
470
+ if (this.previewAudio) {
471
+ this.previewAudio.volume = v;
472
+ }
473
+ }
474
+
475
+ setHitsoundVolume(volume) {
476
+ const v = Math.max(0, Math.min(1, Number(volume) || 0));
477
+ this.hitsoundVolume = v;
478
+ this.hitsoundPlayer?.setVolume(v);
479
+ }
480
+
481
+ setBackgroundVisible(visible) {
482
+ if (!this.backgroundSprite) return;
483
+ this.backgroundSprite.alpha = visible ? 1 : 0;
484
+ }
485
+
486
+ setAudioOffsetMs(ms) {
487
+ const v = Number(ms) || 0;
488
+ this.audioOffsetMs = v;
489
+
490
+ this.audioSyncOffsetMs = 0;
491
+ this.lastAudioSyncSamplePerfMs = 0;
492
+ if (this.previewAudio) {
493
+ const shiftedMs = this.getPreviewAudioTimeMsForBeatmapTime(
494
+ this.currentTime,
495
+ );
496
+ this.previewAudio.currentTime = Math.max(0, shiftedMs) / 1000;
497
+ }
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Internal: hit object cache
502
+ // ---------------------------------------------------------------------------
503
+
504
+ rebuildHitObjectCache() {
505
+ this.resetActiveObjects();
506
+ this.timingPointCache.clear();
507
+ if (!this.beatmap || !this.beatmap.HitObjects) {
508
+ this.hitObjectEntries = [];
509
+ this.hitObjectTimes = [];
510
+ this.beatmapDurationMs = 0;
511
+ return;
512
+ }
513
+
514
+ this.hitObjectEntries = Object.entries(this.beatmap.HitObjects)
515
+ .map(([time, obj]) => [Number.parseInt(time, 10), obj])
516
+ .filter(([time]) => Number.isFinite(time))
517
+ .sort((a, b) => a[0] - b[0]);
518
+ this.hitObjectTimes = this.hitObjectEntries.map(([time]) => time);
519
+ for (const [time, hitObject] of this.hitObjectEntries) {
520
+ this.prepareHitObject(hitObject, time);
521
+ }
522
+ this.beatmapDurationMs = this.computeBeatmapDurationMs();
523
+ this.hitsoundPlayer?.buildHitsoundEvents(this.hitObjectEntries, {
524
+ getCachedTimingPoint: (hitTime) => this.getCachedTimingPoint(hitTime),
525
+ getSliderDurationCached: (hitObject, hitTime) =>
526
+ this.getSliderDurationCached(hitObject, hitTime),
527
+ getHitObjectEndTime: (hitObject, hitTime) =>
528
+ this.getHitObjectEndTime(hitObject, hitTime),
529
+ });
530
+ this.hitsoundPlayer?.resetFromCurrentTime(this.currentTime);
531
+ }
532
+
533
+ resetActiveObjects() {
534
+ for (const hitObject of this.activeHitObjects) {
535
+ this.removeHitObject(hitObject);
536
+ }
537
+ this.activeHitObjects.clear();
538
+ }
539
+
540
+ resetHitsoundPlaybackFlags() {
541
+ for (const [, hitObject] of this.hitObjectEntries) {
542
+ hitObject._hitsoundHeadPlayed = false;
543
+ hitObject._hitsoundSliderEndPlayed = false;
544
+ hitObject._hitsoundSliderRepeatPlayed = {};
545
+ }
546
+ }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // Internal: resize
550
+ // ---------------------------------------------------------------------------
551
+
552
+ resize() {
553
+ if (!this.app) {
554
+ return;
555
+ }
556
+ const { width, height } = this.getDisplayDimensions();
557
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
558
+ if (this.app.renderer.resolution !== dpr) {
559
+ this.app.renderer.resolution = dpr;
560
+ }
561
+ this.app.renderer.resize(width, height);
562
+ if (this.backgroundSprite?.texture) {
563
+ const scaleX = width / this.backgroundSprite.texture.width;
564
+ const scaleY = height / this.backgroundSprite.texture.height;
565
+ const scale = Math.max(scaleX, scaleY);
566
+ this.backgroundSprite.scale.set(scale);
567
+ this.backgroundSprite.x =
568
+ (width - this.backgroundSprite.texture.width * scale) / 2;
569
+ this.backgroundSprite.y =
570
+ (height - this.backgroundSprite.texture.height * scale) / 2;
571
+ }
572
+ this.rebuildHitObjectVisualsForResize();
573
+ }
574
+
575
+ rebuildHitObjectVisualsForResize() {
576
+ this.resetActiveObjects();
577
+ if (!this.hitObjectEntries?.length) {
578
+ return;
579
+ }
580
+ for (const [, hitObject] of this.hitObjectEntries) {
581
+ this.destroyHitObjectVisuals(hitObject);
582
+ }
583
+ }
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // Internal: rendering frame loop
587
+ // ---------------------------------------------------------------------------
588
+
589
+ updateFrame() {
590
+ if (!this.app || !this.beatmap || !this.beatmap.HitObjects) {
591
+ return;
592
+ }
593
+
594
+ this.currentTime = this.isPlaying
595
+ ? this.getTransportCurrentTimeMs()
596
+ : this.currentTime;
597
+
598
+ const { preempt, fadeIn } = this.calculateARValues();
599
+ this.currentLayout = this.getLayout();
600
+ const rangeStart = lowerBound(
601
+ this.hitObjectTimes,
602
+ this.currentTime - LOOKBACK_MS,
603
+ );
604
+ const rangeEnd = upperBound(
605
+ this.hitObjectTimes,
606
+ this.currentTime + preempt + LOOKAHEAD_MS,
607
+ );
608
+ const nextActive = new Set();
609
+
610
+ for (let i = rangeStart; i < rangeEnd; i++) {
611
+ const [, hitObject] = this.hitObjectEntries[i];
612
+ const hitTime = Number(hitObject.time);
613
+ const timeDiff = hitTime - this.currentTime;
614
+ const alpha = Math.min(1, Math.max(0, (preempt - timeDiff) / fadeIn));
615
+
616
+ if (hitObject.type.includes("Spinner")) {
617
+ const spinnerEndTime = this.getHitObjectEndTime(hitObject, hitTime);
618
+ if (this.currentTime >= hitTime && this.currentTime <= spinnerEndTime) {
619
+ this.addOrUpdateHitObject(hitObject, alpha, preempt, timeDiff);
620
+ nextActive.add(hitObject);
621
+ }
622
+ } else if (
623
+ timeDiff < preempt &&
624
+ !hitObject.type.includes("Slider") &&
625
+ timeDiff > -FADE_OUT_MS
626
+ ) {
627
+ this.addOrUpdateHitObject(hitObject, alpha, preempt, timeDiff);
628
+ nextActive.add(hitObject);
629
+ } else if (hitObject.type.includes("Slider")) {
630
+ const sliderDuration = this.getSliderDurationCached(hitObject, hitTime);
631
+ const sliderEndTime = hitTime + sliderDuration;
632
+ if (
633
+ timeDiff < preempt &&
634
+ this.currentTime <= sliderEndTime + FADE_OUT_MS * 2
635
+ ) {
636
+ this.addOrUpdateHitObject(hitObject, alpha, preempt, timeDiff);
637
+ nextActive.add(hitObject);
638
+ }
639
+ }
640
+ }
641
+
642
+ for (const hitObject of this.activeHitObjects) {
643
+ if (!nextActive.has(hitObject)) {
644
+ this.removeHitObject(hitObject);
645
+ }
646
+ }
647
+ this.activeHitObjects = nextActive;
648
+ }
649
+
650
+ addOrUpdateHitObject(hitObject, alpha, preempt, timeDiff) {
651
+ const { centerX, centerY, gridUnit, topLeftX, topLeftY } =
652
+ this.currentLayout || this.getLayout();
653
+ const hitTime = Number(hitObject.time);
654
+ const zIndex = -hitTime;
655
+ const approachZIndex = APPROACH_CIRCLE_Z_BASE - hitTime;
656
+
657
+ if (hitObject.type.includes("Spinner")) {
658
+ if (!hitObject.approachSprite) {
659
+ const approachSprite = new PIXI.Sprite(this.textures.texture_approach);
660
+ approachSprite.anchor.set(0.5);
661
+ approachSprite.x = centerX;
662
+ approachSprite.y = centerY;
663
+ approachSprite.width = gridUnit * 512;
664
+ approachSprite.height = gridUnit * 512;
665
+ approachSprite.zIndex = approachZIndex;
666
+ this.app.stage.addChild(approachSprite);
667
+ hitObject.approachSprite = approachSprite;
668
+ }
669
+ const spinnerEndTime = this.getHitObjectEndTime(hitObject, hitTime);
670
+ const spinnerDuration = Math.max(1, spinnerEndTime - hitTime);
671
+ const progress = Math.max(
672
+ 0,
673
+ Math.min(1, (this.currentTime - hitTime) / spinnerDuration),
674
+ );
675
+ const spinnerScale = 1 - progress;
676
+ const spinnerBaseSize = gridUnit * 512;
677
+ this.showHitObject(hitObject);
678
+ hitObject.approachSprite.alpha = 1;
679
+ hitObject.approachSprite.width = spinnerBaseSize * spinnerScale;
680
+ hitObject.approachSprite.height = spinnerBaseSize * spinnerScale;
681
+ return;
682
+ }
683
+
684
+ if (!hitObject.hitCircleSprite) {
685
+ const posX = topLeftX + Number(hitObject.x) * gridUnit;
686
+ const posY = topLeftY + Number(hitObject.y) * gridUnit;
687
+ const circleSize = this.getCircleSize(gridUnit);
688
+ const comboColor = this.getComboColor(hitObject.comboColor);
689
+
690
+ hitObject.hitCircleSprite = new PIXI.Sprite(
691
+ this.textures.texture_hitcircle,
692
+ );
693
+ hitObject.hitCircleSprite.anchor.set(0.5);
694
+ hitObject.hitCircleSprite.x = posX;
695
+ hitObject.hitCircleSprite.y = posY;
696
+ hitObject.hitCircleSprite.width = circleSize;
697
+ hitObject.hitCircleSprite.height = circleSize;
698
+ hitObject.hitCircleSprite.tint = comboColor;
699
+ hitObject.hitCircleSprite.zIndex = zIndex + 3;
700
+
701
+ hitObject.hitCircleOverlaySprite = new PIXI.Sprite(
702
+ this.textures.texture_hitcircleoverlay,
703
+ );
704
+ hitObject.hitCircleOverlaySprite.anchor.set(0.5);
705
+ hitObject.hitCircleOverlaySprite.x = posX;
706
+ hitObject.hitCircleOverlaySprite.y = posY;
707
+ hitObject.hitCircleOverlaySprite.width = circleSize;
708
+ hitObject.hitCircleOverlaySprite.height = circleSize;
709
+ hitObject.hitCircleOverlaySprite.zIndex = zIndex + 4;
710
+
711
+ hitObject.approachSprite = new PIXI.Sprite(
712
+ this.textures.texture_approach,
713
+ );
714
+ hitObject.approachSprite.anchor.set(0.5);
715
+ hitObject.approachSprite.x = posX;
716
+ hitObject.approachSprite.y = posY;
717
+ hitObject.approachSprite.tint = comboColor;
718
+ hitObject.approachSprite.zIndex = approachZIndex;
719
+
720
+ hitObject.comboText = new PIXI.Text({
721
+ text: hitObject.combo || "",
722
+ style: {
723
+ fill: 0xffffff,
724
+ },
725
+ });
726
+ hitObject.comboText.anchor.set(0.5);
727
+ hitObject.comboText.x = posX;
728
+ hitObject.comboText.y = posY;
729
+ hitObject.comboText.zIndex = zIndex + 6;
730
+ const textScale =
731
+ Math.min(
732
+ circleSize / (hitObject.comboText.width || 1),
733
+ circleSize / (hitObject.comboText.height || 1),
734
+ ) * 0.4;
735
+ hitObject.comboText.scale.set(textScale);
736
+ hitObject._comboBaseScale = textScale;
737
+
738
+ this.app.stage.addChild(hitObject.approachSprite);
739
+ this.app.stage.addChild(hitObject.hitCircleSprite);
740
+ this.app.stage.addChild(hitObject.hitCircleOverlaySprite);
741
+ this.app.stage.addChild(hitObject.comboText);
742
+
743
+ if (hitObject.type.includes("Slider")) {
744
+ this.addSlider(hitObject, posX, posY, circleSize, zIndex, comboColor);
745
+ }
746
+ }
747
+
748
+ this.showHitObject(hitObject);
749
+
750
+ const scale = 0.9 + (timeDiff / preempt) * 3;
751
+ if (hitObject.approachSprite) {
752
+ if (scale > 1) {
753
+ hitObject.approachSprite.width =
754
+ hitObject.hitCircleSprite.width * scale;
755
+ hitObject.approachSprite.height =
756
+ hitObject.hitCircleSprite.height * scale;
757
+ hitObject.approachSprite.alpha = Math.max(0, Math.min(1, alpha));
758
+ } else {
759
+ hitObject.approachSprite.alpha = 0;
760
+ }
761
+ }
762
+
763
+ if (hitObject.hitCircleSprite && !hitObject.type.includes("Slider")) {
764
+ const circleSize = this.getCircleSize(gridUnit);
765
+ if (timeDiff <= 0) {
766
+ const fadeProgress = this.getHitBurstProgress(timeDiff);
767
+ const fadeAlpha = this.getHitBurstAlpha(fadeProgress);
768
+ hitObject.hitCircleSprite.alpha = fadeAlpha;
769
+ hitObject.hitCircleOverlaySprite.alpha = fadeAlpha;
770
+ hitObject.comboText.alpha = fadeAlpha;
771
+ const grownSize = this.getHitBurstSize(circleSize, fadeProgress);
772
+ const comboScale = this.getHitBurstScale(
773
+ hitObject._comboBaseScale,
774
+ fadeProgress,
775
+ );
776
+ hitObject.hitCircleSprite.width = grownSize;
777
+ hitObject.hitCircleSprite.height = grownSize;
778
+ hitObject.hitCircleOverlaySprite.width = grownSize;
779
+ hitObject.hitCircleOverlaySprite.height = grownSize;
780
+ hitObject.comboText.scale.set(comboScale);
781
+ } else {
782
+ hitObject.hitCircleSprite.width = circleSize;
783
+ hitObject.hitCircleSprite.height = circleSize;
784
+ hitObject.hitCircleOverlaySprite.width = circleSize;
785
+ hitObject.hitCircleOverlaySprite.height = circleSize;
786
+ hitObject.hitCircleSprite.alpha = Math.max(0, Math.min(1, alpha));
787
+ hitObject.hitCircleOverlaySprite.alpha = Math.max(
788
+ 0,
789
+ Math.min(1, alpha),
790
+ );
791
+ hitObject.comboText.alpha = Math.max(0, Math.min(1, alpha));
792
+ hitObject.comboText.scale.set(hitObject._comboBaseScale || 1);
793
+ }
794
+ }
795
+ if (hitObject.sliderSprite) {
796
+ const hitTimeMs = Number(hitObject.time);
797
+ const sliderDuration = this.getSliderDurationCached(hitObject, hitTimeMs);
798
+ const sliderEndTime = hitTimeMs + sliderDuration;
799
+ const sliderRepeat = Number(hitObject.sliderInfo?.sliderRepeat || 1);
800
+ const isPreHit = this.currentTime < hitTimeMs;
801
+ const inPostSliderFade =
802
+ sliderDuration > 0 && this.currentTime > sliderEndTime;
803
+ const sliderAlpha = isPreHit ? Math.max(0.02, Math.min(1, alpha)) : 1;
804
+ const { gridUnit: sliderGridUnit } =
805
+ this.currentLayout || this.getLayout();
806
+ const sliderHeadBaseSize = this.getCircleSize(sliderGridUnit);
807
+
808
+ if (inPostSliderFade) {
809
+ const fadeProgress = Math.min(
810
+ 1,
811
+ (this.currentTime - sliderEndTime) / FADE_OUT_MS,
812
+ );
813
+ const fadeAlpha = Math.max(0, 1 - fadeProgress);
814
+ hitObject.sliderSprite.alpha = fadeAlpha;
815
+ hitObject.hitCircleSprite_sliderend.alpha = fadeAlpha;
816
+ hitObject.hitCircleOverlaySprite_sliderend.alpha = fadeAlpha;
817
+ if (hitObject.reverseSprite) {
818
+ hitObject.reverseSprite.visible =
819
+ this.hasRemainingSliderRepeatsAtEdge(
820
+ this.currentTime,
821
+ hitTimeMs,
822
+ sliderDuration,
823
+ sliderRepeat,
824
+ "end",
825
+ );
826
+ hitObject.reverseSprite.alpha = fadeAlpha;
827
+ }
828
+ if (hitObject.reverseSpriteHead) {
829
+ hitObject.reverseSpriteHead.visible =
830
+ this.currentTime >= hitTimeMs &&
831
+ this.hasRemainingSliderRepeatsAtEdge(
832
+ this.currentTime,
833
+ hitTimeMs,
834
+ sliderDuration,
835
+ sliderRepeat,
836
+ "start",
837
+ );
838
+ hitObject.reverseSpriteHead.alpha = fadeAlpha;
839
+ }
840
+ if (hitObject.followCircle) {
841
+ hitObject.followCircle.alpha = fadeAlpha * 0.7;
842
+ }
843
+ } else {
844
+ hitObject.sliderSprite.alpha = sliderAlpha;
845
+ hitObject.hitCircleSprite_sliderend.alpha = sliderAlpha;
846
+ hitObject.hitCircleOverlaySprite_sliderend.alpha = sliderAlpha;
847
+ if (hitObject.reverseSprite) {
848
+ hitObject.reverseSprite.visible =
849
+ this.hasRemainingSliderRepeatsAtEdge(
850
+ this.currentTime,
851
+ hitTimeMs,
852
+ sliderDuration,
853
+ sliderRepeat,
854
+ "end",
855
+ );
856
+ hitObject.reverseSprite.alpha = sliderAlpha;
857
+ }
858
+ if (hitObject.reverseSpriteHead) {
859
+ hitObject.reverseSpriteHead.visible =
860
+ this.currentTime >= hitTimeMs &&
861
+ this.hasRemainingSliderRepeatsAtEdge(
862
+ this.currentTime,
863
+ hitTimeMs,
864
+ sliderDuration,
865
+ sliderRepeat,
866
+ "start",
867
+ );
868
+ hitObject.reverseSpriteHead.alpha = sliderAlpha;
869
+ }
870
+ }
871
+
872
+ if (timeDiff <= 0 && -timeDiff <= FADE_OUT_MS) {
873
+ const headFadeProgress = this.getHitBurstProgress(timeDiff);
874
+ const headFadeAlpha = this.getHitBurstAlpha(headFadeProgress);
875
+ const grownSize = this.getHitBurstSize(
876
+ sliderHeadBaseSize,
877
+ headFadeProgress,
878
+ );
879
+ const comboScale = this.getHitBurstScale(
880
+ hitObject._comboBaseScale,
881
+ headFadeProgress,
882
+ );
883
+ hitObject.hitCircleSprite.alpha = headFadeAlpha;
884
+ hitObject.hitCircleOverlaySprite.alpha = headFadeAlpha;
885
+ hitObject.comboText.alpha = headFadeAlpha;
886
+ hitObject.hitCircleSprite.width = grownSize;
887
+ hitObject.hitCircleSprite.height = grownSize;
888
+ hitObject.hitCircleOverlaySprite.width = grownSize;
889
+ hitObject.hitCircleOverlaySprite.height = grownSize;
890
+ hitObject.comboText.scale.set(comboScale);
891
+ } else if (timeDiff <= 0) {
892
+ hitObject.hitCircleSprite.width = sliderHeadBaseSize;
893
+ hitObject.hitCircleSprite.height = sliderHeadBaseSize;
894
+ hitObject.hitCircleOverlaySprite.width = sliderHeadBaseSize;
895
+ hitObject.hitCircleOverlaySprite.height = sliderHeadBaseSize;
896
+ hitObject.hitCircleSprite.alpha = 0;
897
+ hitObject.hitCircleOverlaySprite.alpha = 0;
898
+ hitObject.comboText.alpha = 0;
899
+ hitObject.comboText.scale.set(hitObject._comboBaseScale || 1);
900
+ } else {
901
+ hitObject.hitCircleSprite.width = sliderHeadBaseSize;
902
+ hitObject.hitCircleSprite.height = sliderHeadBaseSize;
903
+ hitObject.hitCircleOverlaySprite.width = sliderHeadBaseSize;
904
+ hitObject.hitCircleOverlaySprite.height = sliderHeadBaseSize;
905
+ hitObject.hitCircleSprite.alpha = sliderAlpha;
906
+ hitObject.hitCircleOverlaySprite.alpha = sliderAlpha;
907
+ hitObject.comboText.alpha = sliderAlpha;
908
+ hitObject.comboText.scale.set(hitObject._comboBaseScale || 1);
909
+ }
910
+
911
+ this.updateSliderFollowCircle(hitObject, hitTime, timeDiff);
912
+ }
913
+ }
914
+
915
+ // ---------------------------------------------------------------------------
916
+ // Internal: slider follow circle & repeat bursts
917
+ // ---------------------------------------------------------------------------
918
+
919
+ updateSliderFollowCircle(hitObject, hitTime, timeDiff) {
920
+ if (!hitObject.followCircle || !hitObject.sliderInfo || timeDiff > 0) {
921
+ if (hitObject.followCircle) {
922
+ hitObject.followCircle.alpha = 0;
923
+ }
924
+ return;
925
+ }
926
+
927
+ const timingPoint = this.getCachedTimingPoint(hitTime);
928
+ if (!timingPoint?.closestBPM) {
929
+ hitObject.followCircle.alpha = 0;
930
+ return;
931
+ }
932
+
933
+ const sliderDuration = this.getSliderDurationCached(hitObject, hitTime);
934
+
935
+ if (!Number.isFinite(sliderDuration) || sliderDuration <= 0) {
936
+ hitObject.followCircle.alpha = 0;
937
+ return;
938
+ }
939
+
940
+ const sliderRepeat = Number(hitObject.sliderInfo.sliderRepeat || 1);
941
+ const sliderLength = Number(hitObject.sliderInfo.sliderLength || 0);
942
+ hitObject.sliderRepeat = sliderRepeat;
943
+ hitObject.sliderLength = sliderLength;
944
+ hitObject.sliderDuration = sliderDuration;
945
+
946
+ if (this.currentTime > hitTime + sliderDuration) {
947
+ hitObject.followCircle.alpha = 0;
948
+ this.updateSliderRepeatBursts(
949
+ hitObject,
950
+ hitTime,
951
+ sliderDuration,
952
+ sliderRepeat,
953
+ );
954
+ return;
955
+ }
956
+
957
+ const { gridUnit } = this.currentLayout || this.getLayout();
958
+ const followPosition = getFollowPosition(
959
+ hitObject,
960
+ hitTime,
961
+ this.currentTime,
962
+ gridUnit,
963
+ );
964
+ if (
965
+ followPosition &&
966
+ Number.isFinite(followPosition.x) &&
967
+ Number.isFinite(followPosition.y)
968
+ ) {
969
+ hitObject.followCircle.x = followPosition.x;
970
+ hitObject.followCircle.y = followPosition.y;
971
+ }
972
+ hitObject.followCircle.alpha = 0.7;
973
+
974
+ this.updateSliderRepeatBursts(
975
+ hitObject,
976
+ hitTime,
977
+ sliderDuration,
978
+ sliderRepeat,
979
+ );
980
+ }
981
+
982
+ hasRemainingSliderRepeatsAtEdge(
983
+ currentTime,
984
+ hitTime,
985
+ sliderDuration,
986
+ sliderRepeat,
987
+ edgeType,
988
+ ) {
989
+ if (
990
+ !Number.isFinite(currentTime) ||
991
+ !Number.isFinite(hitTime) ||
992
+ !Number.isFinite(sliderDuration) ||
993
+ sliderDuration <= 0 ||
994
+ !Number.isFinite(sliderRepeat) ||
995
+ sliderRepeat <= 1
996
+ ) {
997
+ return false;
998
+ }
999
+
1000
+ const spanDuration = sliderDuration / sliderRepeat;
1001
+ if (!Number.isFinite(spanDuration) || spanDuration <= 0) {
1002
+ return false;
1003
+ }
1004
+
1005
+ for (let repeatIndex = 1; repeatIndex < sliderRepeat; repeatIndex += 1) {
1006
+ const repeatTime = hitTime + spanDuration * repeatIndex;
1007
+ if (repeatTime <= currentTime) {
1008
+ continue;
1009
+ }
1010
+ const repeatEdge = repeatIndex % 2 === 1 ? "end" : "start";
1011
+ if (repeatEdge === edgeType) {
1012
+ return true;
1013
+ }
1014
+ }
1015
+
1016
+ return false;
1017
+ }
1018
+
1019
+ updateSliderRepeatBursts(hitObject, hitTime, sliderDuration, sliderRepeat) {
1020
+ if (!Number.isFinite(sliderDuration) || sliderDuration <= 0) {
1021
+ this.cleanupSliderRepeatBursts(hitObject);
1022
+ return;
1023
+ }
1024
+
1025
+ if (!hitObject._repeatBurstMap) {
1026
+ hitObject._repeatBurstMap = {};
1027
+ }
1028
+ if (!hitObject._repeatBurstTriggered) {
1029
+ hitObject._repeatBurstTriggered = {};
1030
+ }
1031
+
1032
+ const spanDuration = sliderDuration / sliderRepeat;
1033
+ if (!Number.isFinite(spanDuration) || spanDuration <= 0) {
1034
+ this.cleanupSliderRepeatBursts(hitObject);
1035
+ return;
1036
+ }
1037
+
1038
+ const { gridUnit } = this.currentLayout || this.getLayout();
1039
+ const circleBaseSize = this.getCircleSize(gridUnit);
1040
+ const baseArrowSize = this.getCircleSize(gridUnit) * 0.6;
1041
+
1042
+ if (sliderRepeat > 1) {
1043
+ for (let repeatIndex = 1; repeatIndex < sliderRepeat; repeatIndex += 1) {
1044
+ const repeatTime = hitTime + spanDuration * repeatIndex;
1045
+ if (this.currentTime < repeatTime) {
1046
+ continue;
1047
+ }
1048
+
1049
+ if (
1050
+ !hitObject._repeatBurstMap[repeatIndex] &&
1051
+ !hitObject._repeatBurstTriggered[repeatIndex]
1052
+ ) {
1053
+ const burstEffect = this.createSliderEdgeBurstEffect(
1054
+ hitObject,
1055
+ repeatIndex % 2 === 1 ? "end" : "start",
1056
+ true,
1057
+ circleBaseSize,
1058
+ baseArrowSize,
1059
+ );
1060
+ if (burstEffect) {
1061
+ hitObject._repeatBurstMap[repeatIndex] = {
1062
+ effect: burstEffect,
1063
+ startedAtMs: this.currentTime,
1064
+ };
1065
+ hitObject._repeatBurstTriggered[repeatIndex] = true;
1066
+ }
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ const sliderEndTime = hitTime + sliderDuration;
1072
+ if (
1073
+ this.currentTime >= sliderEndTime &&
1074
+ !hitObject._repeatBurstMap.end &&
1075
+ !hitObject._repeatBurstTriggered.end
1076
+ ) {
1077
+ const endEffect = this.createSliderEdgeBurstEffect(
1078
+ hitObject,
1079
+ sliderRepeat % 2 === 1 ? "end" : "start",
1080
+ false,
1081
+ circleBaseSize,
1082
+ baseArrowSize,
1083
+ );
1084
+ if (endEffect) {
1085
+ hitObject._repeatBurstMap.end = {
1086
+ effect: endEffect,
1087
+ startedAtMs: this.currentTime,
1088
+ };
1089
+ hitObject._repeatBurstTriggered.end = true;
1090
+ }
1091
+ }
1092
+
1093
+ for (const [repeatKey, burstState] of Object.entries(
1094
+ hitObject._repeatBurstMap,
1095
+ )) {
1096
+ const burstEffect = burstState?.effect;
1097
+ if (!burstEffect) {
1098
+ delete hitObject._repeatBurstMap[repeatKey];
1099
+ continue;
1100
+ }
1101
+ const burstProgress =
1102
+ (this.currentTime -
1103
+ (Number(burstState.startedAtMs) || this.currentTime)) /
1104
+ FADE_OUT_MS;
1105
+ if (burstProgress < 0) continue;
1106
+ if (burstProgress > 1) {
1107
+ this.destroySliderBurstEffect(burstEffect);
1108
+ delete hitObject._repeatBurstMap[repeatKey];
1109
+ continue;
1110
+ }
1111
+
1112
+ const alpha = this.getHitBurstAlpha(burstProgress);
1113
+ const circleSize = this.getHitBurstSize(
1114
+ Number(burstEffect.baseCircleSize) || circleBaseSize,
1115
+ burstProgress,
1116
+ );
1117
+ burstEffect.hitCircle.alpha = alpha;
1118
+ burstEffect.hitCircle.width = circleSize;
1119
+ burstEffect.hitCircle.height = circleSize;
1120
+ burstEffect.hitCircleOverlay.alpha = alpha;
1121
+ burstEffect.hitCircleOverlay.width = circleSize;
1122
+ burstEffect.hitCircleOverlay.height = circleSize;
1123
+
1124
+ if (burstEffect.arrowSprite) {
1125
+ const arrowSize = this.getHitBurstSize(
1126
+ Number(burstEffect.baseArrowSize) || baseArrowSize,
1127
+ burstProgress,
1128
+ );
1129
+ burstEffect.arrowSprite.alpha = alpha;
1130
+ burstEffect.arrowSprite.width = arrowSize;
1131
+ burstEffect.arrowSprite.height = arrowSize;
1132
+ }
1133
+ }
1134
+ }
1135
+
1136
+ createSliderEdgeBurstEffect(
1137
+ hitObject,
1138
+ edgeType,
1139
+ includeArrow,
1140
+ baseCircleSize,
1141
+ baseArrowSize,
1142
+ ) {
1143
+ const sliderInfo = hitObject.sliderInfo;
1144
+ const anchorPositions = sliderInfo?.anchorPositions || [];
1145
+ if (
1146
+ !sliderInfo ||
1147
+ !Array.isArray(anchorPositions) ||
1148
+ anchorPositions.length < 2
1149
+ ) {
1150
+ return null;
1151
+ }
1152
+
1153
+ const effectContainer = new PIXI.Container();
1154
+ effectContainer.zIndex = Number.isFinite(hitObject.hitCircleSprite?.zIndex)
1155
+ ? hitObject.hitCircleSprite.zIndex + 10
1156
+ : 0;
1157
+
1158
+ const hitCircle = new PIXI.Sprite(this.textures.texture_hitcircle);
1159
+ hitCircle.anchor.set(0.5);
1160
+ hitCircle.width = baseCircleSize;
1161
+ hitCircle.height = baseCircleSize;
1162
+ hitCircle.tint = hitObject.hitCircleSprite?.tint ?? 0xffffff;
1163
+
1164
+ const hitCircleOverlay = new PIXI.Sprite(
1165
+ this.textures.texture_hitcircleoverlay,
1166
+ );
1167
+ hitCircleOverlay.anchor.set(0.5);
1168
+ hitCircleOverlay.width = baseCircleSize;
1169
+ hitCircleOverlay.height = baseCircleSize;
1170
+
1171
+ effectContainer.addChild(hitCircle);
1172
+ effectContainer.addChild(hitCircleOverlay);
1173
+
1174
+ let x = 0;
1175
+ let y = 0;
1176
+ let rotation = 0;
1177
+ if (edgeType === "end") {
1178
+ x = sliderInfo.sliderEndPos?.x ?? hitObject.hitCircleSprite?.x ?? 0;
1179
+ y = sliderInfo.sliderEndPos?.y ?? hitObject.hitCircleSprite?.y ?? 0;
1180
+ rotation = getReverseArrowAngle(sliderInfo);
1181
+ } else {
1182
+ x = hitObject.hitCircleSprite?.x ?? anchorPositions[0].x;
1183
+ y = hitObject.hitCircleSprite?.y ?? anchorPositions[0].y;
1184
+ const first = anchorPositions[0];
1185
+ const second = anchorPositions[1];
1186
+ rotation = Math.atan2(second.y - first.y, second.x - first.x);
1187
+ }
1188
+
1189
+ hitCircle.x = x;
1190
+ hitCircle.y = y;
1191
+ hitCircleOverlay.x = x;
1192
+ hitCircleOverlay.y = y;
1193
+
1194
+ let arrowSprite = null;
1195
+ if (includeArrow) {
1196
+ arrowSprite = new PIXI.Sprite(this.textures.texture_reverse);
1197
+ arrowSprite.anchor.set(0.5);
1198
+ arrowSprite.width = baseArrowSize;
1199
+ arrowSprite.height = baseArrowSize;
1200
+ arrowSprite.x = x;
1201
+ arrowSprite.y = y;
1202
+ arrowSprite.rotation = rotation;
1203
+ effectContainer.addChild(arrowSprite);
1204
+ }
1205
+
1206
+ this.app.stage.addChild(effectContainer);
1207
+ return {
1208
+ container: effectContainer,
1209
+ hitCircle,
1210
+ hitCircleOverlay,
1211
+ arrowSprite,
1212
+ baseCircleSize,
1213
+ baseArrowSize: includeArrow ? baseArrowSize : 0,
1214
+ };
1215
+ }
1216
+
1217
+ destroySliderBurstEffect(effect) {
1218
+ if (!effect?.container) {
1219
+ return;
1220
+ }
1221
+ this.app.stage.removeChild(effect.container);
1222
+ if (typeof effect.container.destroy === "function") {
1223
+ effect.container.destroy({ children: true });
1224
+ }
1225
+ }
1226
+
1227
+ cleanupSliderRepeatBursts(hitObject) {
1228
+ if (!hitObject?._repeatBurstMap) {
1229
+ return;
1230
+ }
1231
+ for (const burstEntry of Object.values(hitObject._repeatBurstMap)) {
1232
+ const burstEffect = burstEntry?.effect;
1233
+ if (!burstEffect) {
1234
+ continue;
1235
+ }
1236
+ this.destroySliderBurstEffect(burstEffect);
1237
+ }
1238
+ hitObject._repeatBurstMap = {};
1239
+ hitObject._repeatBurstTriggered = {};
1240
+ }
1241
+
1242
+ // ---------------------------------------------------------------------------
1243
+ // Internal: slider creation
1244
+ // ---------------------------------------------------------------------------
1245
+
1246
+ addSlider(hitObject, posX, posY, circleSize, zIndex, comboColor) {
1247
+ const sliderInfo = hitObject.sliderInfo;
1248
+ const sliderBodyPath =
1249
+ sliderInfo.sliderType === "P"
1250
+ ? perfectCurve(sliderInfo, this.currentLayout || this.getLayout())
1251
+ : bezierCurve(sliderInfo, this.currentLayout || this.getLayout());
1252
+ const sliderBorderPath =
1253
+ sliderInfo.sliderType === "P"
1254
+ ? perfectCurve(sliderInfo, this.currentLayout || this.getLayout())
1255
+ : bezierCurve(sliderInfo, this.currentLayout || this.getLayout());
1256
+
1257
+ const sliderBorderWidth = circleSize * 0.92;
1258
+ const sliderBodyWidth = sliderBorderWidth * 0.88;
1259
+
1260
+ sliderBorderPath.stroke({
1261
+ width: sliderBorderWidth,
1262
+ color: 0xffffff,
1263
+ join: "round",
1264
+ cap: "round",
1265
+ });
1266
+ sliderBodyPath.stroke({
1267
+ width: sliderBodyWidth,
1268
+ color: darkenColor(comboColor, SLIDER_BODY_DARKEN_FACTOR),
1269
+ join: "round",
1270
+ cap: "round",
1271
+ });
1272
+
1273
+ const bounds = sliderBodyPath.getLocalBounds();
1274
+ const padding = Math.ceil(circleSize * 0.75);
1275
+ const texW = Math.max(1, Math.ceil(bounds.width + padding * 2));
1276
+ const texH = Math.max(1, Math.ceil(bounds.height + padding * 2));
1277
+ const renderResolution =
1278
+ Math.max(1, this.app?.renderer?.resolution || 1) * SLIDER_RT_OVERSAMPLE;
1279
+ const multisample = PIXI.MSAA_QUALITY?.HIGH;
1280
+ const sliderBodyTexture = PIXI.RenderTexture.create({
1281
+ width: texW,
1282
+ height: texH,
1283
+ resolution: renderResolution,
1284
+ multisample,
1285
+ });
1286
+ const sliderBorderTexture = PIXI.RenderTexture.create({
1287
+ width: texW,
1288
+ height: texH,
1289
+ resolution: renderResolution,
1290
+ multisample,
1291
+ });
1292
+
1293
+ sliderBodyPath.x = padding - bounds.x;
1294
+ sliderBodyPath.y = padding - bounds.y;
1295
+ sliderBorderPath.x = padding - bounds.x;
1296
+ sliderBorderPath.y = padding - bounds.y;
1297
+
1298
+ const bodyRenderContainer = new PIXI.Container();
1299
+ bodyRenderContainer.addChild(sliderBodyPath);
1300
+ this.app.renderer.render({
1301
+ container: bodyRenderContainer,
1302
+ target: sliderBodyTexture,
1303
+ clear: true,
1304
+ clearColor: [0, 0, 0, 0],
1305
+ });
1306
+ bodyRenderContainer.removeChild(sliderBodyPath);
1307
+ bodyRenderContainer.destroy({ children: false });
1308
+
1309
+ const borderRenderContainer = new PIXI.Container();
1310
+ borderRenderContainer.addChild(sliderBorderPath);
1311
+ this.app.renderer.render({
1312
+ container: borderRenderContainer,
1313
+ target: sliderBorderTexture,
1314
+ clear: true,
1315
+ clearColor: [0, 0, 0, 0],
1316
+ });
1317
+ borderRenderContainer.removeChild(sliderBorderPath);
1318
+ borderRenderContainer.destroy({ children: false });
1319
+
1320
+ const sliderBorderSprite = new PIXI.Sprite(sliderBorderTexture);
1321
+ sliderBorderSprite.x = 0;
1322
+ sliderBorderSprite.y = 0;
1323
+
1324
+ const sliderBodySprite = new PIXI.Sprite(sliderBodyTexture);
1325
+ sliderBodySprite.x = 0;
1326
+ sliderBodySprite.y = 0;
1327
+ sliderBodySprite.alpha = SLIDER_BODY_ALPHA;
1328
+
1329
+ const sliderSprite = new PIXI.Container();
1330
+ sliderSprite.sortableChildren = false;
1331
+ sliderSprite.x = bounds.x - padding;
1332
+ sliderSprite.y = bounds.y - padding;
1333
+ sliderSprite.zIndex = zIndex + 1;
1334
+ sliderSprite.addChild(sliderBorderSprite);
1335
+ sliderSprite.addChild(sliderBodySprite);
1336
+
1337
+ this.app.stage.addChild(sliderSprite);
1338
+ hitObject.sliderBorderSprite = null;
1339
+ hitObject.sliderSprite = sliderSprite;
1340
+
1341
+ hitObject.hitCircleSprite_sliderend = new PIXI.Sprite(
1342
+ this.textures.texture_hitcircle,
1343
+ );
1344
+ hitObject.hitCircleSprite_sliderend.anchor.set(0.5);
1345
+ hitObject.hitCircleSprite_sliderend.x = sliderInfo.sliderEndPos.x;
1346
+ hitObject.hitCircleSprite_sliderend.y = sliderInfo.sliderEndPos.y;
1347
+ hitObject.hitCircleSprite_sliderend.width = circleSize;
1348
+ hitObject.hitCircleSprite_sliderend.height = circleSize;
1349
+ hitObject.hitCircleSprite_sliderend.tint = comboColor;
1350
+ hitObject.hitCircleSprite_sliderend.zIndex = zIndex + 2;
1351
+
1352
+ hitObject.hitCircleOverlaySprite_sliderend = new PIXI.Sprite(
1353
+ this.textures.texture_hitcircleoverlay,
1354
+ );
1355
+ hitObject.hitCircleOverlaySprite_sliderend.anchor.set(0.5);
1356
+ hitObject.hitCircleOverlaySprite_sliderend.x = sliderInfo.sliderEndPos.x;
1357
+ hitObject.hitCircleOverlaySprite_sliderend.y = sliderInfo.sliderEndPos.y;
1358
+ hitObject.hitCircleOverlaySprite_sliderend.width = circleSize;
1359
+ hitObject.hitCircleOverlaySprite_sliderend.height = circleSize;
1360
+ hitObject.hitCircleOverlaySprite_sliderend.zIndex = zIndex + 2;
1361
+ this.app.stage.addChild(hitObject.hitCircleSprite_sliderend);
1362
+ this.app.stage.addChild(hitObject.hitCircleOverlaySprite_sliderend);
1363
+
1364
+ if (Number(sliderInfo.sliderRepeat) > 1) {
1365
+ const reverseSprite = new PIXI.Sprite(this.textures.texture_reverse);
1366
+ reverseSprite.anchor.set(0.5);
1367
+ reverseSprite.x = sliderInfo.sliderEndPos.x;
1368
+ reverseSprite.y = sliderInfo.sliderEndPos.y;
1369
+ reverseSprite.width = circleSize * 0.6;
1370
+ reverseSprite.height = circleSize * 0.6;
1371
+ reverseSprite.rotation = getReverseArrowAngle(sliderInfo);
1372
+ reverseSprite.zIndex = zIndex + 2;
1373
+ this.app.stage.addChild(reverseSprite);
1374
+ hitObject.reverseSprite = reverseSprite;
1375
+ }
1376
+
1377
+ if (Number(sliderInfo.sliderRepeat) > 2) {
1378
+ const reverseSpriteHead = new PIXI.Sprite(this.textures.texture_reverse);
1379
+ reverseSpriteHead.anchor.set(0.5);
1380
+ reverseSpriteHead.x = posX;
1381
+ reverseSpriteHead.y = posY;
1382
+ reverseSpriteHead.width = circleSize * 0.6;
1383
+ reverseSpriteHead.height = circleSize * 0.6;
1384
+ const anchorPositions = sliderInfo.anchorPositions || [];
1385
+ if (anchorPositions.length >= 2) {
1386
+ const first = anchorPositions[0];
1387
+ const second = anchorPositions[1];
1388
+ reverseSpriteHead.rotation = Math.atan2(
1389
+ second.y - first.y,
1390
+ second.x - first.x,
1391
+ );
1392
+ } else {
1393
+ reverseSpriteHead.rotation = 0;
1394
+ }
1395
+ reverseSpriteHead.zIndex = zIndex + 2;
1396
+ reverseSpriteHead.visible = false;
1397
+ this.app.stage.addChild(reverseSpriteHead);
1398
+ hitObject.reverseSpriteHead = reverseSpriteHead;
1399
+ }
1400
+
1401
+ const followCircle = new PIXI.Graphics();
1402
+ followCircle.beginFill(0xeeeeee);
1403
+ followCircle.drawCircle(0, 0, circleSize / 2.4);
1404
+ followCircle.endFill();
1405
+ followCircle.x = posX;
1406
+ followCircle.y = posY;
1407
+ followCircle.alpha = 0;
1408
+ followCircle.zIndex = zIndex + 2;
1409
+ this.app.stage.addChild(followCircle);
1410
+ hitObject.followCircle = followCircle;
1411
+ }
1412
+
1413
+ // ---------------------------------------------------------------------------
1414
+ // Internal: display object helpers
1415
+ // ---------------------------------------------------------------------------
1416
+
1417
+ destroyDisplayObject(displayObject) {
1418
+ if (!displayObject) {
1419
+ return;
1420
+ }
1421
+ if (displayObject.parent) {
1422
+ displayObject.parent.removeChild(displayObject);
1423
+ }
1424
+ if (typeof displayObject.destroy === "function") {
1425
+ displayObject.destroy({ children: true });
1426
+ }
1427
+ }
1428
+
1429
+ destroyHitObjectVisuals(hitObject) {
1430
+ if (!hitObject) {
1431
+ return;
1432
+ }
1433
+ this.cleanupSliderRepeatBursts(hitObject);
1434
+
1435
+ this.destroyDisplayObject(hitObject.approachSprite);
1436
+ this.destroyDisplayObject(hitObject.hitCircleSprite);
1437
+ this.destroyDisplayObject(hitObject.hitCircleOverlaySprite);
1438
+ this.destroyDisplayObject(hitObject.comboText);
1439
+ this.destroyDisplayObject(hitObject.sliderSprite);
1440
+ this.destroyDisplayObject(hitObject.sliderBorderSprite);
1441
+ this.destroyDisplayObject(hitObject.hitCircleSprite_sliderend);
1442
+ this.destroyDisplayObject(hitObject.hitCircleOverlaySprite_sliderend);
1443
+ this.destroyDisplayObject(hitObject.reverseSprite);
1444
+ this.destroyDisplayObject(hitObject.reverseSpriteHead);
1445
+ this.destroyDisplayObject(hitObject.followCircle);
1446
+
1447
+ hitObject.approachSprite = null;
1448
+ hitObject.hitCircleSprite = null;
1449
+ hitObject.hitCircleOverlaySprite = null;
1450
+ hitObject.comboText = null;
1451
+ hitObject.sliderSprite = null;
1452
+ hitObject.sliderBorderSprite = null;
1453
+ hitObject.hitCircleSprite_sliderend = null;
1454
+ hitObject.hitCircleOverlaySprite_sliderend = null;
1455
+ hitObject.reverseSprite = null;
1456
+ hitObject.reverseSpriteHead = null;
1457
+ hitObject.followCircle = null;
1458
+ }
1459
+
1460
+ removeHitObject(hitObject) {
1461
+ if (!this.app) {
1462
+ return;
1463
+ }
1464
+ this.cleanupSliderRepeatBursts(hitObject);
1465
+ this.hideHitObject(hitObject);
1466
+ }
1467
+
1468
+ showHitObject(hitObject) {
1469
+ if (hitObject.hitCircleSprite) hitObject.hitCircleSprite.visible = true;
1470
+ if (hitObject.approachSprite) hitObject.approachSprite.visible = true;
1471
+ if (hitObject.hitCircleOverlaySprite)
1472
+ hitObject.hitCircleOverlaySprite.visible = true;
1473
+ if (hitObject.reverseSprite) hitObject.reverseSprite.visible = true;
1474
+ if (hitObject.reverseSpriteHead)
1475
+ hitObject.reverseSpriteHead.visible = false;
1476
+ if (hitObject.comboText) hitObject.comboText.visible = true;
1477
+ if (hitObject.sliderSprite) hitObject.sliderSprite.visible = true;
1478
+ if (hitObject.sliderBorderSprite)
1479
+ hitObject.sliderBorderSprite.visible = true;
1480
+ if (hitObject.hitCircleOverlaySprite_sliderend)
1481
+ hitObject.hitCircleOverlaySprite_sliderend.visible = true;
1482
+ if (hitObject.hitCircleSprite_sliderend)
1483
+ hitObject.hitCircleSprite_sliderend.visible = true;
1484
+ if (hitObject.followCircle) hitObject.followCircle.visible = true;
1485
+ }
1486
+
1487
+ hideHitObject(hitObject) {
1488
+ if (hitObject.hitCircleSprite) hitObject.hitCircleSprite.visible = false;
1489
+ if (hitObject.approachSprite) hitObject.approachSprite.visible = false;
1490
+ if (hitObject.hitCircleOverlaySprite)
1491
+ hitObject.hitCircleOverlaySprite.visible = false;
1492
+ if (hitObject.reverseSprite) hitObject.reverseSprite.visible = false;
1493
+ if (hitObject.reverseSpriteHead)
1494
+ hitObject.reverseSpriteHead.visible = false;
1495
+ if (hitObject.comboText) hitObject.comboText.visible = false;
1496
+ if (hitObject.sliderSprite) hitObject.sliderSprite.visible = false;
1497
+ if (hitObject.sliderBorderSprite)
1498
+ hitObject.sliderBorderSprite.visible = false;
1499
+ if (hitObject.hitCircleOverlaySprite_sliderend)
1500
+ hitObject.hitCircleOverlaySprite_sliderend.visible = false;
1501
+ if (hitObject.hitCircleSprite_sliderend)
1502
+ hitObject.hitCircleSprite_sliderend.visible = false;
1503
+ if (hitObject.followCircle) hitObject.followCircle.visible = false;
1504
+ }
1505
+
1506
+ // ---------------------------------------------------------------------------
1507
+ // Internal: asset loading
1508
+ // ---------------------------------------------------------------------------
1509
+
1510
+ async loadAssets() {
1511
+ const base = this.assetBaseUrl.replace(/\/$/, "");
1512
+ const texture_approach = await Assets.load(`${base}/approachcircle.png`);
1513
+ const texture_hitcircle = await Assets.load(`${base}/hitcircle.png`);
1514
+ const texture_hitcircleoverlay = await Assets.load(
1515
+ `${base}/hitcircleoverlay.png`,
1516
+ );
1517
+ const texture_reverse = await Assets.load(`${base}/reversearrow.png`);
1518
+ this.applyLinearTextureFiltering(texture_approach);
1519
+ this.applyLinearTextureFiltering(texture_hitcircle);
1520
+ this.applyLinearTextureFiltering(texture_hitcircleoverlay);
1521
+ this.applyLinearTextureFiltering(texture_reverse);
1522
+ return {
1523
+ texture_approach,
1524
+ texture_hitcircle,
1525
+ texture_hitcircleoverlay,
1526
+ texture_reverse,
1527
+ };
1528
+ }
1529
+
1530
+ applyLinearTextureFiltering(texture) {
1531
+ if (!texture) {
1532
+ return;
1533
+ }
1534
+ const sourceStyle = texture.source?.style;
1535
+ if (sourceStyle) {
1536
+ sourceStyle.scaleMode = "linear";
1537
+ return;
1538
+ }
1539
+ if (texture.baseTexture && PIXI.SCALE_MODES?.LINEAR != null) {
1540
+ texture.baseTexture.scaleMode = PIXI.SCALE_MODES.LINEAR;
1541
+ }
1542
+ }
1543
+
1544
+ // ---------------------------------------------------------------------------
1545
+ // Internal: calculations
1546
+ // ---------------------------------------------------------------------------
1547
+
1548
+ calculateARValues() {
1549
+ const AR = Number(this.beatmap?.Difficulty?.ApproachRate || 5);
1550
+ let preempt;
1551
+ let fadeIn;
1552
+ if (AR < 5) {
1553
+ preempt = 1200 + (600 * (5 - AR)) / 5;
1554
+ fadeIn = 800 + (400 * (5 - AR)) / 5;
1555
+ } else if (AR === 5) {
1556
+ preempt = 1200;
1557
+ fadeIn = 800;
1558
+ } else {
1559
+ preempt = 1200 - (750 * (AR - 5)) / 5;
1560
+ fadeIn = 800 - (500 * (AR - 5)) / 5;
1561
+ }
1562
+ return { preempt, fadeIn };
1563
+ }
1564
+
1565
+ getDisplayDimensions() {
1566
+ const containerWidth = this.containerEl?.clientWidth || 1280;
1567
+ const containerHeight = this.containerEl?.clientHeight || 720;
1568
+ const containerAspect = containerWidth / containerHeight;
1569
+
1570
+ if (containerAspect > BASE_ASPECT_RATIO) {
1571
+ const height = containerHeight;
1572
+ return { width: height * BASE_ASPECT_RATIO, height };
1573
+ }
1574
+ const width = containerWidth;
1575
+ return { width, height: width / BASE_ASPECT_RATIO };
1576
+ }
1577
+
1578
+ getLayout() {
1579
+ const { width, height } = this.getDisplayDimensions();
1580
+ const centerX = width / 2;
1581
+ const centerY = height / 2;
1582
+ const gridUnit = (height * 0.8) / 384;
1583
+ return {
1584
+ centerX,
1585
+ centerY,
1586
+ gridUnit,
1587
+ topLeftX: centerX - 256 * gridUnit,
1588
+ topLeftY: centerY - 192 * gridUnit,
1589
+ };
1590
+ }
1591
+
1592
+ getCircleSize(gridUnit) {
1593
+ const cs = Number(this.beatmap?.Difficulty?.CircleSize || 4);
1594
+ return (54.4 - 4.48 * cs) * gridUnit * 2;
1595
+ }
1596
+
1597
+ getComboColor(comboColorIndex) {
1598
+ const comboColors = [0xff9900, 0xff0000, 0x3399ff, 0x33ff00];
1599
+ return comboColors[(comboColorIndex || 0) % comboColors.length];
1600
+ }
1601
+
1602
+ getSliderDuration(hitObject, hitTime) {
1603
+ if (!hitObject?.sliderInfo) {
1604
+ return 0;
1605
+ }
1606
+ const timingPoint = this.getCachedTimingPoint(hitTime);
1607
+ if (!timingPoint?.closestBPM) {
1608
+ return 0;
1609
+ }
1610
+ const beatLength = timingPoint.closestBPM.beatLengthValue;
1611
+ const baseSV = Number(this.beatmap?.Difficulty?.SliderMultiplier || 1);
1612
+ const hasInheritedSVAfterBpm =
1613
+ timingPoint.closestSV &&
1614
+ Number.isFinite(timingPoint.closestSV.time) &&
1615
+ Number.isFinite(timingPoint.closestBPM.time) &&
1616
+ timingPoint.closestSV.time >= timingPoint.closestBPM.time;
1617
+ const speed = hasInheritedSVAfterBpm ? timingPoint.closestSV.sv : -100;
1618
+ const sliderRepeat = Number(hitObject.sliderInfo.sliderRepeat || 1);
1619
+ const sliderLength = Number(hitObject.sliderInfo.sliderLength || 0);
1620
+ const svFormulaDuration =
1621
+ (sliderLength / (100 * baseSV * (-100 / speed))) *
1622
+ beatLength *
1623
+ sliderRepeat;
1624
+ const fallbackDuration = (beatLength * sliderRepeat * sliderLength) / 100;
1625
+ const duration =
1626
+ Number.isFinite(svFormulaDuration) && svFormulaDuration > 0
1627
+ ? svFormulaDuration
1628
+ : fallbackDuration;
1629
+ return Number.isFinite(duration) && duration > 0 ? duration : 0;
1630
+ }
1631
+
1632
+ getSliderDurationCached(hitObject, hitTime) {
1633
+ if (
1634
+ Number.isFinite(hitObject._sliderDuration) &&
1635
+ hitObject._sliderDuration > 0
1636
+ ) {
1637
+ return hitObject._sliderDuration;
1638
+ }
1639
+ const duration = this.getSliderDuration(hitObject, hitTime);
1640
+ hitObject._sliderDuration = duration > 0 ? duration : 2000;
1641
+ return hitObject._sliderDuration;
1642
+ }
1643
+
1644
+ getCachedTimingPoint(hitTime) {
1645
+ if (this.timingPointCache.has(hitTime)) {
1646
+ return this.timingPointCache.get(hitTime);
1647
+ }
1648
+ const timingPoint = getTimingPointAt(hitTime, this.beatmap);
1649
+ this.timingPointCache.set(hitTime, timingPoint);
1650
+ return timingPoint;
1651
+ }
1652
+
1653
+ prepareHitObject(hitObject, hitTime) {
1654
+ hitObject.time = hitTime;
1655
+ hitObject._hitsoundHeadPlayed = false;
1656
+ hitObject._hitsoundSliderEndPlayed = false;
1657
+ hitObject._hitsoundSliderRepeatPlayed = {};
1658
+ hitObject._sliderDuration =
1659
+ this.getSliderDuration(hitObject, hitTime) || 2000;
1660
+ hitObject._repeatBurstMap = {};
1661
+ hitObject._repeatBurstTriggered = {};
1662
+ }
1663
+
1664
+ getHitBurstProgress(timeDiff) {
1665
+ return Math.max(0, Math.min(1, -timeDiff / FADE_OUT_MS));
1666
+ }
1667
+
1668
+ getHitBurstAlpha(progress) {
1669
+ return Math.max(0, 1 - (Number(progress) || 0));
1670
+ }
1671
+
1672
+ getHitBurstSize(baseSize, progress) {
1673
+ const safeBaseSize = Number(baseSize) || 0;
1674
+ const safeProgress = Number(progress) || 0;
1675
+ return safeBaseSize + safeProgress * safeBaseSize * 0.3;
1676
+ }
1677
+
1678
+ getHitBurstScale(baseScale, progress) {
1679
+ const safeBaseScale = Number(baseScale) || 1;
1680
+ const safeProgress = Number(progress) || 0;
1681
+ return safeBaseScale + safeProgress * safeBaseScale * 0.3;
1682
+ }
1683
+
1684
+ getDelimitedParts(value, delimiter) {
1685
+ if (typeof value === "string") {
1686
+ return value.split(delimiter);
1687
+ }
1688
+ if (Array.isArray(value)) {
1689
+ return value.map((item) => String(item ?? ""));
1690
+ }
1691
+ if (value == null) {
1692
+ return [];
1693
+ }
1694
+ return String(value).split(delimiter);
1695
+ }
1696
+
1697
+ computeBeatmapDurationMs() {
1698
+ if (!this.hitObjectEntries.length) {
1699
+ return 0;
1700
+ }
1701
+ let maxEndTime = 0;
1702
+ for (const [hitTime, hitObject] of this.hitObjectEntries) {
1703
+ const endTime = this.getHitObjectEndTime(hitObject, hitTime);
1704
+ if (Number.isFinite(endTime) && endTime > maxEndTime) {
1705
+ maxEndTime = endTime;
1706
+ }
1707
+ }
1708
+ return Math.max(0, maxEndTime);
1709
+ }
1710
+
1711
+ getHitObjectEndTime(hitObject, hitTime) {
1712
+ if (!hitObject) {
1713
+ return hitTime;
1714
+ }
1715
+ if (hitObject.type?.includes("Slider")) {
1716
+ const sliderDuration = this.getSliderDurationCached(hitObject, hitTime);
1717
+ return hitTime + Math.max(0, sliderDuration);
1718
+ }
1719
+ if (hitObject.type?.includes("Spinner")) {
1720
+ const extrasParts = this.getDelimitedParts(hitObject.extras, ":");
1721
+ const spinnerEndTime = Number.parseInt(extrasParts[0], 10);
1722
+ if (Number.isFinite(spinnerEndTime) && spinnerEndTime > hitTime) {
1723
+ return spinnerEndTime;
1724
+ }
1725
+ }
1726
+ return hitTime;
1727
+ }
1728
+
1729
+ getTransportCurrentTimeMs() {
1730
+ if (!this.isPlaying) {
1731
+ return this.currentTime;
1732
+ }
1733
+
1734
+ const nowPerfMs = performance.now();
1735
+ const elapsedMs = nowPerfMs - this.transportStartPerfTime;
1736
+ const perfVisualMs = this.transportStartMs + Math.max(0, elapsedMs);
1737
+ if (
1738
+ this.previewAudio &&
1739
+ !this.previewAudio.paused &&
1740
+ Number.isFinite(this.previewAudio.currentTime)
1741
+ ) {
1742
+ if (
1743
+ !this.lastAudioSyncSamplePerfMs ||
1744
+ nowPerfMs - this.lastAudioSyncSamplePerfMs >=
1745
+ AUDIO_SYNC_SAMPLE_INTERVAL_MS
1746
+ ) {
1747
+ this.lastAudioSyncSamplePerfMs = nowPerfMs;
1748
+ const audioDerivedVisualMs = this.getBeatmapTimeMsForPreviewAudioTime(
1749
+ this.previewAudio.currentTime * 1000,
1750
+ );
1751
+ if (Number.isFinite(audioDerivedVisualMs)) {
1752
+ const targetOffsetMs = audioDerivedVisualMs - perfVisualMs;
1753
+ if (Math.abs(targetOffsetMs) > 120) {
1754
+ this.audioSyncOffsetMs = targetOffsetMs;
1755
+ } else {
1756
+ this.audioSyncOffsetMs =
1757
+ this.audioSyncOffsetMs +
1758
+ (targetOffsetMs - this.audioSyncOffsetMs) * 0.35;
1759
+ }
1760
+ }
1761
+ }
1762
+ }
1763
+ return perfVisualMs + this.audioSyncOffsetMs;
1764
+ }
1765
+
1766
+ getPreviewAudioTimeMsForBeatmapTime(beatmapTimeMs) {
1767
+ return beatmapTimeMs - this.previewTimeMs + this.audioOffsetMs;
1768
+ }
1769
+
1770
+ getBeatmapTimeMsForPreviewAudioTime(previewAudioTimeMs) {
1771
+ return previewAudioTimeMs + this.previewTimeMs - this.audioOffsetMs;
1772
+ }
1773
+
1774
+ /** @deprecated No-op, kept for backward compatibility. */
1775
+ async initHitsounds() {}
1776
+ }
1777
+
1778
+ // ---------------------------------------------------------------------------
1779
+ // Module-level helpers
1780
+ // ---------------------------------------------------------------------------
1781
+
1782
+ function lowerBound(arr, target) {
1783
+ let left = 0;
1784
+ let right = arr.length;
1785
+ while (left < right) {
1786
+ const mid = (left + right) >> 1;
1787
+ if (arr[mid] < target) {
1788
+ left = mid + 1;
1789
+ } else {
1790
+ right = mid;
1791
+ }
1792
+ }
1793
+ return left;
1794
+ }
1795
+
1796
+ function upperBound(arr, target) {
1797
+ let left = 0;
1798
+ let right = arr.length;
1799
+ while (left < right) {
1800
+ const mid = (left + right) >> 1;
1801
+ if (arr[mid] <= target) {
1802
+ left = mid + 1;
1803
+ } else {
1804
+ right = mid;
1805
+ }
1806
+ }
1807
+ return left;
1808
+ }
1809
+
1810
+ function darkenColor(color, factor) {
1811
+ const normalized = Math.max(0, Math.min(1, Number(factor) || 0));
1812
+ const r = (color >> 16) & 0xff;
1813
+ const g = (color >> 8) & 0xff;
1814
+ const b = color & 0xff;
1815
+ const dr = Math.round(r * normalized);
1816
+ const dg = Math.round(g * normalized);
1817
+ const db = Math.round(b * normalized);
1818
+ return (dr << 16) | (dg << 8) | db;
1819
+ }