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,2389 @@
1
+ import * as PIXI from "pixi.js";
2
+ import { Application, Assets } from "pixi.js";
3
+ function parseOsuText(text) {
4
+ try {
5
+ const lines = text.split(/\r?\n/);
6
+ const result = {};
7
+ let currentSection = "";
8
+ let comboIndex = 0;
9
+ let comboColorIndex = 0;
10
+ lines.forEach((line) => {
11
+ if (line.startsWith("[") && line.endsWith("]")) {
12
+ currentSection = line.slice(1, -1);
13
+ result[currentSection] = currentSection === "Colours" ? [] : {};
14
+ } else {
15
+ if (currentSection === "TimingPoints" || currentSection === "HitObjects") {
16
+ if (currentSection === "TimingPoints") {
17
+ const timingPoint = line.split(",");
18
+ const offset = timingPoint[0];
19
+ const beatLength = timingPoint[1];
20
+ const meter = timingPoint[2];
21
+ const sampleset = parseInt(timingPoint[3]);
22
+ const sampleIndex = timingPoint[4];
23
+ const volume = timingPoint[5];
24
+ const inherited = timingPoint[6];
25
+ const kiai = timingPoint[7];
26
+ if (!offset || !beatLength) {
27
+ return;
28
+ }
29
+ if (!result[currentSection][offset]) {
30
+ result[currentSection][offset] = [];
31
+ }
32
+ if (!result[currentSection][offset][inherited]) {
33
+ result[currentSection][offset][inherited] = [];
34
+ }
35
+ result[currentSection][offset][inherited].push(beatLength);
36
+ result[currentSection][offset][inherited].push(sampleset);
37
+ } else {
38
+ const hitObject = line.split(",");
39
+ const x = hitObject[0];
40
+ const y = hitObject[1];
41
+ const time = hitObject[2];
42
+ const type = parseHitObjectType(hitObject[3]);
43
+ const hitSound = hitObject[4];
44
+ const extras = type.includes("Circle") ? hitObject[5] : hitObject.slice(5);
45
+ if (type.includes("New Combo")) {
46
+ comboIndex = 1;
47
+ comboColorIndex++;
48
+ }
49
+ const combo = comboIndex;
50
+ const comboColor = comboColorIndex;
51
+ var sliderInfo;
52
+ if (type.includes("Slider")) {
53
+ sliderInfo = parseSliderInfo(hitObject.slice(5));
54
+ sliderInfo.anchorPositions.unshift({
55
+ x: Number(x),
56
+ y: Number(y)
57
+ });
58
+ }
59
+ result[currentSection][time] = {
60
+ x,
61
+ y,
62
+ time,
63
+ type,
64
+ hitSound,
65
+ extras
66
+ };
67
+ result[currentSection][time].combo = combo;
68
+ result[currentSection][time].comboColor = comboColor;
69
+ result[currentSection][time].hitsound = {};
70
+ comboIndex++;
71
+ if (sliderInfo) {
72
+ result[currentSection][time].sliderInfo = sliderInfo;
73
+ }
74
+ }
75
+ } else {
76
+ if (!currentSection || !result[currentSection]) {
77
+ return;
78
+ }
79
+ const [key, value] = line.split(":").map((part) => part.trim());
80
+ if (key && value) {
81
+ if (value.includes(",")) {
82
+ result[currentSection][key] = value.split(",").map((v) => v.trim());
83
+ } else {
84
+ result[currentSection][key] = value;
85
+ }
86
+ }
87
+ }
88
+ }
89
+ });
90
+ if (!result["Difficulty"]["ApproachRate"]) {
91
+ result["Difficulty"]["ApproachRate"] = result["Difficulty"]["OverallDifficulty"];
92
+ }
93
+ return result;
94
+ } catch (error) {
95
+ console.error("Error parsing .osu text:", error);
96
+ return null;
97
+ }
98
+ }
99
+ async function fetchAndParseOsu(url) {
100
+ try {
101
+ const response = await fetch(url);
102
+ if (!response.ok) {
103
+ throw new Error(`HTTP error! status: ${response.status}`);
104
+ }
105
+ const text = await response.text();
106
+ return parseOsuText(text);
107
+ } catch (error) {
108
+ console.error("Error downloading or parsing the file:", error);
109
+ return null;
110
+ }
111
+ }
112
+ function parseHitObjectType(type) {
113
+ const hitObjectTypes = {
114
+ 0: "Circle",
115
+ 1: "Slider",
116
+ 2: "New Combo",
117
+ 3: "Spinner"
118
+ };
119
+ let parsedTypes = [];
120
+ let typeValue = parseInt(type, 10);
121
+ if (typeValue & 1) parsedTypes.push(hitObjectTypes[0]);
122
+ if (typeValue & 2) parsedTypes.push(hitObjectTypes[1]);
123
+ if (typeValue & 4) parsedTypes.push(hitObjectTypes[2]);
124
+ if (typeValue & 8) parsedTypes.push(hitObjectTypes[3]);
125
+ return parsedTypes.length > 0 ? parsedTypes : "Unknown";
126
+ }
127
+ function parseSliderInfo(extras) {
128
+ const sliderInfo = extras[0];
129
+ const parts = sliderInfo.split("|");
130
+ const sliderType = parts[0];
131
+ const anchorPositions = parts.slice(1).map((pos) => {
132
+ const [x, y] = pos.split(":").map(Number);
133
+ return { x, y };
134
+ });
135
+ let deepCopyAnchorPositions = JSON.parse(
136
+ JSON.stringify(anchorPositions[anchorPositions.length - 1])
137
+ );
138
+ anchorPositions.push(deepCopyAnchorPositions);
139
+ const sliderRepeat = extras[1];
140
+ const sliderLength = extras[2];
141
+ const edgeSounds = extras[3];
142
+ const edgeSets = extras[4];
143
+ return {
144
+ sliderType,
145
+ sliderRepeat,
146
+ sliderLength,
147
+ anchorPositions,
148
+ edgeSounds,
149
+ edgeSets
150
+ };
151
+ }
152
+ const DEFAULT_BUNDLED_ASSET_BASE = "https://raw.githubusercontent.com/inix1257/osu-beatmap-renderer/main/assets";
153
+ const HITSOUND_SAMPLE_FILES = [
154
+ "normal-hitnormal.wav",
155
+ "normal-hitwhistle.wav",
156
+ "normal-hitfinish.wav",
157
+ "normal-hitclap.wav",
158
+ "soft-hitnormal.wav",
159
+ "soft-hitwhistle.wav",
160
+ "soft-hitfinish.wav",
161
+ "soft-hitclap.wav",
162
+ "drum-hitnormal.wav",
163
+ "drum-hitwhistle.wav",
164
+ "drum-hitfinish.wav",
165
+ "drum-hitclap.wav"
166
+ ];
167
+ function lowerBound$1(arr, target) {
168
+ let left = 0;
169
+ let right = arr.length;
170
+ while (left < right) {
171
+ const mid = left + right >> 1;
172
+ if (arr[mid] < target) {
173
+ left = mid + 1;
174
+ } else {
175
+ right = mid;
176
+ }
177
+ }
178
+ return left;
179
+ }
180
+ function fileBaseName(file) {
181
+ return file.replace(/\.[^/.]+$/, "");
182
+ }
183
+ class HitsoundPlayer {
184
+ constructor({
185
+ hitsoundVolume = 0.3,
186
+ schedulerIntervalMs = 20,
187
+ schedulerLookaheadMs = 120,
188
+ assetBaseUrl = DEFAULT_BUNDLED_ASSET_BASE
189
+ } = {}) {
190
+ this.hitsoundVolume = hitsoundVolume;
191
+ this.assetBaseUrl = assetBaseUrl.replace(/\/$/, "");
192
+ this.schedulerIntervalMs = schedulerIntervalMs;
193
+ this.schedulerLookaheadMs = schedulerLookaheadMs;
194
+ this.audioContext = null;
195
+ this.hitsoundBuffers = /* @__PURE__ */ new Map();
196
+ this.schedulerTimer = null;
197
+ this.hitsoundEvents = [];
198
+ this.hitsoundEventTimes = [];
199
+ this.hitsoundEventCursor = 0;
200
+ this.activeScheduledSources = /* @__PURE__ */ new Set();
201
+ this.isPlayingRef = () => false;
202
+ this.getTransportCurrentTimeMsRef = () => 0;
203
+ }
204
+ async init() {
205
+ try {
206
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
207
+ await Promise.all(
208
+ HITSOUND_SAMPLE_FILES.map(async (file) => {
209
+ const name = fileBaseName(file);
210
+ const url = `${this.assetBaseUrl}/${file}`;
211
+ const response = await fetch(url);
212
+ if (!response.ok) {
213
+ throw new Error(`Hitsound fetch failed ${response.status}: ${url}`);
214
+ }
215
+ const arrayBuffer = await response.arrayBuffer();
216
+ const audioBuffer = await this.audioContext.decodeAudioData(
217
+ arrayBuffer.slice(0)
218
+ );
219
+ this.hitsoundBuffers.set(name, audioBuffer);
220
+ })
221
+ );
222
+ } catch (_error) {
223
+ this.hitsoundBuffers.clear();
224
+ }
225
+ }
226
+ destroy() {
227
+ this.stopScheduler();
228
+ this.clearScheduledSources();
229
+ if (this.audioContext) {
230
+ this.audioContext.close().catch(() => {
231
+ });
232
+ this.audioContext = null;
233
+ }
234
+ this.hitsoundBuffers.clear();
235
+ this.hitsoundEvents = [];
236
+ this.hitsoundEventTimes = [];
237
+ this.hitsoundEventCursor = 0;
238
+ }
239
+ setVolume(volume) {
240
+ const v = Math.max(0, Math.min(1, Number(volume) || 0));
241
+ this.hitsoundVolume = v;
242
+ }
243
+ buildHitsoundEvents(hitObjectEntries, { getCachedTimingPoint, getSliderDurationCached, getHitObjectEndTime }) {
244
+ this.hitsoundEvents = [];
245
+ for (const [hitTime, hitObject] of hitObjectEntries) {
246
+ const timingPoint = getCachedTimingPoint(hitTime);
247
+ if (!timingPoint) continue;
248
+ if (hitObject.type.includes("Slider")) {
249
+ const sliderDuration = getSliderDurationCached(hitObject, hitTime);
250
+ const sliderInfo = hitObject.sliderInfo;
251
+ const repeat = Number(sliderInfo?.sliderRepeat || 1);
252
+ this.hitsoundEvents.push(
253
+ this.resolveSliderEdgeEvent(
254
+ hitObject,
255
+ timingPoint,
256
+ hitTime,
257
+ 0,
258
+ hitTime
259
+ )
260
+ );
261
+ for (let i = 1; i < repeat; i++) {
262
+ const repeatTime = hitTime + sliderDuration * i / repeat;
263
+ this.hitsoundEvents.push(
264
+ this.resolveSliderEdgeEvent(
265
+ hitObject,
266
+ timingPoint,
267
+ hitTime,
268
+ i,
269
+ repeatTime
270
+ )
271
+ );
272
+ }
273
+ const endTime = hitTime + sliderDuration;
274
+ this.hitsoundEvents.push(
275
+ this.resolveSliderEdgeEvent(
276
+ hitObject,
277
+ timingPoint,
278
+ hitTime,
279
+ repeat,
280
+ endTime
281
+ )
282
+ );
283
+ } else if (hitObject.type.includes("Spinner")) {
284
+ this.hitsoundEvents.push(
285
+ this.resolveSpinnerEndEvent(
286
+ hitObject,
287
+ timingPoint,
288
+ hitTime,
289
+ getHitObjectEndTime
290
+ )
291
+ );
292
+ } else {
293
+ this.hitsoundEvents.push(
294
+ this.resolveCircleEvent(hitObject, timingPoint, hitTime)
295
+ );
296
+ }
297
+ }
298
+ this.hitsoundEvents = this.hitsoundEvents.filter((e) => e && Number.isFinite(e.time)).sort((a, b) => a.time - b.time);
299
+ this.hitsoundEventTimes = this.hitsoundEvents.map((e) => e.time);
300
+ this.hitsoundEventCursor = 0;
301
+ }
302
+ startScheduler({ isPlaying, getTransportCurrentTimeMs }) {
303
+ if (this.schedulerTimer) return;
304
+ this.isPlayingRef = isPlaying;
305
+ this.getTransportCurrentTimeMsRef = getTransportCurrentTimeMs;
306
+ this.tickScheduler();
307
+ this.schedulerTimer = window.setInterval(
308
+ () => this.tickScheduler(),
309
+ this.schedulerIntervalMs
310
+ );
311
+ }
312
+ stopScheduler() {
313
+ if (!this.schedulerTimer) return;
314
+ window.clearInterval(this.schedulerTimer);
315
+ this.schedulerTimer = null;
316
+ }
317
+ tickScheduler() {
318
+ if (!this.isPlayingRef() || !this.audioContext || this.hitsoundEvents.length === 0)
319
+ return;
320
+ if (this.audioContext.state === "suspended") return;
321
+ const transportNow = this.getTransportCurrentTimeMsRef();
322
+ const scheduleUntil = transportNow + this.schedulerLookaheadMs;
323
+ while (this.hitsoundEventCursor < this.hitsoundEvents.length) {
324
+ const event = this.hitsoundEvents[this.hitsoundEventCursor];
325
+ if (event.time > scheduleUntil) break;
326
+ if (event.time >= transportNow - 25) {
327
+ const when = this.audioContext.currentTime + Math.max(0, (event.time - transportNow) / 1e3);
328
+ this.playHitsoundAt(
329
+ event.normalSet,
330
+ event.additionSet,
331
+ event.hitsound,
332
+ when
333
+ );
334
+ }
335
+ this.hitsoundEventCursor += 1;
336
+ }
337
+ }
338
+ resetFromCurrentTime(currentTime) {
339
+ this.clearScheduledSources();
340
+ this.hitsoundEventCursor = lowerBound$1(this.hitsoundEventTimes, currentTime);
341
+ }
342
+ clearScheduledSources() {
343
+ for (const source of this.activeScheduledSources) {
344
+ try {
345
+ source.stop();
346
+ } catch (_e) {
347
+ }
348
+ }
349
+ this.activeScheduledSources.clear();
350
+ }
351
+ playHitsoundAt(normalset, additionSet, hitsound, when) {
352
+ const normal = Number.parseInt(normalset, 10);
353
+ let addition = Number.parseInt(additionSet, 10);
354
+ const soundMask = Number.parseInt(hitsound, 10);
355
+ if (Number.isNaN(normal) || Number.isNaN(soundMask)) return;
356
+ if (normal === 1) this.playHitsoundBufferAt("normal-hitnormal", when);
357
+ if (normal === 2) this.playHitsoundBufferAt("soft-hitnormal", when);
358
+ if (normal === 3) this.playHitsoundBufferAt("drum-hitnormal", when);
359
+ if (addition === 0) addition = normal;
360
+ if (addition === 1) {
361
+ if (soundMask & 2) this.playHitsoundBufferAt("normal-hitwhistle", when);
362
+ if (soundMask & 4) this.playHitsoundBufferAt("normal-hitfinish", when);
363
+ if (soundMask & 8) this.playHitsoundBufferAt("normal-hitclap", when);
364
+ }
365
+ if (addition === 2) {
366
+ if (soundMask & 2) this.playHitsoundBufferAt("soft-hitwhistle", when);
367
+ if (soundMask & 4) this.playHitsoundBufferAt("soft-hitfinish", when);
368
+ if (soundMask & 8) this.playHitsoundBufferAt("soft-hitclap", when);
369
+ }
370
+ if (addition === 3) {
371
+ if (soundMask & 2) this.playHitsoundBufferAt("drum-hitwhistle", when);
372
+ if (soundMask & 4) this.playHitsoundBufferAt("drum-hitfinish", when);
373
+ if (soundMask & 8) this.playHitsoundBufferAt("drum-hitclap", when);
374
+ }
375
+ }
376
+ playHitsoundBufferAt(key, when) {
377
+ if (!this.audioContext || !this.hitsoundBuffers.has(key)) return;
378
+ const source = this.audioContext.createBufferSource();
379
+ const gain = this.audioContext.createGain();
380
+ gain.gain.value = this.hitsoundVolume;
381
+ source.buffer = this.hitsoundBuffers.get(key);
382
+ source.connect(gain);
383
+ gain.connect(this.audioContext.destination);
384
+ source.onended = () => {
385
+ this.activeScheduledSources.delete(source);
386
+ };
387
+ this.activeScheduledSources.add(source);
388
+ source.start(when);
389
+ }
390
+ resolveCircleEvent(hitObject, timingPoint, hitTime) {
391
+ const extrasParts = this.getDelimitedParts(hitObject?.extras, ":");
392
+ const rawNormalSet = extrasParts[0];
393
+ const rawAdditionSet = extrasParts[1];
394
+ const normalSet = rawNormalSet && rawNormalSet !== "0" ? rawNormalSet : String(timingPoint.sampleSet || 1);
395
+ const additionSet = rawAdditionSet && rawAdditionSet !== "0" ? rawAdditionSet : normalSet;
396
+ return {
397
+ time: hitTime,
398
+ normalSet,
399
+ additionSet,
400
+ hitsound: String(hitObject.hitSound || "0")
401
+ };
402
+ }
403
+ resolveSliderEdgeEvent(hitObject, timingPoint, _hitTime, edgeIndex, eventTime) {
404
+ const sliderInfo = hitObject.sliderInfo;
405
+ if (sliderInfo?.edgeSounds && sliderInfo?.edgeSets) {
406
+ const edgeSounds = this.getDelimitedParts(sliderInfo.edgeSounds, "|");
407
+ const edgeSets = this.getDelimitedParts(sliderInfo.edgeSets, "|");
408
+ const hitsound = edgeSounds[edgeIndex] || "0";
409
+ const edgeSetParts = this.getDelimitedParts(
410
+ edgeSets[edgeIndex] || "0:0",
411
+ ":"
412
+ );
413
+ const normalSet = edgeSetParts[0] === "0" ? String(timingPoint.sampleSet || 1) : edgeSetParts[0] || String(timingPoint.sampleSet || 1);
414
+ const additionSet = edgeSetParts[1] === "0" ? normalSet : edgeSetParts[1] || normalSet;
415
+ return { time: eventTime, normalSet, additionSet, hitsound };
416
+ }
417
+ const set = String(timingPoint.sampleSet || 1);
418
+ return { time: eventTime, normalSet: set, additionSet: set, hitsound: "0" };
419
+ }
420
+ resolveSpinnerEndEvent(hitObject, timingPoint, hitTime, getHitObjectEndTime) {
421
+ const spinnerEndTime = getHitObjectEndTime(hitObject, hitTime);
422
+ const extras = hitObject?.extras;
423
+ const hitSampleRaw = Array.isArray(extras) ? extras[1] : null;
424
+ const hitSampleParts = this.getDelimitedParts(hitSampleRaw, ":");
425
+ const rawNormalSet = hitSampleParts[0];
426
+ const rawAdditionSet = hitSampleParts[1];
427
+ const normalSet = rawNormalSet && rawNormalSet !== "0" ? rawNormalSet : String(timingPoint.sampleSet || 1);
428
+ const additionSet = rawAdditionSet && rawAdditionSet !== "0" ? rawAdditionSet : normalSet;
429
+ return {
430
+ time: spinnerEndTime,
431
+ normalSet,
432
+ additionSet,
433
+ hitsound: String(hitObject.hitSound || "0")
434
+ };
435
+ }
436
+ getDelimitedParts(value, delimiter) {
437
+ if (typeof value === "string") {
438
+ return value.split(delimiter);
439
+ }
440
+ if (Array.isArray(value)) {
441
+ return value.map((item) => String(item ?? ""));
442
+ }
443
+ if (value == null) {
444
+ return [];
445
+ }
446
+ return String(value).split(delimiter);
447
+ }
448
+ }
449
+ class PathPoint {
450
+ constructor(x = 0, y = 0) {
451
+ this.x = x;
452
+ this.y = y;
453
+ }
454
+ static compare(point1, point2) {
455
+ return point1.x === point2.x && point1.y === point2.y;
456
+ }
457
+ }
458
+ class Bezier {
459
+ constructor() {
460
+ this.arrPn = [];
461
+ this.mu = 0;
462
+ this.resultPoint = new PathPoint();
463
+ this.initResultPoint();
464
+ this.arcLength = 0;
465
+ this.arcLengthTable = [];
466
+ }
467
+ setBezierN(arrPn) {
468
+ this.arrPn = arrPn.slice();
469
+ this.calculateArcLength();
470
+ }
471
+ bezierCalc() {
472
+ let k, kn, nn, nkn;
473
+ let blend, muk, munk;
474
+ const n = this.arrPn.length - 1;
475
+ this.initResultPoint();
476
+ muk = 1;
477
+ munk = Math.pow(1 - this.mu, n);
478
+ for (k = 0; k <= n; k++) {
479
+ nn = n;
480
+ kn = k;
481
+ nkn = n - k;
482
+ blend = muk * munk;
483
+ muk *= this.mu;
484
+ munk /= 1 - this.mu;
485
+ while (nn >= 1) {
486
+ blend *= nn;
487
+ nn--;
488
+ if (kn > 1) {
489
+ blend /= kn;
490
+ kn--;
491
+ }
492
+ if (nkn > 1) {
493
+ blend /= nkn;
494
+ nkn--;
495
+ }
496
+ }
497
+ this.resultPoint.x += this.arrPn[k].x * blend;
498
+ this.resultPoint.y += this.arrPn[k].y * blend;
499
+ }
500
+ }
501
+ initResultPoint() {
502
+ this.resultPoint.x = 0;
503
+ this.resultPoint.y = 0;
504
+ }
505
+ setMu(mu) {
506
+ this.mu = mu;
507
+ }
508
+ getResult() {
509
+ return this.resultPoint;
510
+ }
511
+ calculateArcLength() {
512
+ this.arcLength = 0;
513
+ this.arcLengthTable = [];
514
+ let prevPoint = this.arrPn[0];
515
+ for (let t = 0; t <= 1; t += 0.01) {
516
+ this.setMu(t);
517
+ this.bezierCalc();
518
+ const currPoint = this.getResult();
519
+ const segmentLength = Math.sqrt(Math.pow(currPoint.x - prevPoint.x, 2) + Math.pow(currPoint.y - prevPoint.y, 2));
520
+ this.arcLength += segmentLength;
521
+ this.arcLengthTable.push({ t, length: this.arcLength });
522
+ prevPoint = { ...currPoint };
523
+ }
524
+ }
525
+ getMuForArcLength(targetLength) {
526
+ for (let i = 0; i < this.arcLengthTable.length - 1; i++) {
527
+ const curr = this.arcLengthTable[i];
528
+ const next = this.arcLengthTable[i + 1];
529
+ if (targetLength >= curr.length && targetLength <= next.length) {
530
+ const ratio = (targetLength - curr.length) / (next.length - curr.length);
531
+ return curr.t + ratio * (next.t - curr.t);
532
+ }
533
+ }
534
+ return 1;
535
+ }
536
+ }
537
+ function getTimingPointAt(givenTime, beatmap) {
538
+ const timingPoints = beatmap["TimingPoints"];
539
+ let closestBPM = null;
540
+ let closestSV = null;
541
+ let sampleSet = null;
542
+ Object.entries(timingPoints).forEach(([time, beatLengths]) => {
543
+ const timeInt = parseInt(time);
544
+ beatLengths.forEach((beatLength) => {
545
+ const beatLengthValue = parseFloat(beatLength[0]);
546
+ if (!sampleSet) sampleSet = parseInt(beatLength[1]);
547
+ if (timeInt <= givenTime) {
548
+ if (beatLengthValue > 0) {
549
+ const bpm = 6e4 / beatLengthValue;
550
+ if (!closestBPM || timeInt > closestBPM.time) {
551
+ closestBPM = { time: timeInt, bpm, beatLengthValue };
552
+ }
553
+ } else {
554
+ if (!closestSV || timeInt > closestSV.time) {
555
+ closestSV = { time: timeInt, sv: beatLengthValue };
556
+ sampleSet = parseInt(beatLength[1]);
557
+ }
558
+ }
559
+ }
560
+ });
561
+ });
562
+ return { closestBPM, closestSV, sampleSet };
563
+ }
564
+ function getFollowPosition(hitObject, hitTime, currentTime, grid_unit) {
565
+ const rawProgress = (currentTime - hitTime) / hitObject.sliderDuration * hitObject.sliderRepeat;
566
+ let sliderInfo = hitObject.sliderInfo;
567
+ if (!sliderInfo || !sliderInfo.anchorPositions || sliderInfo.anchorPositions.length === 0) {
568
+ return {
569
+ x: sliderInfo?.anchorPositions?.[0]?.x || hitObject.hitCircleSprite?.x || 0,
570
+ y: sliderInfo?.anchorPositions?.[0]?.y || hitObject.hitCircleSprite?.y || 0
571
+ };
572
+ }
573
+ const maxProgress = Number(hitObject.sliderRepeat) || 0;
574
+ const clampedProgress = Math.max(0, Math.min(maxProgress, rawProgress));
575
+ let wrappedProgress = clampedProgress % 2;
576
+ if (wrappedProgress < 0) {
577
+ wrappedProgress += 2;
578
+ }
579
+ const sliderProgress = wrappedProgress > 1 ? 2 - wrappedProgress : wrappedProgress;
580
+ let anchorPositions = sliderInfo.anchorPositions;
581
+ const targetLength = sliderProgress * (hitObject.sliderLength * grid_unit);
582
+ const startPos = anchorPositions[0];
583
+ const endPos = sliderInfo.sliderEndPos || anchorPositions[anchorPositions.length - 1] || startPos;
584
+ const EDGE_EPSILON = 1e-4;
585
+ if (sliderProgress <= EDGE_EPSILON) {
586
+ return { x: startPos.x, y: startPos.y };
587
+ }
588
+ if (sliderProgress >= 1 - EDGE_EPSILON) {
589
+ return { x: endPos.x, y: endPos.y };
590
+ }
591
+ let position = {
592
+ x: startPos.x,
593
+ y: startPos.y
594
+ };
595
+ let accumulatedLength = 0;
596
+ switch (sliderInfo.sliderType) {
597
+ case "P": {
598
+ try {
599
+ const circleCenter = getCircleCenter(
600
+ anchorPositions[0],
601
+ anchorPositions[1],
602
+ anchorPositions[2]
603
+ );
604
+ const radius = Math.sqrt(
605
+ Math.pow(circleCenter.x - anchorPositions[0].x, 2) + Math.pow(circleCenter.y - anchorPositions[0].y, 2)
606
+ );
607
+ let yDeltaA = anchorPositions[1].y - anchorPositions[0].y;
608
+ let xDeltaA = anchorPositions[1].x - anchorPositions[0].x;
609
+ let yDeltaB = anchorPositions[2].y - anchorPositions[1].y;
610
+ let xDeltaB = anchorPositions[2].x - anchorPositions[1].x;
611
+ const angleA = Math.atan2(
612
+ anchorPositions[0].y - circleCenter.y,
613
+ anchorPositions[0].x - circleCenter.x
614
+ );
615
+ const angleC = Math.atan2(
616
+ anchorPositions[2].y - circleCenter.y,
617
+ anchorPositions[2].x - circleCenter.x
618
+ );
619
+ const anticlockwise = xDeltaB * yDeltaA - xDeltaA * yDeltaB > 0;
620
+ const startAngle = angleA;
621
+ let endAngle = angleC;
622
+ if (!anticlockwise && endAngle - startAngle < 0) {
623
+ endAngle += 2 * Math.PI;
624
+ }
625
+ if (anticlockwise && endAngle - startAngle > 0) {
626
+ endAngle -= 2 * Math.PI;
627
+ }
628
+ let angleStep = (endAngle - startAngle) / 100;
629
+ let prevX = anchorPositions[0].x;
630
+ let prevY = anchorPositions[0].y;
631
+ let totalLength = 0;
632
+ for (let i = 0; i <= 100; i++) {
633
+ const currentAngle = startAngle + angleStep * i;
634
+ const x = circleCenter.x + radius * Math.cos(currentAngle);
635
+ const y = circleCenter.y + radius * Math.sin(currentAngle);
636
+ if (i > 0) {
637
+ totalLength += Math.sqrt(
638
+ Math.pow(Math.abs(x - prevX), 2) + Math.pow(Math.abs(y - prevY), 2)
639
+ );
640
+ if (totalLength >= targetLength) {
641
+ position = { x, y };
642
+ return position;
643
+ }
644
+ }
645
+ prevX = x;
646
+ prevY = y;
647
+ }
648
+ position = { x: prevX, y: prevY };
649
+ } catch (e) {
650
+ console.error("Error calculating perfect curve position", e);
651
+ }
652
+ break;
653
+ }
654
+ default: {
655
+ try {
656
+ let arrPn = [];
657
+ let bezier = new Bezier();
658
+ arrPn.push(new PathPoint(anchorPositions[0].x, anchorPositions[0].y));
659
+ for (let i = 1; i < anchorPositions.length; i++) {
660
+ let prevPos = anchorPositions[i - 1];
661
+ let currPos = anchorPositions[i];
662
+ if (i === anchorPositions.length - 1 || PathPoint.compare(prevPos, currPos)) {
663
+ if (i === anchorPositions.length - 1) {
664
+ let p = new PathPoint();
665
+ p.x = anchorPositions[i].x;
666
+ p.y = anchorPositions[i].y;
667
+ arrPn.push(p);
668
+ }
669
+ bezier.setBezierN(arrPn);
670
+ const segmentLength = bezier.arcLength;
671
+ if (accumulatedLength + segmentLength >= targetLength) {
672
+ const segmentTargetLength = targetLength - accumulatedLength;
673
+ const mu = bezier.getMuForArcLength(segmentTargetLength);
674
+ bezier.setMu(mu);
675
+ bezier.bezierCalc();
676
+ const result = bezier.getResult();
677
+ if (result && result.x !== void 0 && result.y !== void 0) {
678
+ position = result;
679
+ break;
680
+ }
681
+ }
682
+ accumulatedLength += segmentLength;
683
+ arrPn = [];
684
+ bezier = new Bezier();
685
+ if (i < anchorPositions.length - 1) {
686
+ let p = new PathPoint();
687
+ p.x = prevPos.x;
688
+ p.y = prevPos.y;
689
+ arrPn.push(p);
690
+ }
691
+ } else {
692
+ let p = new PathPoint();
693
+ p.x = anchorPositions[i].x;
694
+ p.y = anchorPositions[i].y;
695
+ arrPn.push(p);
696
+ }
697
+ }
698
+ } catch (e) {
699
+ console.error("Error calculating bezier curve position", e);
700
+ }
701
+ if (!Number.isFinite(position.x) || !Number.isFinite(position.y) || position.x === startPos.x && position.y === startPos.y && sliderProgress > 0.5) {
702
+ position = sliderProgress > 0.5 ? { x: endPos.x, y: endPos.y } : { x: startPos.x, y: startPos.y };
703
+ }
704
+ break;
705
+ }
706
+ }
707
+ return position;
708
+ }
709
+ function getCircleCenter(p1, p2, p3) {
710
+ const mid1 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
711
+ const mid2 = { x: (p2.x + p3.x) / 2, y: (p2.y + p3.y) / 2 };
712
+ const slope1 = (p2.y - p1.y) / (p2.x - p1.x);
713
+ const slope2 = (p3.y - p2.y) / (p3.x - p2.x);
714
+ let perpSlope1 = -1 / slope1;
715
+ let perpSlope2 = -1 / slope2;
716
+ if (p1.x === p2.x) {
717
+ perpSlope1 = 0;
718
+ }
719
+ if (p2.x === p3.x) {
720
+ perpSlope2 = 0;
721
+ }
722
+ if (p1.y === p2.y) {
723
+ perpSlope1 = -1e3;
724
+ }
725
+ if (p2.y === p3.y) {
726
+ perpSlope2 = -1e3;
727
+ }
728
+ const centerX = (perpSlope1 * mid1.x - perpSlope2 * mid2.x + mid2.y - mid1.y) / (perpSlope1 - perpSlope2);
729
+ const centerY = perpSlope1 * (centerX - mid1.x) + mid1.y;
730
+ return { x: centerX, y: centerY };
731
+ }
732
+ function getReverseArrowAngle(sliderInfo) {
733
+ const anchorPositions = sliderInfo.anchorPositions;
734
+ const endPos = sliderInfo.sliderEndPos;
735
+ if (!anchorPositions || anchorPositions.length < 2 || !endPos) {
736
+ return 0;
737
+ }
738
+ const secondLastPoint = anchorPositions[Math.max(0, anchorPositions.length - 2)];
739
+ return Math.atan2(secondLastPoint.y - endPos.y, secondLastPoint.x - endPos.x) + Math.PI;
740
+ }
741
+ function bezierCurve(sliderInfo, layout) {
742
+ const anchorPositions = sliderInfo.anchorPositions;
743
+ if (!sliderInfo.adjusted) {
744
+ adjustAnchorPositions(anchorPositions, layout);
745
+ sliderInfo.adjusted = true;
746
+ }
747
+ const sliderTotalLength = Number(sliderInfo.sliderLength) * layout.gridUnit;
748
+ let totalLength = 0;
749
+ let arrPn = [new PathPoint(anchorPositions[0].x, anchorPositions[0].y)];
750
+ let bezier = new Bezier();
751
+ const sliderPath = new PIXI.Graphics();
752
+ sliderPath.moveTo(anchorPositions[0].x, anchorPositions[0].y);
753
+ for (let i = 1; i < anchorPositions.length; i++) {
754
+ const prevPos = anchorPositions[i - 1];
755
+ const currPos = anchorPositions[i];
756
+ if (i === anchorPositions.length - 1 || PathPoint.compare(prevPos, currPos)) {
757
+ if (i === anchorPositions.length - 1) {
758
+ arrPn.push(new PathPoint(anchorPositions[i].x, anchorPositions[i].y));
759
+ }
760
+ bezier.setBezierN(arrPn);
761
+ const muGap = 10 / Math.max(1, sliderTotalLength);
762
+ const startP = new PathPoint();
763
+ const endP = new PathPoint();
764
+ for (let mu = 0; mu <= 1; mu += muGap) {
765
+ startP.x = bezier.getResult().x;
766
+ startP.y = bezier.getResult().y;
767
+ bezier.setMu(mu);
768
+ bezier.bezierCalc();
769
+ endP.x = bezier.getResult().x;
770
+ endP.y = bezier.getResult().y;
771
+ sliderInfo.sliderEndPos = startP;
772
+ if (Number.isFinite(endP.x) && Number.isFinite(endP.y)) {
773
+ sliderPath.lineTo(endP.x, endP.y);
774
+ sliderInfo.sliderEndPos = endP;
775
+ }
776
+ if (mu === 0) continue;
777
+ totalLength += Math.hypot(startP.x - endP.x, startP.y - endP.y);
778
+ if (totalLength >= sliderTotalLength) break;
779
+ }
780
+ arrPn = [];
781
+ bezier = new Bezier();
782
+ if (i < anchorPositions.length - 1) {
783
+ arrPn.push(new PathPoint(prevPos.x, prevPos.y));
784
+ } else {
785
+ arrPn.push(new PathPoint(currPos.x, currPos.y));
786
+ }
787
+ }
788
+ arrPn.push(new PathPoint(anchorPositions[i].x, anchorPositions[i].y));
789
+ }
790
+ return sliderPath;
791
+ }
792
+ function perfectCurve(sliderInfo, layout) {
793
+ const anchorPositions = sliderInfo.anchorPositions;
794
+ if (!sliderInfo.adjusted) {
795
+ adjustAnchorPositions(anchorPositions, layout);
796
+ sliderInfo.adjusted = true;
797
+ }
798
+ const sliderTotalLength = Number(sliderInfo.sliderLength) * layout.gridUnit;
799
+ let totalLength = 0;
800
+ const sliderPath = new PIXI.Graphics();
801
+ sliderPath.moveTo(anchorPositions[0].x, anchorPositions[0].y);
802
+ const circleCenter = getCircleCenter(anchorPositions[0], anchorPositions[1], anchorPositions[2]);
803
+ const radius = Math.hypot(circleCenter.x - anchorPositions[0].x, circleCenter.y - anchorPositions[0].y);
804
+ const angleA = Math.atan2(anchorPositions[0].y - circleCenter.y, anchorPositions[0].x - circleCenter.x);
805
+ const angleC = Math.atan2(anchorPositions[2].y - circleCenter.y, anchorPositions[2].x - circleCenter.x);
806
+ const yDeltaA = anchorPositions[1].y - anchorPositions[0].y;
807
+ const xDeltaA = anchorPositions[1].x - anchorPositions[0].x;
808
+ const yDeltaB = anchorPositions[2].y - anchorPositions[1].y;
809
+ const xDeltaB = anchorPositions[2].x - anchorPositions[1].x;
810
+ const anticlockwise = xDeltaB * yDeltaA - xDeltaA * yDeltaB > 0;
811
+ const startAngle = angleA;
812
+ let endAngle = angleC;
813
+ if (!anticlockwise && endAngle - startAngle < 0) endAngle += Math.PI * 2;
814
+ if (anticlockwise && endAngle - startAngle > 0) endAngle -= Math.PI * 2;
815
+ const angleStep = (endAngle - startAngle) / 100;
816
+ let prevX;
817
+ let prevY;
818
+ for (let i = 0; i <= 100; i++) {
819
+ const angle = startAngle + angleStep * i;
820
+ const x = circleCenter.x + radius * Math.cos(angle);
821
+ const y = circleCenter.y + radius * Math.sin(angle);
822
+ sliderPath.lineTo(x, y);
823
+ if (i !== 0) {
824
+ totalLength += Math.hypot(x - prevX, y - prevY);
825
+ if (totalLength >= sliderTotalLength) {
826
+ prevX = x;
827
+ prevY = y;
828
+ break;
829
+ }
830
+ }
831
+ prevX = x;
832
+ prevY = y;
833
+ }
834
+ sliderInfo.sliderEndPos = { x: prevX, y: prevY };
835
+ return sliderPath;
836
+ }
837
+ function adjustAnchorPositions(anchorPositions, layout) {
838
+ for (const anchor of anchorPositions) {
839
+ anchor.x = layout.topLeftX + anchor.x * layout.gridUnit;
840
+ anchor.y = layout.topLeftY + anchor.y * layout.gridUnit;
841
+ }
842
+ }
843
+ const DEFAULT_AUDIO_OFFSET_MS = 200;
844
+ let globalAudioOffsetMs = DEFAULT_AUDIO_OFFSET_MS;
845
+ const listeners = /* @__PURE__ */ new Set();
846
+ function getGlobalAudioOffsetMs() {
847
+ return globalAudioOffsetMs;
848
+ }
849
+ function setGlobalAudioOffsetMs(ms) {
850
+ const next = Number(ms) || 0;
851
+ if (next === globalAudioOffsetMs) {
852
+ return;
853
+ }
854
+ globalAudioOffsetMs = next;
855
+ for (const listener of listeners) {
856
+ listener(globalAudioOffsetMs);
857
+ }
858
+ }
859
+ function subscribeGlobalAudioOffset(listener) {
860
+ if (typeof listener !== "function") {
861
+ return () => {
862
+ };
863
+ }
864
+ listeners.add(listener);
865
+ return () => {
866
+ listeners.delete(listener);
867
+ };
868
+ }
869
+ const BASE_WIDTH = 1280;
870
+ const BASE_HEIGHT = 720;
871
+ const BASE_ASPECT_RATIO = BASE_WIDTH / BASE_HEIGHT;
872
+ const LOOKBACK_MS = 1e4;
873
+ const LOOKAHEAD_MS = 200;
874
+ const FADE_OUT_MS = 250;
875
+ const AUDIO_SYNC_SAMPLE_INTERVAL_MS = 200;
876
+ const SLIDER_BODY_ALPHA = 0.9;
877
+ const APPROACH_CIRCLE_Z_BASE = 1e9;
878
+ const SLIDER_BODY_DARKEN_FACTOR = 0.7;
879
+ const SLIDER_RT_OVERSAMPLE = 2;
880
+ class BeatmapEngine {
881
+ /**
882
+ * @param {HTMLElement|string} container - DOM element or element ID to mount the renderer into.
883
+ * @param {object} [options]
884
+ * @param {string} [options.baseUrl=''] - Base URL prefix for legacy updateBeatmap/updateBeatmapBypass
885
+ * token-based endpoints (e.g. '' for same-origin, 'https://example.com').
886
+ * @param {string} [options.assetBaseUrl] - Base URL for bundled skin/hitsound assets (default: GitHub raw `assets/`).
887
+ * @param {number} [options.audioOffsetMs] - Initial audio offset in ms (default: global value from AudioTimingConfig).
888
+ */
889
+ constructor(container, options = {}) {
890
+ if (typeof container === "string") {
891
+ this.containerEl = document.getElementById(container);
892
+ if (!this.containerEl) {
893
+ throw new Error(`Container element not found: ${container}`);
894
+ }
895
+ } else if (container instanceof HTMLElement) {
896
+ this.containerEl = container;
897
+ } else {
898
+ throw new Error(
899
+ "BeatmapEngine requires an HTMLElement or element ID as first argument"
900
+ );
901
+ }
902
+ this.baseUrl = options.baseUrl ?? "";
903
+ this.assetBaseUrl = options.assetBaseUrl ?? DEFAULT_BUNDLED_ASSET_BASE;
904
+ this.app = null;
905
+ this.textures = null;
906
+ this.backgroundSprite = null;
907
+ this.resizeHandler = () => this.resize();
908
+ this.beatmap = null;
909
+ this.hitObjectEntries = [];
910
+ this.hitObjectTimes = [];
911
+ this.activeHitObjects = /* @__PURE__ */ new Set();
912
+ this.timingPointCache = /* @__PURE__ */ new Map();
913
+ this.currentLayout = null;
914
+ this.hitsoundPlayer = null;
915
+ this.audioContext = null;
916
+ this.musicVolume = 0.3;
917
+ this.hitsoundVolume = 0.3;
918
+ this.transportStartMs = 0;
919
+ this.transportStartPerfTime = 0;
920
+ this.transportStartAudioTime = 0;
921
+ this.currentTime = 0;
922
+ this.isPlaying = false;
923
+ this.resumeOnGestureListenerAttached = false;
924
+ this.lastTickTime = null;
925
+ this.previewAudio = null;
926
+ this.beatmapDurationMs = 0;
927
+ this.previewTimeMs = 0;
928
+ this.audioOffsetMs = options.audioOffsetMs !== void 0 ? Number(options.audioOffsetMs) || 0 : getGlobalAudioOffsetMs();
929
+ this.audioSyncOffsetMs = 0;
930
+ this.lastAudioSyncSamplePerfMs = 0;
931
+ }
932
+ async init() {
933
+ const { width, height } = this.getDisplayDimensions();
934
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
935
+ this.app = new Application();
936
+ await this.app.init({
937
+ background: "#000000",
938
+ width,
939
+ height,
940
+ antialias: true,
941
+ resolution: dpr,
942
+ autoDensity: true
943
+ });
944
+ this.app.stage.sortableChildren = true;
945
+ this.containerEl.innerHTML = "";
946
+ this.containerEl.appendChild(this.app.canvas);
947
+ this.backgroundSprite = new PIXI.Sprite();
948
+ this.backgroundSprite.zIndex = -999999;
949
+ this.app.stage.addChild(this.backgroundSprite);
950
+ this.textures = await this.loadAssets();
951
+ this.hitsoundPlayer = new HitsoundPlayer({
952
+ hitsoundVolume: this.hitsoundVolume,
953
+ assetBaseUrl: this.assetBaseUrl
954
+ });
955
+ await this.hitsoundPlayer.init();
956
+ this.audioContext = this.hitsoundPlayer.audioContext;
957
+ window.addEventListener("resize", this.resizeHandler);
958
+ this.app.ticker.add(() => this.updateFrame());
959
+ this.resize();
960
+ }
961
+ destroy() {
962
+ window.removeEventListener("resize", this.resizeHandler);
963
+ this.pause();
964
+ this.hitsoundPlayer?.destroy();
965
+ this.hitsoundPlayer = null;
966
+ if (this.previewAudio) {
967
+ this.previewAudio.pause();
968
+ this.previewAudio.src = "";
969
+ this.previewAudio = null;
970
+ }
971
+ if (this.app) {
972
+ this.app.ticker.stop();
973
+ this.app.destroy(true, { children: true, texture: false });
974
+ this.app = null;
975
+ }
976
+ }
977
+ // ---------------------------------------------------------------------------
978
+ // Beatmap loading
979
+ // ---------------------------------------------------------------------------
980
+ /**
981
+ * Load a beatmap from various sources.
982
+ *
983
+ * @param {object} params
984
+ * @param {string} [params.osuText] - Raw .osu file content (preferred).
985
+ * @param {string} [params.osuUrl] - URL to fetch the .osu file from.
986
+ * @param {string} [params.audioUrl] - URL for the audio track.
987
+ * @param {string} [params.backgroundUrl] - URL for the background image.
988
+ */
989
+ async loadBeatmap({ osuText, osuUrl, audioUrl, backgroundUrl } = {}) {
990
+ this.pause();
991
+ this.setCurrentTime(0);
992
+ if (osuText) {
993
+ this.beatmap = parseOsuText(osuText);
994
+ } else if (osuUrl) {
995
+ this.beatmap = await fetchAndParseOsu(osuUrl);
996
+ } else {
997
+ throw new Error("Either osuText or osuUrl must be provided");
998
+ }
999
+ this.rebuildHitObjectCache();
1000
+ if (backgroundUrl) {
1001
+ await this.updateBackgroundFromUrl(backgroundUrl);
1002
+ }
1003
+ if (audioUrl) {
1004
+ this.updateAudioTrackFromUrl(audioUrl);
1005
+ }
1006
+ const previewTime = Number.parseInt(
1007
+ this.beatmap?.General?.PreviewTime || "0",
1008
+ 10
1009
+ );
1010
+ const safePreviewTime = Number.isFinite(previewTime) ? Math.max(0, previewTime) : 0;
1011
+ this.previewTimeMs = safePreviewTime;
1012
+ this.setCurrentTime(safePreviewTime);
1013
+ }
1014
+ /**
1015
+ * Legacy method for omqWeb: load beatmap using token-based endpoints.
1016
+ * Requires baseUrl to be set in constructor options if running cross-origin.
1017
+ */
1018
+ async updateBeatmap(beatmapId, imageToken = beatmapId, audioToken = beatmapId) {
1019
+ this.pause();
1020
+ this.setCurrentTime(0);
1021
+ this.beatmap = await fetchAndParseOsu(
1022
+ `${this.baseUrl}/beatmap/${beatmapId}`
1023
+ );
1024
+ this.rebuildHitObjectCache();
1025
+ await this.updateBackgroundFromUrl(
1026
+ `${this.baseUrl}/image/${imageToken}`
1027
+ );
1028
+ if (audioToken) {
1029
+ this.updateAudioTrackFromUrl(`${this.baseUrl}/audio/${audioToken}`);
1030
+ }
1031
+ const previewTime = Number.parseInt(
1032
+ this.beatmap?.General?.PreviewTime || "0",
1033
+ 10
1034
+ );
1035
+ const safePreviewTime = Number.isFinite(previewTime) ? Math.max(0, previewTime) : 0;
1036
+ this.previewTimeMs = safePreviewTime;
1037
+ this.setCurrentTime(safePreviewTime);
1038
+ }
1039
+ /**
1040
+ * Legacy method for omqWeb debug: load beatmap bypassing normal endpoint.
1041
+ */
1042
+ async updateBeatmapBypass(beatmapId) {
1043
+ this.pause();
1044
+ this.setCurrentTime(0);
1045
+ this.beatmap = await fetchAndParseOsu(
1046
+ `${this.baseUrl}/beatmapbp/${beatmapId}`
1047
+ );
1048
+ this.rebuildHitObjectCache();
1049
+ const previewTime = Number.parseInt(
1050
+ this.beatmap?.General?.PreviewTime || "0",
1051
+ 10
1052
+ );
1053
+ const safePreviewTime = Number.isFinite(previewTime) ? Math.max(0, previewTime) : 0;
1054
+ this.previewTimeMs = safePreviewTime;
1055
+ this.setCurrentTime(safePreviewTime);
1056
+ }
1057
+ // ---------------------------------------------------------------------------
1058
+ // Audio
1059
+ // ---------------------------------------------------------------------------
1060
+ updateAudioTrackFromUrl(audioUrl) {
1061
+ if (this.previewAudio) {
1062
+ this.previewAudio.pause();
1063
+ this.previewAudio.src = "";
1064
+ }
1065
+ this.previewAudio = new Audio(audioUrl);
1066
+ this.previewAudio.preload = "auto";
1067
+ this.previewAudio.volume = this.musicVolume;
1068
+ this.previewAudio.crossOrigin = "anonymous";
1069
+ }
1070
+ /** @deprecated Use updateAudioTrackFromUrl */
1071
+ updateAudioTrack(audioToken) {
1072
+ this.updateAudioTrackFromUrl(`${this.baseUrl}/audio/${audioToken}`);
1073
+ }
1074
+ // ---------------------------------------------------------------------------
1075
+ // Background
1076
+ // ---------------------------------------------------------------------------
1077
+ async updateBackgroundFromUrl(imageUrl) {
1078
+ if (!this.app || !this.backgroundSprite) return;
1079
+ if (!imageUrl) {
1080
+ this.backgroundSprite.texture = PIXI.Texture.EMPTY;
1081
+ this.backgroundSprite.scale.set(1);
1082
+ this.backgroundSprite.x = 0;
1083
+ this.backgroundSprite.y = 0;
1084
+ return;
1085
+ }
1086
+ try {
1087
+ const img = new Image();
1088
+ img.crossOrigin = "anonymous";
1089
+ await new Promise((resolve, reject) => {
1090
+ img.onload = resolve;
1091
+ img.onerror = reject;
1092
+ img.src = imageUrl;
1093
+ });
1094
+ const texture = PIXI.Texture.from(img);
1095
+ this.applyLinearTextureFiltering(texture);
1096
+ const { width, height } = this.getDisplayDimensions();
1097
+ const scaleX = width / texture.width;
1098
+ const scaleY = height / texture.height;
1099
+ const scale = Math.max(scaleX, scaleY);
1100
+ this.backgroundSprite.texture = texture;
1101
+ this.backgroundSprite.scale.set(scale);
1102
+ this.backgroundSprite.x = (width - texture.width * scale) / 2;
1103
+ this.backgroundSprite.y = (height - texture.height * scale) / 2;
1104
+ } catch (_error) {
1105
+ this.backgroundSprite.texture = null;
1106
+ }
1107
+ }
1108
+ /** @deprecated Use updateBackgroundFromUrl */
1109
+ async updateBackground(imageToken) {
1110
+ await this.updateBackgroundFromUrl(
1111
+ `${this.baseUrl}/image/${imageToken}`
1112
+ );
1113
+ }
1114
+ async setBackground(imageUrl) {
1115
+ await this.updateBackgroundFromUrl(imageUrl);
1116
+ }
1117
+ // ---------------------------------------------------------------------------
1118
+ // Playback controls
1119
+ // ---------------------------------------------------------------------------
1120
+ play(options = {}) {
1121
+ if (this.isPlaying) {
1122
+ return;
1123
+ }
1124
+ const enableMusic = options.enableAudio ?? options.audio ?? options.withAudio ?? true;
1125
+ const enableHitsounds = options.enableHitsounds ?? true;
1126
+ this.transportStartMs = this.currentTime;
1127
+ this.transportStartPerfTime = performance.now();
1128
+ this.transportStartAudioTime = this.audioContext ? this.audioContext.currentTime : 0;
1129
+ this.isPlaying = true;
1130
+ this.audioSyncOffsetMs = 0;
1131
+ this.lastAudioSyncSamplePerfMs = 0;
1132
+ this.lastTickTime = performance.now();
1133
+ const shouldResumeAudioContext = enableMusic || enableHitsounds;
1134
+ if (shouldResumeAudioContext && this.audioContext?.state === "suspended") {
1135
+ this.audioContext.resume().catch(() => {
1136
+ });
1137
+ if (!this.resumeOnGestureListenerAttached) {
1138
+ this.resumeOnGestureListenerAttached = true;
1139
+ const resumeOnGesture = () => {
1140
+ this.resumeOnGestureListenerAttached = false;
1141
+ this.audioContext?.resume().catch(() => {
1142
+ });
1143
+ if (enableMusic) {
1144
+ this.previewAudio?.play().catch(() => {
1145
+ });
1146
+ }
1147
+ };
1148
+ document.addEventListener("pointerdown", resumeOnGesture, {
1149
+ once: true
1150
+ });
1151
+ }
1152
+ }
1153
+ if (this.previewAudio) {
1154
+ const shiftedMs = this.getPreviewAudioTimeMsForBeatmapTime(
1155
+ this.currentTime
1156
+ );
1157
+ this.previewAudio.currentTime = Math.max(0, shiftedMs) / 1e3;
1158
+ if (enableMusic) {
1159
+ this.previewAudio.play().catch(() => {
1160
+ });
1161
+ } else {
1162
+ this.previewAudio.pause();
1163
+ }
1164
+ }
1165
+ if (enableHitsounds) {
1166
+ this.hitsoundPlayer?.startScheduler({
1167
+ isPlaying: () => this.isPlaying,
1168
+ getTransportCurrentTimeMs: () => this.getTransportCurrentTimeMs()
1169
+ });
1170
+ } else {
1171
+ this.hitsoundPlayer?.stopScheduler();
1172
+ this.hitsoundPlayer?.clearScheduledSources();
1173
+ }
1174
+ }
1175
+ pause() {
1176
+ if (!this.isPlaying) {
1177
+ return;
1178
+ }
1179
+ this.currentTime = this.getTransportCurrentTimeMs();
1180
+ this.isPlaying = false;
1181
+ this.hitsoundPlayer?.stopScheduler();
1182
+ this.hitsoundPlayer?.clearScheduledSources();
1183
+ if (this.previewAudio) {
1184
+ this.previewAudio.pause();
1185
+ }
1186
+ }
1187
+ getCurrentTime() {
1188
+ if (this.isPlaying) {
1189
+ return this.getTransportCurrentTimeMs();
1190
+ }
1191
+ return this.currentTime;
1192
+ }
1193
+ getDuration() {
1194
+ if (this.previewAudio && Number.isFinite(this.previewAudio.duration) && this.previewAudio.duration > 0) {
1195
+ return Math.max(
1196
+ this.beatmapDurationMs,
1197
+ this.previewAudio.duration * 1e3
1198
+ );
1199
+ }
1200
+ return Math.max(0, this.beatmapDurationMs);
1201
+ }
1202
+ setCurrentTime(ms) {
1203
+ if (!Number.isFinite(ms)) {
1204
+ return;
1205
+ }
1206
+ this.currentTime = Math.max(0, ms);
1207
+ this.transportStartMs = this.currentTime;
1208
+ this.transportStartPerfTime = performance.now();
1209
+ this.transportStartAudioTime = this.audioContext ? this.audioContext.currentTime : 0;
1210
+ this.lastAudioSyncSamplePerfMs = 0;
1211
+ if (this.previewAudio) {
1212
+ const shiftedMs = this.getPreviewAudioTimeMsForBeatmapTime(
1213
+ this.currentTime
1214
+ );
1215
+ this.previewAudio.currentTime = Math.max(0, shiftedMs) / 1e3;
1216
+ if (this.isPlaying && !this.previewAudio.paused) {
1217
+ const audioDerivedVisualMs = this.getBeatmapTimeMsForPreviewAudioTime(
1218
+ this.previewAudio.currentTime * 1e3
1219
+ );
1220
+ if (Number.isFinite(audioDerivedVisualMs)) {
1221
+ this.currentTime = Math.max(0, audioDerivedVisualMs);
1222
+ this.transportStartMs = this.currentTime;
1223
+ this.transportStartPerfTime = performance.now();
1224
+ this.transportStartAudioTime = this.audioContext ? this.audioContext.currentTime : 0;
1225
+ this.audioSyncOffsetMs = 0;
1226
+ this.lastAudioSyncSamplePerfMs = 0;
1227
+ }
1228
+ }
1229
+ }
1230
+ this.resetHitsoundPlaybackFlags();
1231
+ this.hitsoundPlayer?.resetFromCurrentTime(this.currentTime);
1232
+ this.resetActiveObjects();
1233
+ }
1234
+ seek(ms) {
1235
+ this.setCurrentTime(ms);
1236
+ }
1237
+ seekToPreview() {
1238
+ this.seek(this.previewTimeMs || 0);
1239
+ }
1240
+ // ---------------------------------------------------------------------------
1241
+ // Volume
1242
+ // ---------------------------------------------------------------------------
1243
+ setVolume(volume) {
1244
+ const v = Math.max(0, Math.min(1, Number(volume) || 0));
1245
+ this.musicVolume = v;
1246
+ this.hitsoundVolume = v;
1247
+ if (this.previewAudio) {
1248
+ this.previewAudio.volume = v;
1249
+ }
1250
+ this.hitsoundPlayer?.setVolume(v);
1251
+ }
1252
+ setMusicVolume(volume) {
1253
+ const v = Math.max(0, Math.min(1, Number(volume) || 0));
1254
+ this.musicVolume = v;
1255
+ if (this.previewAudio) {
1256
+ this.previewAudio.volume = v;
1257
+ }
1258
+ }
1259
+ setHitsoundVolume(volume) {
1260
+ const v = Math.max(0, Math.min(1, Number(volume) || 0));
1261
+ this.hitsoundVolume = v;
1262
+ this.hitsoundPlayer?.setVolume(v);
1263
+ }
1264
+ setBackgroundVisible(visible) {
1265
+ if (!this.backgroundSprite) return;
1266
+ this.backgroundSprite.alpha = visible ? 1 : 0;
1267
+ }
1268
+ setAudioOffsetMs(ms) {
1269
+ const v = Number(ms) || 0;
1270
+ this.audioOffsetMs = v;
1271
+ this.audioSyncOffsetMs = 0;
1272
+ this.lastAudioSyncSamplePerfMs = 0;
1273
+ if (this.previewAudio) {
1274
+ const shiftedMs = this.getPreviewAudioTimeMsForBeatmapTime(
1275
+ this.currentTime
1276
+ );
1277
+ this.previewAudio.currentTime = Math.max(0, shiftedMs) / 1e3;
1278
+ }
1279
+ }
1280
+ // ---------------------------------------------------------------------------
1281
+ // Internal: hit object cache
1282
+ // ---------------------------------------------------------------------------
1283
+ rebuildHitObjectCache() {
1284
+ this.resetActiveObjects();
1285
+ this.timingPointCache.clear();
1286
+ if (!this.beatmap || !this.beatmap.HitObjects) {
1287
+ this.hitObjectEntries = [];
1288
+ this.hitObjectTimes = [];
1289
+ this.beatmapDurationMs = 0;
1290
+ return;
1291
+ }
1292
+ this.hitObjectEntries = Object.entries(this.beatmap.HitObjects).map(([time, obj]) => [Number.parseInt(time, 10), obj]).filter(([time]) => Number.isFinite(time)).sort((a, b) => a[0] - b[0]);
1293
+ this.hitObjectTimes = this.hitObjectEntries.map(([time]) => time);
1294
+ for (const [time, hitObject] of this.hitObjectEntries) {
1295
+ this.prepareHitObject(hitObject, time);
1296
+ }
1297
+ this.beatmapDurationMs = this.computeBeatmapDurationMs();
1298
+ this.hitsoundPlayer?.buildHitsoundEvents(this.hitObjectEntries, {
1299
+ getCachedTimingPoint: (hitTime) => this.getCachedTimingPoint(hitTime),
1300
+ getSliderDurationCached: (hitObject, hitTime) => this.getSliderDurationCached(hitObject, hitTime),
1301
+ getHitObjectEndTime: (hitObject, hitTime) => this.getHitObjectEndTime(hitObject, hitTime)
1302
+ });
1303
+ this.hitsoundPlayer?.resetFromCurrentTime(this.currentTime);
1304
+ }
1305
+ resetActiveObjects() {
1306
+ for (const hitObject of this.activeHitObjects) {
1307
+ this.removeHitObject(hitObject);
1308
+ }
1309
+ this.activeHitObjects.clear();
1310
+ }
1311
+ resetHitsoundPlaybackFlags() {
1312
+ for (const [, hitObject] of this.hitObjectEntries) {
1313
+ hitObject._hitsoundHeadPlayed = false;
1314
+ hitObject._hitsoundSliderEndPlayed = false;
1315
+ hitObject._hitsoundSliderRepeatPlayed = {};
1316
+ }
1317
+ }
1318
+ // ---------------------------------------------------------------------------
1319
+ // Internal: resize
1320
+ // ---------------------------------------------------------------------------
1321
+ resize() {
1322
+ if (!this.app) {
1323
+ return;
1324
+ }
1325
+ const { width, height } = this.getDisplayDimensions();
1326
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
1327
+ if (this.app.renderer.resolution !== dpr) {
1328
+ this.app.renderer.resolution = dpr;
1329
+ }
1330
+ this.app.renderer.resize(width, height);
1331
+ if (this.backgroundSprite?.texture) {
1332
+ const scaleX = width / this.backgroundSprite.texture.width;
1333
+ const scaleY = height / this.backgroundSprite.texture.height;
1334
+ const scale = Math.max(scaleX, scaleY);
1335
+ this.backgroundSprite.scale.set(scale);
1336
+ this.backgroundSprite.x = (width - this.backgroundSprite.texture.width * scale) / 2;
1337
+ this.backgroundSprite.y = (height - this.backgroundSprite.texture.height * scale) / 2;
1338
+ }
1339
+ this.rebuildHitObjectVisualsForResize();
1340
+ }
1341
+ rebuildHitObjectVisualsForResize() {
1342
+ this.resetActiveObjects();
1343
+ if (!this.hitObjectEntries?.length) {
1344
+ return;
1345
+ }
1346
+ for (const [, hitObject] of this.hitObjectEntries) {
1347
+ this.destroyHitObjectVisuals(hitObject);
1348
+ }
1349
+ }
1350
+ // ---------------------------------------------------------------------------
1351
+ // Internal: rendering frame loop
1352
+ // ---------------------------------------------------------------------------
1353
+ updateFrame() {
1354
+ if (!this.app || !this.beatmap || !this.beatmap.HitObjects) {
1355
+ return;
1356
+ }
1357
+ this.currentTime = this.isPlaying ? this.getTransportCurrentTimeMs() : this.currentTime;
1358
+ const { preempt, fadeIn } = this.calculateARValues();
1359
+ this.currentLayout = this.getLayout();
1360
+ const rangeStart = lowerBound(
1361
+ this.hitObjectTimes,
1362
+ this.currentTime - LOOKBACK_MS
1363
+ );
1364
+ const rangeEnd = upperBound(
1365
+ this.hitObjectTimes,
1366
+ this.currentTime + preempt + LOOKAHEAD_MS
1367
+ );
1368
+ const nextActive = /* @__PURE__ */ new Set();
1369
+ for (let i = rangeStart; i < rangeEnd; i++) {
1370
+ const [, hitObject] = this.hitObjectEntries[i];
1371
+ const hitTime = Number(hitObject.time);
1372
+ const timeDiff = hitTime - this.currentTime;
1373
+ const alpha = Math.min(1, Math.max(0, (preempt - timeDiff) / fadeIn));
1374
+ if (hitObject.type.includes("Spinner")) {
1375
+ const spinnerEndTime = this.getHitObjectEndTime(hitObject, hitTime);
1376
+ if (this.currentTime >= hitTime && this.currentTime <= spinnerEndTime) {
1377
+ this.addOrUpdateHitObject(hitObject, alpha, preempt, timeDiff);
1378
+ nextActive.add(hitObject);
1379
+ }
1380
+ } else if (timeDiff < preempt && !hitObject.type.includes("Slider") && timeDiff > -FADE_OUT_MS) {
1381
+ this.addOrUpdateHitObject(hitObject, alpha, preempt, timeDiff);
1382
+ nextActive.add(hitObject);
1383
+ } else if (hitObject.type.includes("Slider")) {
1384
+ const sliderDuration = this.getSliderDurationCached(hitObject, hitTime);
1385
+ const sliderEndTime = hitTime + sliderDuration;
1386
+ if (timeDiff < preempt && this.currentTime <= sliderEndTime + FADE_OUT_MS * 2) {
1387
+ this.addOrUpdateHitObject(hitObject, alpha, preempt, timeDiff);
1388
+ nextActive.add(hitObject);
1389
+ }
1390
+ }
1391
+ }
1392
+ for (const hitObject of this.activeHitObjects) {
1393
+ if (!nextActive.has(hitObject)) {
1394
+ this.removeHitObject(hitObject);
1395
+ }
1396
+ }
1397
+ this.activeHitObjects = nextActive;
1398
+ }
1399
+ addOrUpdateHitObject(hitObject, alpha, preempt, timeDiff) {
1400
+ const { centerX, centerY, gridUnit, topLeftX, topLeftY } = this.currentLayout || this.getLayout();
1401
+ const hitTime = Number(hitObject.time);
1402
+ const zIndex = -hitTime;
1403
+ const approachZIndex = APPROACH_CIRCLE_Z_BASE - hitTime;
1404
+ if (hitObject.type.includes("Spinner")) {
1405
+ if (!hitObject.approachSprite) {
1406
+ const approachSprite = new PIXI.Sprite(this.textures.texture_approach);
1407
+ approachSprite.anchor.set(0.5);
1408
+ approachSprite.x = centerX;
1409
+ approachSprite.y = centerY;
1410
+ approachSprite.width = gridUnit * 512;
1411
+ approachSprite.height = gridUnit * 512;
1412
+ approachSprite.zIndex = approachZIndex;
1413
+ this.app.stage.addChild(approachSprite);
1414
+ hitObject.approachSprite = approachSprite;
1415
+ }
1416
+ const spinnerEndTime = this.getHitObjectEndTime(hitObject, hitTime);
1417
+ const spinnerDuration = Math.max(1, spinnerEndTime - hitTime);
1418
+ const progress = Math.max(
1419
+ 0,
1420
+ Math.min(1, (this.currentTime - hitTime) / spinnerDuration)
1421
+ );
1422
+ const spinnerScale = 1 - progress;
1423
+ const spinnerBaseSize = gridUnit * 512;
1424
+ this.showHitObject(hitObject);
1425
+ hitObject.approachSprite.alpha = 1;
1426
+ hitObject.approachSprite.width = spinnerBaseSize * spinnerScale;
1427
+ hitObject.approachSprite.height = spinnerBaseSize * spinnerScale;
1428
+ return;
1429
+ }
1430
+ if (!hitObject.hitCircleSprite) {
1431
+ const posX = topLeftX + Number(hitObject.x) * gridUnit;
1432
+ const posY = topLeftY + Number(hitObject.y) * gridUnit;
1433
+ const circleSize = this.getCircleSize(gridUnit);
1434
+ const comboColor = this.getComboColor(hitObject.comboColor);
1435
+ hitObject.hitCircleSprite = new PIXI.Sprite(
1436
+ this.textures.texture_hitcircle
1437
+ );
1438
+ hitObject.hitCircleSprite.anchor.set(0.5);
1439
+ hitObject.hitCircleSprite.x = posX;
1440
+ hitObject.hitCircleSprite.y = posY;
1441
+ hitObject.hitCircleSprite.width = circleSize;
1442
+ hitObject.hitCircleSprite.height = circleSize;
1443
+ hitObject.hitCircleSprite.tint = comboColor;
1444
+ hitObject.hitCircleSprite.zIndex = zIndex + 3;
1445
+ hitObject.hitCircleOverlaySprite = new PIXI.Sprite(
1446
+ this.textures.texture_hitcircleoverlay
1447
+ );
1448
+ hitObject.hitCircleOverlaySprite.anchor.set(0.5);
1449
+ hitObject.hitCircleOverlaySprite.x = posX;
1450
+ hitObject.hitCircleOverlaySprite.y = posY;
1451
+ hitObject.hitCircleOverlaySprite.width = circleSize;
1452
+ hitObject.hitCircleOverlaySprite.height = circleSize;
1453
+ hitObject.hitCircleOverlaySprite.zIndex = zIndex + 4;
1454
+ hitObject.approachSprite = new PIXI.Sprite(
1455
+ this.textures.texture_approach
1456
+ );
1457
+ hitObject.approachSprite.anchor.set(0.5);
1458
+ hitObject.approachSprite.x = posX;
1459
+ hitObject.approachSprite.y = posY;
1460
+ hitObject.approachSprite.tint = comboColor;
1461
+ hitObject.approachSprite.zIndex = approachZIndex;
1462
+ hitObject.comboText = new PIXI.Text({
1463
+ text: hitObject.combo || "",
1464
+ style: {
1465
+ fill: 16777215
1466
+ }
1467
+ });
1468
+ hitObject.comboText.anchor.set(0.5);
1469
+ hitObject.comboText.x = posX;
1470
+ hitObject.comboText.y = posY;
1471
+ hitObject.comboText.zIndex = zIndex + 6;
1472
+ const textScale = Math.min(
1473
+ circleSize / (hitObject.comboText.width || 1),
1474
+ circleSize / (hitObject.comboText.height || 1)
1475
+ ) * 0.4;
1476
+ hitObject.comboText.scale.set(textScale);
1477
+ hitObject._comboBaseScale = textScale;
1478
+ this.app.stage.addChild(hitObject.approachSprite);
1479
+ this.app.stage.addChild(hitObject.hitCircleSprite);
1480
+ this.app.stage.addChild(hitObject.hitCircleOverlaySprite);
1481
+ this.app.stage.addChild(hitObject.comboText);
1482
+ if (hitObject.type.includes("Slider")) {
1483
+ this.addSlider(hitObject, posX, posY, circleSize, zIndex, comboColor);
1484
+ }
1485
+ }
1486
+ this.showHitObject(hitObject);
1487
+ const scale = 0.9 + timeDiff / preempt * 3;
1488
+ if (hitObject.approachSprite) {
1489
+ if (scale > 1) {
1490
+ hitObject.approachSprite.width = hitObject.hitCircleSprite.width * scale;
1491
+ hitObject.approachSprite.height = hitObject.hitCircleSprite.height * scale;
1492
+ hitObject.approachSprite.alpha = Math.max(0, Math.min(1, alpha));
1493
+ } else {
1494
+ hitObject.approachSprite.alpha = 0;
1495
+ }
1496
+ }
1497
+ if (hitObject.hitCircleSprite && !hitObject.type.includes("Slider")) {
1498
+ const circleSize = this.getCircleSize(gridUnit);
1499
+ if (timeDiff <= 0) {
1500
+ const fadeProgress = this.getHitBurstProgress(timeDiff);
1501
+ const fadeAlpha = this.getHitBurstAlpha(fadeProgress);
1502
+ hitObject.hitCircleSprite.alpha = fadeAlpha;
1503
+ hitObject.hitCircleOverlaySprite.alpha = fadeAlpha;
1504
+ hitObject.comboText.alpha = fadeAlpha;
1505
+ const grownSize = this.getHitBurstSize(circleSize, fadeProgress);
1506
+ const comboScale = this.getHitBurstScale(
1507
+ hitObject._comboBaseScale,
1508
+ fadeProgress
1509
+ );
1510
+ hitObject.hitCircleSprite.width = grownSize;
1511
+ hitObject.hitCircleSprite.height = grownSize;
1512
+ hitObject.hitCircleOverlaySprite.width = grownSize;
1513
+ hitObject.hitCircleOverlaySprite.height = grownSize;
1514
+ hitObject.comboText.scale.set(comboScale);
1515
+ } else {
1516
+ hitObject.hitCircleSprite.width = circleSize;
1517
+ hitObject.hitCircleSprite.height = circleSize;
1518
+ hitObject.hitCircleOverlaySprite.width = circleSize;
1519
+ hitObject.hitCircleOverlaySprite.height = circleSize;
1520
+ hitObject.hitCircleSprite.alpha = Math.max(0, Math.min(1, alpha));
1521
+ hitObject.hitCircleOverlaySprite.alpha = Math.max(
1522
+ 0,
1523
+ Math.min(1, alpha)
1524
+ );
1525
+ hitObject.comboText.alpha = Math.max(0, Math.min(1, alpha));
1526
+ hitObject.comboText.scale.set(hitObject._comboBaseScale || 1);
1527
+ }
1528
+ }
1529
+ if (hitObject.sliderSprite) {
1530
+ const hitTimeMs = Number(hitObject.time);
1531
+ const sliderDuration = this.getSliderDurationCached(hitObject, hitTimeMs);
1532
+ const sliderEndTime = hitTimeMs + sliderDuration;
1533
+ const sliderRepeat = Number(hitObject.sliderInfo?.sliderRepeat || 1);
1534
+ const isPreHit = this.currentTime < hitTimeMs;
1535
+ const inPostSliderFade = sliderDuration > 0 && this.currentTime > sliderEndTime;
1536
+ const sliderAlpha = isPreHit ? Math.max(0.02, Math.min(1, alpha)) : 1;
1537
+ const { gridUnit: sliderGridUnit } = this.currentLayout || this.getLayout();
1538
+ const sliderHeadBaseSize = this.getCircleSize(sliderGridUnit);
1539
+ if (inPostSliderFade) {
1540
+ const fadeProgress = Math.min(
1541
+ 1,
1542
+ (this.currentTime - sliderEndTime) / FADE_OUT_MS
1543
+ );
1544
+ const fadeAlpha = Math.max(0, 1 - fadeProgress);
1545
+ hitObject.sliderSprite.alpha = fadeAlpha;
1546
+ hitObject.hitCircleSprite_sliderend.alpha = fadeAlpha;
1547
+ hitObject.hitCircleOverlaySprite_sliderend.alpha = fadeAlpha;
1548
+ if (hitObject.reverseSprite) {
1549
+ hitObject.reverseSprite.visible = this.hasRemainingSliderRepeatsAtEdge(
1550
+ this.currentTime,
1551
+ hitTimeMs,
1552
+ sliderDuration,
1553
+ sliderRepeat,
1554
+ "end"
1555
+ );
1556
+ hitObject.reverseSprite.alpha = fadeAlpha;
1557
+ }
1558
+ if (hitObject.reverseSpriteHead) {
1559
+ hitObject.reverseSpriteHead.visible = this.currentTime >= hitTimeMs && this.hasRemainingSliderRepeatsAtEdge(
1560
+ this.currentTime,
1561
+ hitTimeMs,
1562
+ sliderDuration,
1563
+ sliderRepeat,
1564
+ "start"
1565
+ );
1566
+ hitObject.reverseSpriteHead.alpha = fadeAlpha;
1567
+ }
1568
+ if (hitObject.followCircle) {
1569
+ hitObject.followCircle.alpha = fadeAlpha * 0.7;
1570
+ }
1571
+ } else {
1572
+ hitObject.sliderSprite.alpha = sliderAlpha;
1573
+ hitObject.hitCircleSprite_sliderend.alpha = sliderAlpha;
1574
+ hitObject.hitCircleOverlaySprite_sliderend.alpha = sliderAlpha;
1575
+ if (hitObject.reverseSprite) {
1576
+ hitObject.reverseSprite.visible = this.hasRemainingSliderRepeatsAtEdge(
1577
+ this.currentTime,
1578
+ hitTimeMs,
1579
+ sliderDuration,
1580
+ sliderRepeat,
1581
+ "end"
1582
+ );
1583
+ hitObject.reverseSprite.alpha = sliderAlpha;
1584
+ }
1585
+ if (hitObject.reverseSpriteHead) {
1586
+ hitObject.reverseSpriteHead.visible = this.currentTime >= hitTimeMs && this.hasRemainingSliderRepeatsAtEdge(
1587
+ this.currentTime,
1588
+ hitTimeMs,
1589
+ sliderDuration,
1590
+ sliderRepeat,
1591
+ "start"
1592
+ );
1593
+ hitObject.reverseSpriteHead.alpha = sliderAlpha;
1594
+ }
1595
+ }
1596
+ if (timeDiff <= 0 && -timeDiff <= FADE_OUT_MS) {
1597
+ const headFadeProgress = this.getHitBurstProgress(timeDiff);
1598
+ const headFadeAlpha = this.getHitBurstAlpha(headFadeProgress);
1599
+ const grownSize = this.getHitBurstSize(
1600
+ sliderHeadBaseSize,
1601
+ headFadeProgress
1602
+ );
1603
+ const comboScale = this.getHitBurstScale(
1604
+ hitObject._comboBaseScale,
1605
+ headFadeProgress
1606
+ );
1607
+ hitObject.hitCircleSprite.alpha = headFadeAlpha;
1608
+ hitObject.hitCircleOverlaySprite.alpha = headFadeAlpha;
1609
+ hitObject.comboText.alpha = headFadeAlpha;
1610
+ hitObject.hitCircleSprite.width = grownSize;
1611
+ hitObject.hitCircleSprite.height = grownSize;
1612
+ hitObject.hitCircleOverlaySprite.width = grownSize;
1613
+ hitObject.hitCircleOverlaySprite.height = grownSize;
1614
+ hitObject.comboText.scale.set(comboScale);
1615
+ } else if (timeDiff <= 0) {
1616
+ hitObject.hitCircleSprite.width = sliderHeadBaseSize;
1617
+ hitObject.hitCircleSprite.height = sliderHeadBaseSize;
1618
+ hitObject.hitCircleOverlaySprite.width = sliderHeadBaseSize;
1619
+ hitObject.hitCircleOverlaySprite.height = sliderHeadBaseSize;
1620
+ hitObject.hitCircleSprite.alpha = 0;
1621
+ hitObject.hitCircleOverlaySprite.alpha = 0;
1622
+ hitObject.comboText.alpha = 0;
1623
+ hitObject.comboText.scale.set(hitObject._comboBaseScale || 1);
1624
+ } else {
1625
+ hitObject.hitCircleSprite.width = sliderHeadBaseSize;
1626
+ hitObject.hitCircleSprite.height = sliderHeadBaseSize;
1627
+ hitObject.hitCircleOverlaySprite.width = sliderHeadBaseSize;
1628
+ hitObject.hitCircleOverlaySprite.height = sliderHeadBaseSize;
1629
+ hitObject.hitCircleSprite.alpha = sliderAlpha;
1630
+ hitObject.hitCircleOverlaySprite.alpha = sliderAlpha;
1631
+ hitObject.comboText.alpha = sliderAlpha;
1632
+ hitObject.comboText.scale.set(hitObject._comboBaseScale || 1);
1633
+ }
1634
+ this.updateSliderFollowCircle(hitObject, hitTime, timeDiff);
1635
+ }
1636
+ }
1637
+ // ---------------------------------------------------------------------------
1638
+ // Internal: slider follow circle & repeat bursts
1639
+ // ---------------------------------------------------------------------------
1640
+ updateSliderFollowCircle(hitObject, hitTime, timeDiff) {
1641
+ if (!hitObject.followCircle || !hitObject.sliderInfo || timeDiff > 0) {
1642
+ if (hitObject.followCircle) {
1643
+ hitObject.followCircle.alpha = 0;
1644
+ }
1645
+ return;
1646
+ }
1647
+ const timingPoint = this.getCachedTimingPoint(hitTime);
1648
+ if (!timingPoint?.closestBPM) {
1649
+ hitObject.followCircle.alpha = 0;
1650
+ return;
1651
+ }
1652
+ const sliderDuration = this.getSliderDurationCached(hitObject, hitTime);
1653
+ if (!Number.isFinite(sliderDuration) || sliderDuration <= 0) {
1654
+ hitObject.followCircle.alpha = 0;
1655
+ return;
1656
+ }
1657
+ const sliderRepeat = Number(hitObject.sliderInfo.sliderRepeat || 1);
1658
+ const sliderLength = Number(hitObject.sliderInfo.sliderLength || 0);
1659
+ hitObject.sliderRepeat = sliderRepeat;
1660
+ hitObject.sliderLength = sliderLength;
1661
+ hitObject.sliderDuration = sliderDuration;
1662
+ if (this.currentTime > hitTime + sliderDuration) {
1663
+ hitObject.followCircle.alpha = 0;
1664
+ this.updateSliderRepeatBursts(
1665
+ hitObject,
1666
+ hitTime,
1667
+ sliderDuration,
1668
+ sliderRepeat
1669
+ );
1670
+ return;
1671
+ }
1672
+ const { gridUnit } = this.currentLayout || this.getLayout();
1673
+ const followPosition = getFollowPosition(
1674
+ hitObject,
1675
+ hitTime,
1676
+ this.currentTime,
1677
+ gridUnit
1678
+ );
1679
+ if (followPosition && Number.isFinite(followPosition.x) && Number.isFinite(followPosition.y)) {
1680
+ hitObject.followCircle.x = followPosition.x;
1681
+ hitObject.followCircle.y = followPosition.y;
1682
+ }
1683
+ hitObject.followCircle.alpha = 0.7;
1684
+ this.updateSliderRepeatBursts(
1685
+ hitObject,
1686
+ hitTime,
1687
+ sliderDuration,
1688
+ sliderRepeat
1689
+ );
1690
+ }
1691
+ hasRemainingSliderRepeatsAtEdge(currentTime, hitTime, sliderDuration, sliderRepeat, edgeType) {
1692
+ if (!Number.isFinite(currentTime) || !Number.isFinite(hitTime) || !Number.isFinite(sliderDuration) || sliderDuration <= 0 || !Number.isFinite(sliderRepeat) || sliderRepeat <= 1) {
1693
+ return false;
1694
+ }
1695
+ const spanDuration = sliderDuration / sliderRepeat;
1696
+ if (!Number.isFinite(spanDuration) || spanDuration <= 0) {
1697
+ return false;
1698
+ }
1699
+ for (let repeatIndex = 1; repeatIndex < sliderRepeat; repeatIndex += 1) {
1700
+ const repeatTime = hitTime + spanDuration * repeatIndex;
1701
+ if (repeatTime <= currentTime) {
1702
+ continue;
1703
+ }
1704
+ const repeatEdge = repeatIndex % 2 === 1 ? "end" : "start";
1705
+ if (repeatEdge === edgeType) {
1706
+ return true;
1707
+ }
1708
+ }
1709
+ return false;
1710
+ }
1711
+ updateSliderRepeatBursts(hitObject, hitTime, sliderDuration, sliderRepeat) {
1712
+ if (!Number.isFinite(sliderDuration) || sliderDuration <= 0) {
1713
+ this.cleanupSliderRepeatBursts(hitObject);
1714
+ return;
1715
+ }
1716
+ if (!hitObject._repeatBurstMap) {
1717
+ hitObject._repeatBurstMap = {};
1718
+ }
1719
+ if (!hitObject._repeatBurstTriggered) {
1720
+ hitObject._repeatBurstTriggered = {};
1721
+ }
1722
+ const spanDuration = sliderDuration / sliderRepeat;
1723
+ if (!Number.isFinite(spanDuration) || spanDuration <= 0) {
1724
+ this.cleanupSliderRepeatBursts(hitObject);
1725
+ return;
1726
+ }
1727
+ const { gridUnit } = this.currentLayout || this.getLayout();
1728
+ const circleBaseSize = this.getCircleSize(gridUnit);
1729
+ const baseArrowSize = this.getCircleSize(gridUnit) * 0.6;
1730
+ if (sliderRepeat > 1) {
1731
+ for (let repeatIndex = 1; repeatIndex < sliderRepeat; repeatIndex += 1) {
1732
+ const repeatTime = hitTime + spanDuration * repeatIndex;
1733
+ if (this.currentTime < repeatTime) {
1734
+ continue;
1735
+ }
1736
+ if (!hitObject._repeatBurstMap[repeatIndex] && !hitObject._repeatBurstTriggered[repeatIndex]) {
1737
+ const burstEffect = this.createSliderEdgeBurstEffect(
1738
+ hitObject,
1739
+ repeatIndex % 2 === 1 ? "end" : "start",
1740
+ true,
1741
+ circleBaseSize,
1742
+ baseArrowSize
1743
+ );
1744
+ if (burstEffect) {
1745
+ hitObject._repeatBurstMap[repeatIndex] = {
1746
+ effect: burstEffect,
1747
+ startedAtMs: this.currentTime
1748
+ };
1749
+ hitObject._repeatBurstTriggered[repeatIndex] = true;
1750
+ }
1751
+ }
1752
+ }
1753
+ }
1754
+ const sliderEndTime = hitTime + sliderDuration;
1755
+ if (this.currentTime >= sliderEndTime && !hitObject._repeatBurstMap.end && !hitObject._repeatBurstTriggered.end) {
1756
+ const endEffect = this.createSliderEdgeBurstEffect(
1757
+ hitObject,
1758
+ sliderRepeat % 2 === 1 ? "end" : "start",
1759
+ false,
1760
+ circleBaseSize,
1761
+ baseArrowSize
1762
+ );
1763
+ if (endEffect) {
1764
+ hitObject._repeatBurstMap.end = {
1765
+ effect: endEffect,
1766
+ startedAtMs: this.currentTime
1767
+ };
1768
+ hitObject._repeatBurstTriggered.end = true;
1769
+ }
1770
+ }
1771
+ for (const [repeatKey, burstState] of Object.entries(
1772
+ hitObject._repeatBurstMap
1773
+ )) {
1774
+ const burstEffect = burstState?.effect;
1775
+ if (!burstEffect) {
1776
+ delete hitObject._repeatBurstMap[repeatKey];
1777
+ continue;
1778
+ }
1779
+ const burstProgress = (this.currentTime - (Number(burstState.startedAtMs) || this.currentTime)) / FADE_OUT_MS;
1780
+ if (burstProgress < 0) continue;
1781
+ if (burstProgress > 1) {
1782
+ this.destroySliderBurstEffect(burstEffect);
1783
+ delete hitObject._repeatBurstMap[repeatKey];
1784
+ continue;
1785
+ }
1786
+ const alpha = this.getHitBurstAlpha(burstProgress);
1787
+ const circleSize = this.getHitBurstSize(
1788
+ Number(burstEffect.baseCircleSize) || circleBaseSize,
1789
+ burstProgress
1790
+ );
1791
+ burstEffect.hitCircle.alpha = alpha;
1792
+ burstEffect.hitCircle.width = circleSize;
1793
+ burstEffect.hitCircle.height = circleSize;
1794
+ burstEffect.hitCircleOverlay.alpha = alpha;
1795
+ burstEffect.hitCircleOverlay.width = circleSize;
1796
+ burstEffect.hitCircleOverlay.height = circleSize;
1797
+ if (burstEffect.arrowSprite) {
1798
+ const arrowSize = this.getHitBurstSize(
1799
+ Number(burstEffect.baseArrowSize) || baseArrowSize,
1800
+ burstProgress
1801
+ );
1802
+ burstEffect.arrowSprite.alpha = alpha;
1803
+ burstEffect.arrowSprite.width = arrowSize;
1804
+ burstEffect.arrowSprite.height = arrowSize;
1805
+ }
1806
+ }
1807
+ }
1808
+ createSliderEdgeBurstEffect(hitObject, edgeType, includeArrow, baseCircleSize, baseArrowSize) {
1809
+ const sliderInfo = hitObject.sliderInfo;
1810
+ const anchorPositions = sliderInfo?.anchorPositions || [];
1811
+ if (!sliderInfo || !Array.isArray(anchorPositions) || anchorPositions.length < 2) {
1812
+ return null;
1813
+ }
1814
+ const effectContainer = new PIXI.Container();
1815
+ effectContainer.zIndex = Number.isFinite(hitObject.hitCircleSprite?.zIndex) ? hitObject.hitCircleSprite.zIndex + 10 : 0;
1816
+ const hitCircle = new PIXI.Sprite(this.textures.texture_hitcircle);
1817
+ hitCircle.anchor.set(0.5);
1818
+ hitCircle.width = baseCircleSize;
1819
+ hitCircle.height = baseCircleSize;
1820
+ hitCircle.tint = hitObject.hitCircleSprite?.tint ?? 16777215;
1821
+ const hitCircleOverlay = new PIXI.Sprite(
1822
+ this.textures.texture_hitcircleoverlay
1823
+ );
1824
+ hitCircleOverlay.anchor.set(0.5);
1825
+ hitCircleOverlay.width = baseCircleSize;
1826
+ hitCircleOverlay.height = baseCircleSize;
1827
+ effectContainer.addChild(hitCircle);
1828
+ effectContainer.addChild(hitCircleOverlay);
1829
+ let x = 0;
1830
+ let y = 0;
1831
+ let rotation = 0;
1832
+ if (edgeType === "end") {
1833
+ x = sliderInfo.sliderEndPos?.x ?? hitObject.hitCircleSprite?.x ?? 0;
1834
+ y = sliderInfo.sliderEndPos?.y ?? hitObject.hitCircleSprite?.y ?? 0;
1835
+ rotation = getReverseArrowAngle(sliderInfo);
1836
+ } else {
1837
+ x = hitObject.hitCircleSprite?.x ?? anchorPositions[0].x;
1838
+ y = hitObject.hitCircleSprite?.y ?? anchorPositions[0].y;
1839
+ const first = anchorPositions[0];
1840
+ const second = anchorPositions[1];
1841
+ rotation = Math.atan2(second.y - first.y, second.x - first.x);
1842
+ }
1843
+ hitCircle.x = x;
1844
+ hitCircle.y = y;
1845
+ hitCircleOverlay.x = x;
1846
+ hitCircleOverlay.y = y;
1847
+ let arrowSprite = null;
1848
+ if (includeArrow) {
1849
+ arrowSprite = new PIXI.Sprite(this.textures.texture_reverse);
1850
+ arrowSprite.anchor.set(0.5);
1851
+ arrowSprite.width = baseArrowSize;
1852
+ arrowSprite.height = baseArrowSize;
1853
+ arrowSprite.x = x;
1854
+ arrowSprite.y = y;
1855
+ arrowSprite.rotation = rotation;
1856
+ effectContainer.addChild(arrowSprite);
1857
+ }
1858
+ this.app.stage.addChild(effectContainer);
1859
+ return {
1860
+ container: effectContainer,
1861
+ hitCircle,
1862
+ hitCircleOverlay,
1863
+ arrowSprite,
1864
+ baseCircleSize,
1865
+ baseArrowSize: includeArrow ? baseArrowSize : 0
1866
+ };
1867
+ }
1868
+ destroySliderBurstEffect(effect) {
1869
+ if (!effect?.container) {
1870
+ return;
1871
+ }
1872
+ this.app.stage.removeChild(effect.container);
1873
+ if (typeof effect.container.destroy === "function") {
1874
+ effect.container.destroy({ children: true });
1875
+ }
1876
+ }
1877
+ cleanupSliderRepeatBursts(hitObject) {
1878
+ if (!hitObject?._repeatBurstMap) {
1879
+ return;
1880
+ }
1881
+ for (const burstEntry of Object.values(hitObject._repeatBurstMap)) {
1882
+ const burstEffect = burstEntry?.effect;
1883
+ if (!burstEffect) {
1884
+ continue;
1885
+ }
1886
+ this.destroySliderBurstEffect(burstEffect);
1887
+ }
1888
+ hitObject._repeatBurstMap = {};
1889
+ hitObject._repeatBurstTriggered = {};
1890
+ }
1891
+ // ---------------------------------------------------------------------------
1892
+ // Internal: slider creation
1893
+ // ---------------------------------------------------------------------------
1894
+ addSlider(hitObject, posX, posY, circleSize, zIndex, comboColor) {
1895
+ const sliderInfo = hitObject.sliderInfo;
1896
+ const sliderBodyPath = sliderInfo.sliderType === "P" ? perfectCurve(sliderInfo, this.currentLayout || this.getLayout()) : bezierCurve(sliderInfo, this.currentLayout || this.getLayout());
1897
+ const sliderBorderPath = sliderInfo.sliderType === "P" ? perfectCurve(sliderInfo, this.currentLayout || this.getLayout()) : bezierCurve(sliderInfo, this.currentLayout || this.getLayout());
1898
+ const sliderBorderWidth = circleSize * 0.92;
1899
+ const sliderBodyWidth = sliderBorderWidth * 0.88;
1900
+ sliderBorderPath.stroke({
1901
+ width: sliderBorderWidth,
1902
+ color: 16777215,
1903
+ join: "round",
1904
+ cap: "round"
1905
+ });
1906
+ sliderBodyPath.stroke({
1907
+ width: sliderBodyWidth,
1908
+ color: darkenColor(comboColor, SLIDER_BODY_DARKEN_FACTOR),
1909
+ join: "round",
1910
+ cap: "round"
1911
+ });
1912
+ const bounds = sliderBodyPath.getLocalBounds();
1913
+ const padding = Math.ceil(circleSize * 0.75);
1914
+ const texW = Math.max(1, Math.ceil(bounds.width + padding * 2));
1915
+ const texH = Math.max(1, Math.ceil(bounds.height + padding * 2));
1916
+ const renderResolution = Math.max(1, this.app?.renderer?.resolution || 1) * SLIDER_RT_OVERSAMPLE;
1917
+ const multisample = PIXI.MSAA_QUALITY?.HIGH;
1918
+ const sliderBodyTexture = PIXI.RenderTexture.create({
1919
+ width: texW,
1920
+ height: texH,
1921
+ resolution: renderResolution,
1922
+ multisample
1923
+ });
1924
+ const sliderBorderTexture = PIXI.RenderTexture.create({
1925
+ width: texW,
1926
+ height: texH,
1927
+ resolution: renderResolution,
1928
+ multisample
1929
+ });
1930
+ sliderBodyPath.x = padding - bounds.x;
1931
+ sliderBodyPath.y = padding - bounds.y;
1932
+ sliderBorderPath.x = padding - bounds.x;
1933
+ sliderBorderPath.y = padding - bounds.y;
1934
+ const bodyRenderContainer = new PIXI.Container();
1935
+ bodyRenderContainer.addChild(sliderBodyPath);
1936
+ this.app.renderer.render({
1937
+ container: bodyRenderContainer,
1938
+ target: sliderBodyTexture,
1939
+ clear: true,
1940
+ clearColor: [0, 0, 0, 0]
1941
+ });
1942
+ bodyRenderContainer.removeChild(sliderBodyPath);
1943
+ bodyRenderContainer.destroy({ children: false });
1944
+ const borderRenderContainer = new PIXI.Container();
1945
+ borderRenderContainer.addChild(sliderBorderPath);
1946
+ this.app.renderer.render({
1947
+ container: borderRenderContainer,
1948
+ target: sliderBorderTexture,
1949
+ clear: true,
1950
+ clearColor: [0, 0, 0, 0]
1951
+ });
1952
+ borderRenderContainer.removeChild(sliderBorderPath);
1953
+ borderRenderContainer.destroy({ children: false });
1954
+ const sliderBorderSprite = new PIXI.Sprite(sliderBorderTexture);
1955
+ sliderBorderSprite.x = 0;
1956
+ sliderBorderSprite.y = 0;
1957
+ const sliderBodySprite = new PIXI.Sprite(sliderBodyTexture);
1958
+ sliderBodySprite.x = 0;
1959
+ sliderBodySprite.y = 0;
1960
+ sliderBodySprite.alpha = SLIDER_BODY_ALPHA;
1961
+ const sliderSprite = new PIXI.Container();
1962
+ sliderSprite.sortableChildren = false;
1963
+ sliderSprite.x = bounds.x - padding;
1964
+ sliderSprite.y = bounds.y - padding;
1965
+ sliderSprite.zIndex = zIndex + 1;
1966
+ sliderSprite.addChild(sliderBorderSprite);
1967
+ sliderSprite.addChild(sliderBodySprite);
1968
+ this.app.stage.addChild(sliderSprite);
1969
+ hitObject.sliderBorderSprite = null;
1970
+ hitObject.sliderSprite = sliderSprite;
1971
+ hitObject.hitCircleSprite_sliderend = new PIXI.Sprite(
1972
+ this.textures.texture_hitcircle
1973
+ );
1974
+ hitObject.hitCircleSprite_sliderend.anchor.set(0.5);
1975
+ hitObject.hitCircleSprite_sliderend.x = sliderInfo.sliderEndPos.x;
1976
+ hitObject.hitCircleSprite_sliderend.y = sliderInfo.sliderEndPos.y;
1977
+ hitObject.hitCircleSprite_sliderend.width = circleSize;
1978
+ hitObject.hitCircleSprite_sliderend.height = circleSize;
1979
+ hitObject.hitCircleSprite_sliderend.tint = comboColor;
1980
+ hitObject.hitCircleSprite_sliderend.zIndex = zIndex + 2;
1981
+ hitObject.hitCircleOverlaySprite_sliderend = new PIXI.Sprite(
1982
+ this.textures.texture_hitcircleoverlay
1983
+ );
1984
+ hitObject.hitCircleOverlaySprite_sliderend.anchor.set(0.5);
1985
+ hitObject.hitCircleOverlaySprite_sliderend.x = sliderInfo.sliderEndPos.x;
1986
+ hitObject.hitCircleOverlaySprite_sliderend.y = sliderInfo.sliderEndPos.y;
1987
+ hitObject.hitCircleOverlaySprite_sliderend.width = circleSize;
1988
+ hitObject.hitCircleOverlaySprite_sliderend.height = circleSize;
1989
+ hitObject.hitCircleOverlaySprite_sliderend.zIndex = zIndex + 2;
1990
+ this.app.stage.addChild(hitObject.hitCircleSprite_sliderend);
1991
+ this.app.stage.addChild(hitObject.hitCircleOverlaySprite_sliderend);
1992
+ if (Number(sliderInfo.sliderRepeat) > 1) {
1993
+ const reverseSprite = new PIXI.Sprite(this.textures.texture_reverse);
1994
+ reverseSprite.anchor.set(0.5);
1995
+ reverseSprite.x = sliderInfo.sliderEndPos.x;
1996
+ reverseSprite.y = sliderInfo.sliderEndPos.y;
1997
+ reverseSprite.width = circleSize * 0.6;
1998
+ reverseSprite.height = circleSize * 0.6;
1999
+ reverseSprite.rotation = getReverseArrowAngle(sliderInfo);
2000
+ reverseSprite.zIndex = zIndex + 2;
2001
+ this.app.stage.addChild(reverseSprite);
2002
+ hitObject.reverseSprite = reverseSprite;
2003
+ }
2004
+ if (Number(sliderInfo.sliderRepeat) > 2) {
2005
+ const reverseSpriteHead = new PIXI.Sprite(this.textures.texture_reverse);
2006
+ reverseSpriteHead.anchor.set(0.5);
2007
+ reverseSpriteHead.x = posX;
2008
+ reverseSpriteHead.y = posY;
2009
+ reverseSpriteHead.width = circleSize * 0.6;
2010
+ reverseSpriteHead.height = circleSize * 0.6;
2011
+ const anchorPositions = sliderInfo.anchorPositions || [];
2012
+ if (anchorPositions.length >= 2) {
2013
+ const first = anchorPositions[0];
2014
+ const second = anchorPositions[1];
2015
+ reverseSpriteHead.rotation = Math.atan2(
2016
+ second.y - first.y,
2017
+ second.x - first.x
2018
+ );
2019
+ } else {
2020
+ reverseSpriteHead.rotation = 0;
2021
+ }
2022
+ reverseSpriteHead.zIndex = zIndex + 2;
2023
+ reverseSpriteHead.visible = false;
2024
+ this.app.stage.addChild(reverseSpriteHead);
2025
+ hitObject.reverseSpriteHead = reverseSpriteHead;
2026
+ }
2027
+ const followCircle = new PIXI.Graphics();
2028
+ followCircle.beginFill(15658734);
2029
+ followCircle.drawCircle(0, 0, circleSize / 2.4);
2030
+ followCircle.endFill();
2031
+ followCircle.x = posX;
2032
+ followCircle.y = posY;
2033
+ followCircle.alpha = 0;
2034
+ followCircle.zIndex = zIndex + 2;
2035
+ this.app.stage.addChild(followCircle);
2036
+ hitObject.followCircle = followCircle;
2037
+ }
2038
+ // ---------------------------------------------------------------------------
2039
+ // Internal: display object helpers
2040
+ // ---------------------------------------------------------------------------
2041
+ destroyDisplayObject(displayObject) {
2042
+ if (!displayObject) {
2043
+ return;
2044
+ }
2045
+ if (displayObject.parent) {
2046
+ displayObject.parent.removeChild(displayObject);
2047
+ }
2048
+ if (typeof displayObject.destroy === "function") {
2049
+ displayObject.destroy({ children: true });
2050
+ }
2051
+ }
2052
+ destroyHitObjectVisuals(hitObject) {
2053
+ if (!hitObject) {
2054
+ return;
2055
+ }
2056
+ this.cleanupSliderRepeatBursts(hitObject);
2057
+ this.destroyDisplayObject(hitObject.approachSprite);
2058
+ this.destroyDisplayObject(hitObject.hitCircleSprite);
2059
+ this.destroyDisplayObject(hitObject.hitCircleOverlaySprite);
2060
+ this.destroyDisplayObject(hitObject.comboText);
2061
+ this.destroyDisplayObject(hitObject.sliderSprite);
2062
+ this.destroyDisplayObject(hitObject.sliderBorderSprite);
2063
+ this.destroyDisplayObject(hitObject.hitCircleSprite_sliderend);
2064
+ this.destroyDisplayObject(hitObject.hitCircleOverlaySprite_sliderend);
2065
+ this.destroyDisplayObject(hitObject.reverseSprite);
2066
+ this.destroyDisplayObject(hitObject.reverseSpriteHead);
2067
+ this.destroyDisplayObject(hitObject.followCircle);
2068
+ hitObject.approachSprite = null;
2069
+ hitObject.hitCircleSprite = null;
2070
+ hitObject.hitCircleOverlaySprite = null;
2071
+ hitObject.comboText = null;
2072
+ hitObject.sliderSprite = null;
2073
+ hitObject.sliderBorderSprite = null;
2074
+ hitObject.hitCircleSprite_sliderend = null;
2075
+ hitObject.hitCircleOverlaySprite_sliderend = null;
2076
+ hitObject.reverseSprite = null;
2077
+ hitObject.reverseSpriteHead = null;
2078
+ hitObject.followCircle = null;
2079
+ }
2080
+ removeHitObject(hitObject) {
2081
+ if (!this.app) {
2082
+ return;
2083
+ }
2084
+ this.cleanupSliderRepeatBursts(hitObject);
2085
+ this.hideHitObject(hitObject);
2086
+ }
2087
+ showHitObject(hitObject) {
2088
+ if (hitObject.hitCircleSprite) hitObject.hitCircleSprite.visible = true;
2089
+ if (hitObject.approachSprite) hitObject.approachSprite.visible = true;
2090
+ if (hitObject.hitCircleOverlaySprite)
2091
+ hitObject.hitCircleOverlaySprite.visible = true;
2092
+ if (hitObject.reverseSprite) hitObject.reverseSprite.visible = true;
2093
+ if (hitObject.reverseSpriteHead)
2094
+ hitObject.reverseSpriteHead.visible = false;
2095
+ if (hitObject.comboText) hitObject.comboText.visible = true;
2096
+ if (hitObject.sliderSprite) hitObject.sliderSprite.visible = true;
2097
+ if (hitObject.sliderBorderSprite)
2098
+ hitObject.sliderBorderSprite.visible = true;
2099
+ if (hitObject.hitCircleOverlaySprite_sliderend)
2100
+ hitObject.hitCircleOverlaySprite_sliderend.visible = true;
2101
+ if (hitObject.hitCircleSprite_sliderend)
2102
+ hitObject.hitCircleSprite_sliderend.visible = true;
2103
+ if (hitObject.followCircle) hitObject.followCircle.visible = true;
2104
+ }
2105
+ hideHitObject(hitObject) {
2106
+ if (hitObject.hitCircleSprite) hitObject.hitCircleSprite.visible = false;
2107
+ if (hitObject.approachSprite) hitObject.approachSprite.visible = false;
2108
+ if (hitObject.hitCircleOverlaySprite)
2109
+ hitObject.hitCircleOverlaySprite.visible = false;
2110
+ if (hitObject.reverseSprite) hitObject.reverseSprite.visible = false;
2111
+ if (hitObject.reverseSpriteHead)
2112
+ hitObject.reverseSpriteHead.visible = false;
2113
+ if (hitObject.comboText) hitObject.comboText.visible = false;
2114
+ if (hitObject.sliderSprite) hitObject.sliderSprite.visible = false;
2115
+ if (hitObject.sliderBorderSprite)
2116
+ hitObject.sliderBorderSprite.visible = false;
2117
+ if (hitObject.hitCircleOverlaySprite_sliderend)
2118
+ hitObject.hitCircleOverlaySprite_sliderend.visible = false;
2119
+ if (hitObject.hitCircleSprite_sliderend)
2120
+ hitObject.hitCircleSprite_sliderend.visible = false;
2121
+ if (hitObject.followCircle) hitObject.followCircle.visible = false;
2122
+ }
2123
+ // ---------------------------------------------------------------------------
2124
+ // Internal: asset loading
2125
+ // ---------------------------------------------------------------------------
2126
+ async loadAssets() {
2127
+ const base = this.assetBaseUrl.replace(/\/$/, "");
2128
+ const texture_approach = await Assets.load(`${base}/approachcircle.png`);
2129
+ const texture_hitcircle = await Assets.load(`${base}/hitcircle.png`);
2130
+ const texture_hitcircleoverlay = await Assets.load(
2131
+ `${base}/hitcircleoverlay.png`
2132
+ );
2133
+ const texture_reverse = await Assets.load(`${base}/reversearrow.png`);
2134
+ this.applyLinearTextureFiltering(texture_approach);
2135
+ this.applyLinearTextureFiltering(texture_hitcircle);
2136
+ this.applyLinearTextureFiltering(texture_hitcircleoverlay);
2137
+ this.applyLinearTextureFiltering(texture_reverse);
2138
+ return {
2139
+ texture_approach,
2140
+ texture_hitcircle,
2141
+ texture_hitcircleoverlay,
2142
+ texture_reverse
2143
+ };
2144
+ }
2145
+ applyLinearTextureFiltering(texture) {
2146
+ if (!texture) {
2147
+ return;
2148
+ }
2149
+ const sourceStyle = texture.source?.style;
2150
+ if (sourceStyle) {
2151
+ sourceStyle.scaleMode = "linear";
2152
+ return;
2153
+ }
2154
+ if (texture.baseTexture && PIXI.SCALE_MODES?.LINEAR != null) {
2155
+ texture.baseTexture.scaleMode = PIXI.SCALE_MODES.LINEAR;
2156
+ }
2157
+ }
2158
+ // ---------------------------------------------------------------------------
2159
+ // Internal: calculations
2160
+ // ---------------------------------------------------------------------------
2161
+ calculateARValues() {
2162
+ const AR = Number(this.beatmap?.Difficulty?.ApproachRate || 5);
2163
+ let preempt;
2164
+ let fadeIn;
2165
+ if (AR < 5) {
2166
+ preempt = 1200 + 600 * (5 - AR) / 5;
2167
+ fadeIn = 800 + 400 * (5 - AR) / 5;
2168
+ } else if (AR === 5) {
2169
+ preempt = 1200;
2170
+ fadeIn = 800;
2171
+ } else {
2172
+ preempt = 1200 - 750 * (AR - 5) / 5;
2173
+ fadeIn = 800 - 500 * (AR - 5) / 5;
2174
+ }
2175
+ return { preempt, fadeIn };
2176
+ }
2177
+ getDisplayDimensions() {
2178
+ const containerWidth = this.containerEl?.clientWidth || 1280;
2179
+ const containerHeight = this.containerEl?.clientHeight || 720;
2180
+ const containerAspect = containerWidth / containerHeight;
2181
+ if (containerAspect > BASE_ASPECT_RATIO) {
2182
+ const height = containerHeight;
2183
+ return { width: height * BASE_ASPECT_RATIO, height };
2184
+ }
2185
+ const width = containerWidth;
2186
+ return { width, height: width / BASE_ASPECT_RATIO };
2187
+ }
2188
+ getLayout() {
2189
+ const { width, height } = this.getDisplayDimensions();
2190
+ const centerX = width / 2;
2191
+ const centerY = height / 2;
2192
+ const gridUnit = height * 0.8 / 384;
2193
+ return {
2194
+ centerX,
2195
+ centerY,
2196
+ gridUnit,
2197
+ topLeftX: centerX - 256 * gridUnit,
2198
+ topLeftY: centerY - 192 * gridUnit
2199
+ };
2200
+ }
2201
+ getCircleSize(gridUnit) {
2202
+ const cs = Number(this.beatmap?.Difficulty?.CircleSize || 4);
2203
+ return (54.4 - 4.48 * cs) * gridUnit * 2;
2204
+ }
2205
+ getComboColor(comboColorIndex) {
2206
+ const comboColors = [16750848, 16711680, 3381759, 3407616];
2207
+ return comboColors[(comboColorIndex || 0) % comboColors.length];
2208
+ }
2209
+ getSliderDuration(hitObject, hitTime) {
2210
+ if (!hitObject?.sliderInfo) {
2211
+ return 0;
2212
+ }
2213
+ const timingPoint = this.getCachedTimingPoint(hitTime);
2214
+ if (!timingPoint?.closestBPM) {
2215
+ return 0;
2216
+ }
2217
+ const beatLength = timingPoint.closestBPM.beatLengthValue;
2218
+ const baseSV = Number(this.beatmap?.Difficulty?.SliderMultiplier || 1);
2219
+ const hasInheritedSVAfterBpm = timingPoint.closestSV && Number.isFinite(timingPoint.closestSV.time) && Number.isFinite(timingPoint.closestBPM.time) && timingPoint.closestSV.time >= timingPoint.closestBPM.time;
2220
+ const speed = hasInheritedSVAfterBpm ? timingPoint.closestSV.sv : -100;
2221
+ const sliderRepeat = Number(hitObject.sliderInfo.sliderRepeat || 1);
2222
+ const sliderLength = Number(hitObject.sliderInfo.sliderLength || 0);
2223
+ const svFormulaDuration = sliderLength / (100 * baseSV * (-100 / speed)) * beatLength * sliderRepeat;
2224
+ const fallbackDuration = beatLength * sliderRepeat * sliderLength / 100;
2225
+ const duration = Number.isFinite(svFormulaDuration) && svFormulaDuration > 0 ? svFormulaDuration : fallbackDuration;
2226
+ return Number.isFinite(duration) && duration > 0 ? duration : 0;
2227
+ }
2228
+ getSliderDurationCached(hitObject, hitTime) {
2229
+ if (Number.isFinite(hitObject._sliderDuration) && hitObject._sliderDuration > 0) {
2230
+ return hitObject._sliderDuration;
2231
+ }
2232
+ const duration = this.getSliderDuration(hitObject, hitTime);
2233
+ hitObject._sliderDuration = duration > 0 ? duration : 2e3;
2234
+ return hitObject._sliderDuration;
2235
+ }
2236
+ getCachedTimingPoint(hitTime) {
2237
+ if (this.timingPointCache.has(hitTime)) {
2238
+ return this.timingPointCache.get(hitTime);
2239
+ }
2240
+ const timingPoint = getTimingPointAt(hitTime, this.beatmap);
2241
+ this.timingPointCache.set(hitTime, timingPoint);
2242
+ return timingPoint;
2243
+ }
2244
+ prepareHitObject(hitObject, hitTime) {
2245
+ hitObject.time = hitTime;
2246
+ hitObject._hitsoundHeadPlayed = false;
2247
+ hitObject._hitsoundSliderEndPlayed = false;
2248
+ hitObject._hitsoundSliderRepeatPlayed = {};
2249
+ hitObject._sliderDuration = this.getSliderDuration(hitObject, hitTime) || 2e3;
2250
+ hitObject._repeatBurstMap = {};
2251
+ hitObject._repeatBurstTriggered = {};
2252
+ }
2253
+ getHitBurstProgress(timeDiff) {
2254
+ return Math.max(0, Math.min(1, -timeDiff / FADE_OUT_MS));
2255
+ }
2256
+ getHitBurstAlpha(progress) {
2257
+ return Math.max(0, 1 - (Number(progress) || 0));
2258
+ }
2259
+ getHitBurstSize(baseSize, progress) {
2260
+ const safeBaseSize = Number(baseSize) || 0;
2261
+ const safeProgress = Number(progress) || 0;
2262
+ return safeBaseSize + safeProgress * safeBaseSize * 0.3;
2263
+ }
2264
+ getHitBurstScale(baseScale, progress) {
2265
+ const safeBaseScale = Number(baseScale) || 1;
2266
+ const safeProgress = Number(progress) || 0;
2267
+ return safeBaseScale + safeProgress * safeBaseScale * 0.3;
2268
+ }
2269
+ getDelimitedParts(value, delimiter) {
2270
+ if (typeof value === "string") {
2271
+ return value.split(delimiter);
2272
+ }
2273
+ if (Array.isArray(value)) {
2274
+ return value.map((item) => String(item ?? ""));
2275
+ }
2276
+ if (value == null) {
2277
+ return [];
2278
+ }
2279
+ return String(value).split(delimiter);
2280
+ }
2281
+ computeBeatmapDurationMs() {
2282
+ if (!this.hitObjectEntries.length) {
2283
+ return 0;
2284
+ }
2285
+ let maxEndTime = 0;
2286
+ for (const [hitTime, hitObject] of this.hitObjectEntries) {
2287
+ const endTime = this.getHitObjectEndTime(hitObject, hitTime);
2288
+ if (Number.isFinite(endTime) && endTime > maxEndTime) {
2289
+ maxEndTime = endTime;
2290
+ }
2291
+ }
2292
+ return Math.max(0, maxEndTime);
2293
+ }
2294
+ getHitObjectEndTime(hitObject, hitTime) {
2295
+ if (!hitObject) {
2296
+ return hitTime;
2297
+ }
2298
+ if (hitObject.type?.includes("Slider")) {
2299
+ const sliderDuration = this.getSliderDurationCached(hitObject, hitTime);
2300
+ return hitTime + Math.max(0, sliderDuration);
2301
+ }
2302
+ if (hitObject.type?.includes("Spinner")) {
2303
+ const extrasParts = this.getDelimitedParts(hitObject.extras, ":");
2304
+ const spinnerEndTime = Number.parseInt(extrasParts[0], 10);
2305
+ if (Number.isFinite(spinnerEndTime) && spinnerEndTime > hitTime) {
2306
+ return spinnerEndTime;
2307
+ }
2308
+ }
2309
+ return hitTime;
2310
+ }
2311
+ getTransportCurrentTimeMs() {
2312
+ if (!this.isPlaying) {
2313
+ return this.currentTime;
2314
+ }
2315
+ const nowPerfMs = performance.now();
2316
+ const elapsedMs = nowPerfMs - this.transportStartPerfTime;
2317
+ const perfVisualMs = this.transportStartMs + Math.max(0, elapsedMs);
2318
+ if (this.previewAudio && !this.previewAudio.paused && Number.isFinite(this.previewAudio.currentTime)) {
2319
+ if (!this.lastAudioSyncSamplePerfMs || nowPerfMs - this.lastAudioSyncSamplePerfMs >= AUDIO_SYNC_SAMPLE_INTERVAL_MS) {
2320
+ this.lastAudioSyncSamplePerfMs = nowPerfMs;
2321
+ const audioDerivedVisualMs = this.getBeatmapTimeMsForPreviewAudioTime(
2322
+ this.previewAudio.currentTime * 1e3
2323
+ );
2324
+ if (Number.isFinite(audioDerivedVisualMs)) {
2325
+ const targetOffsetMs = audioDerivedVisualMs - perfVisualMs;
2326
+ if (Math.abs(targetOffsetMs) > 120) {
2327
+ this.audioSyncOffsetMs = targetOffsetMs;
2328
+ } else {
2329
+ this.audioSyncOffsetMs = this.audioSyncOffsetMs + (targetOffsetMs - this.audioSyncOffsetMs) * 0.35;
2330
+ }
2331
+ }
2332
+ }
2333
+ }
2334
+ return perfVisualMs + this.audioSyncOffsetMs;
2335
+ }
2336
+ getPreviewAudioTimeMsForBeatmapTime(beatmapTimeMs) {
2337
+ return beatmapTimeMs - this.previewTimeMs + this.audioOffsetMs;
2338
+ }
2339
+ getBeatmapTimeMsForPreviewAudioTime(previewAudioTimeMs) {
2340
+ return previewAudioTimeMs + this.previewTimeMs - this.audioOffsetMs;
2341
+ }
2342
+ /** @deprecated No-op, kept for backward compatibility. */
2343
+ async initHitsounds() {
2344
+ }
2345
+ }
2346
+ function lowerBound(arr, target) {
2347
+ let left = 0;
2348
+ let right = arr.length;
2349
+ while (left < right) {
2350
+ const mid = left + right >> 1;
2351
+ if (arr[mid] < target) {
2352
+ left = mid + 1;
2353
+ } else {
2354
+ right = mid;
2355
+ }
2356
+ }
2357
+ return left;
2358
+ }
2359
+ function upperBound(arr, target) {
2360
+ let left = 0;
2361
+ let right = arr.length;
2362
+ while (left < right) {
2363
+ const mid = left + right >> 1;
2364
+ if (arr[mid] <= target) {
2365
+ left = mid + 1;
2366
+ } else {
2367
+ right = mid;
2368
+ }
2369
+ }
2370
+ return left;
2371
+ }
2372
+ function darkenColor(color, factor) {
2373
+ const normalized = Math.max(0, Math.min(1, Number(factor) || 0));
2374
+ const r = color >> 16 & 255;
2375
+ const g = color >> 8 & 255;
2376
+ const b = color & 255;
2377
+ const dr = Math.round(r * normalized);
2378
+ const dg = Math.round(g * normalized);
2379
+ const db = Math.round(b * normalized);
2380
+ return dr << 16 | dg << 8 | db;
2381
+ }
2382
+ export {
2383
+ BeatmapEngine,
2384
+ fetchAndParseOsu,
2385
+ getGlobalAudioOffsetMs,
2386
+ parseOsuText,
2387
+ setGlobalAudioOffsetMs,
2388
+ subscribeGlobalAudioOffset
2389
+ };