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.
- package/README.md +39 -0
- package/dist/osu-beatmap-renderer.js +2389 -0
- package/package.json +36 -0
- package/src/AudioTimingConfig.js +29 -0
- package/src/BeatmapEngine.js +1819 -0
- package/src/Bezier.js +89 -0
- package/src/HitsoundPlayer.js +364 -0
- package/src/PathPoint.js +10 -0
- package/src/SliderMath.js +145 -0
- package/src/bundledAssetUrls.js +22 -0
- package/src/index.js +7 -0
- package/src/parser.js +201 -0
- package/src/utils.js +259 -0
package/src/Bezier.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { PathPoint } from './PathPoint.js';
|
|
2
|
+
|
|
3
|
+
export class Bezier {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.arrPn = [];
|
|
6
|
+
this.mu = 0;
|
|
7
|
+
this.resultPoint = new PathPoint();
|
|
8
|
+
this.initResultPoint();
|
|
9
|
+
this.arcLength = 0;
|
|
10
|
+
this.arcLengthTable = [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
setBezierN(arrPn) {
|
|
14
|
+
this.arrPn = arrPn.slice();
|
|
15
|
+
this.calculateArcLength();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
bezierCalc() {
|
|
19
|
+
let k, kn, nn, nkn;
|
|
20
|
+
let blend, muk, munk;
|
|
21
|
+
const n = this.arrPn.length - 1;
|
|
22
|
+
|
|
23
|
+
this.initResultPoint();
|
|
24
|
+
muk = 1;
|
|
25
|
+
munk = Math.pow(1 - this.mu, n);
|
|
26
|
+
for (k = 0; k <= n; k++) {
|
|
27
|
+
nn = n;
|
|
28
|
+
kn = k;
|
|
29
|
+
nkn = n - k;
|
|
30
|
+
blend = muk * munk;
|
|
31
|
+
muk *= this.mu;
|
|
32
|
+
munk /= (1 - this.mu);
|
|
33
|
+
while (nn >= 1) {
|
|
34
|
+
blend *= nn;
|
|
35
|
+
nn--;
|
|
36
|
+
if (kn > 1) {
|
|
37
|
+
blend /= kn;
|
|
38
|
+
kn--;
|
|
39
|
+
}
|
|
40
|
+
if (nkn > 1) {
|
|
41
|
+
blend /= nkn;
|
|
42
|
+
nkn--;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
this.resultPoint.x += this.arrPn[k].x * blend;
|
|
46
|
+
this.resultPoint.y += this.arrPn[k].y * blend;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
initResultPoint() {
|
|
51
|
+
this.resultPoint.x = 0.0;
|
|
52
|
+
this.resultPoint.y = 0.0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setMu(mu) {
|
|
56
|
+
this.mu = mu;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getResult() {
|
|
60
|
+
return this.resultPoint;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
calculateArcLength() {
|
|
64
|
+
this.arcLength = 0;
|
|
65
|
+
this.arcLengthTable = [];
|
|
66
|
+
let prevPoint = this.arrPn[0];
|
|
67
|
+
for (let t = 0; t <= 1; t += 0.01) {
|
|
68
|
+
this.setMu(t);
|
|
69
|
+
this.bezierCalc();
|
|
70
|
+
const currPoint = this.getResult();
|
|
71
|
+
const segmentLength = Math.sqrt(Math.pow(currPoint.x - prevPoint.x, 2) + Math.pow(currPoint.y - prevPoint.y, 2));
|
|
72
|
+
this.arcLength += segmentLength;
|
|
73
|
+
this.arcLengthTable.push({ t, length: this.arcLength });
|
|
74
|
+
prevPoint = { ...currPoint };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getMuForArcLength(targetLength) {
|
|
79
|
+
for (let i = 0; i < this.arcLengthTable.length - 1; i++) {
|
|
80
|
+
const curr = this.arcLengthTable[i];
|
|
81
|
+
const next = this.arcLengthTable[i + 1];
|
|
82
|
+
if (targetLength >= curr.length && targetLength <= next.length) {
|
|
83
|
+
const ratio = (targetLength - curr.length) / (next.length - curr.length);
|
|
84
|
+
return curr.t + ratio * (next.t - curr.t);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_BUNDLED_ASSET_BASE,
|
|
3
|
+
HITSOUND_SAMPLE_FILES,
|
|
4
|
+
} from "./bundledAssetUrls.js";
|
|
5
|
+
|
|
6
|
+
function lowerBound(arr, target) {
|
|
7
|
+
let left = 0;
|
|
8
|
+
let right = arr.length;
|
|
9
|
+
while (left < right) {
|
|
10
|
+
const mid = (left + right) >> 1;
|
|
11
|
+
if (arr[mid] < target) {
|
|
12
|
+
left = mid + 1;
|
|
13
|
+
} else {
|
|
14
|
+
right = mid;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return left;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fileBaseName(file) {
|
|
21
|
+
return file.replace(/\.[^/.]+$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class HitsoundPlayer {
|
|
25
|
+
constructor({
|
|
26
|
+
hitsoundVolume = 0.3,
|
|
27
|
+
schedulerIntervalMs = 20,
|
|
28
|
+
schedulerLookaheadMs = 120,
|
|
29
|
+
assetBaseUrl = DEFAULT_BUNDLED_ASSET_BASE,
|
|
30
|
+
} = {}) {
|
|
31
|
+
this.hitsoundVolume = hitsoundVolume;
|
|
32
|
+
this.assetBaseUrl = assetBaseUrl.replace(/\/$/, "");
|
|
33
|
+
|
|
34
|
+
this.schedulerIntervalMs = schedulerIntervalMs;
|
|
35
|
+
this.schedulerLookaheadMs = schedulerLookaheadMs;
|
|
36
|
+
|
|
37
|
+
this.audioContext = null;
|
|
38
|
+
this.hitsoundBuffers = new Map();
|
|
39
|
+
|
|
40
|
+
this.schedulerTimer = null;
|
|
41
|
+
this.hitsoundEvents = [];
|
|
42
|
+
this.hitsoundEventTimes = [];
|
|
43
|
+
this.hitsoundEventCursor = 0;
|
|
44
|
+
|
|
45
|
+
this.activeScheduledSources = new Set();
|
|
46
|
+
|
|
47
|
+
this.isPlayingRef = () => false;
|
|
48
|
+
this.getTransportCurrentTimeMsRef = () => 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async init() {
|
|
52
|
+
try {
|
|
53
|
+
this.audioContext = new (window.AudioContext ||
|
|
54
|
+
window.webkitAudioContext)();
|
|
55
|
+
|
|
56
|
+
await Promise.all(
|
|
57
|
+
HITSOUND_SAMPLE_FILES.map(async (file) => {
|
|
58
|
+
const name = fileBaseName(file);
|
|
59
|
+
const url = `${this.assetBaseUrl}/${file}`;
|
|
60
|
+
const response = await fetch(url);
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`Hitsound fetch failed ${response.status}: ${url}`);
|
|
63
|
+
}
|
|
64
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
65
|
+
const audioBuffer = await this.audioContext.decodeAudioData(
|
|
66
|
+
arrayBuffer.slice(0),
|
|
67
|
+
);
|
|
68
|
+
this.hitsoundBuffers.set(name, audioBuffer);
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
} catch (_error) {
|
|
72
|
+
this.hitsoundBuffers.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
destroy() {
|
|
77
|
+
this.stopScheduler();
|
|
78
|
+
this.clearScheduledSources();
|
|
79
|
+
|
|
80
|
+
if (this.audioContext) {
|
|
81
|
+
this.audioContext.close().catch(() => {});
|
|
82
|
+
this.audioContext = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.hitsoundBuffers.clear();
|
|
86
|
+
this.hitsoundEvents = [];
|
|
87
|
+
this.hitsoundEventTimes = [];
|
|
88
|
+
this.hitsoundEventCursor = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setVolume(volume) {
|
|
92
|
+
const v = Math.max(0, Math.min(1, Number(volume) || 0));
|
|
93
|
+
this.hitsoundVolume = v;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
buildHitsoundEvents(
|
|
97
|
+
hitObjectEntries,
|
|
98
|
+
{ getCachedTimingPoint, getSliderDurationCached, getHitObjectEndTime },
|
|
99
|
+
) {
|
|
100
|
+
this.hitsoundEvents = [];
|
|
101
|
+
|
|
102
|
+
for (const [hitTime, hitObject] of hitObjectEntries) {
|
|
103
|
+
const timingPoint = getCachedTimingPoint(hitTime);
|
|
104
|
+
if (!timingPoint) continue;
|
|
105
|
+
|
|
106
|
+
if (hitObject.type.includes("Slider")) {
|
|
107
|
+
const sliderDuration = getSliderDurationCached(hitObject, hitTime);
|
|
108
|
+
const sliderInfo = hitObject.sliderInfo;
|
|
109
|
+
const repeat = Number(sliderInfo?.sliderRepeat || 1);
|
|
110
|
+
|
|
111
|
+
this.hitsoundEvents.push(
|
|
112
|
+
this.resolveSliderEdgeEvent(
|
|
113
|
+
hitObject,
|
|
114
|
+
timingPoint,
|
|
115
|
+
hitTime,
|
|
116
|
+
0,
|
|
117
|
+
hitTime,
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
for (let i = 1; i < repeat; i++) {
|
|
121
|
+
const repeatTime = hitTime + (sliderDuration * i) / repeat;
|
|
122
|
+
this.hitsoundEvents.push(
|
|
123
|
+
this.resolveSliderEdgeEvent(
|
|
124
|
+
hitObject,
|
|
125
|
+
timingPoint,
|
|
126
|
+
hitTime,
|
|
127
|
+
i,
|
|
128
|
+
repeatTime,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const endTime = hitTime + sliderDuration;
|
|
133
|
+
this.hitsoundEvents.push(
|
|
134
|
+
this.resolveSliderEdgeEvent(
|
|
135
|
+
hitObject,
|
|
136
|
+
timingPoint,
|
|
137
|
+
hitTime,
|
|
138
|
+
repeat,
|
|
139
|
+
endTime,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
} else if (hitObject.type.includes("Spinner")) {
|
|
143
|
+
this.hitsoundEvents.push(
|
|
144
|
+
this.resolveSpinnerEndEvent(
|
|
145
|
+
hitObject,
|
|
146
|
+
timingPoint,
|
|
147
|
+
hitTime,
|
|
148
|
+
getHitObjectEndTime,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
this.hitsoundEvents.push(
|
|
153
|
+
this.resolveCircleEvent(hitObject, timingPoint, hitTime),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.hitsoundEvents = this.hitsoundEvents
|
|
159
|
+
.filter((e) => e && Number.isFinite(e.time))
|
|
160
|
+
.sort((a, b) => a.time - b.time);
|
|
161
|
+
|
|
162
|
+
this.hitsoundEventTimes = this.hitsoundEvents.map((e) => e.time);
|
|
163
|
+
this.hitsoundEventCursor = 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
startScheduler({ isPlaying, getTransportCurrentTimeMs }) {
|
|
167
|
+
if (this.schedulerTimer) return;
|
|
168
|
+
|
|
169
|
+
this.isPlayingRef = isPlaying;
|
|
170
|
+
this.getTransportCurrentTimeMsRef = getTransportCurrentTimeMs;
|
|
171
|
+
|
|
172
|
+
this.tickScheduler();
|
|
173
|
+
this.schedulerTimer = window.setInterval(
|
|
174
|
+
() => this.tickScheduler(),
|
|
175
|
+
this.schedulerIntervalMs,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
stopScheduler() {
|
|
180
|
+
if (!this.schedulerTimer) return;
|
|
181
|
+
window.clearInterval(this.schedulerTimer);
|
|
182
|
+
this.schedulerTimer = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
tickScheduler() {
|
|
186
|
+
if (
|
|
187
|
+
!this.isPlayingRef() ||
|
|
188
|
+
!this.audioContext ||
|
|
189
|
+
this.hitsoundEvents.length === 0
|
|
190
|
+
)
|
|
191
|
+
return;
|
|
192
|
+
if (this.audioContext.state === "suspended") return;
|
|
193
|
+
|
|
194
|
+
const transportNow = this.getTransportCurrentTimeMsRef();
|
|
195
|
+
const scheduleUntil = transportNow + this.schedulerLookaheadMs;
|
|
196
|
+
|
|
197
|
+
while (this.hitsoundEventCursor < this.hitsoundEvents.length) {
|
|
198
|
+
const event = this.hitsoundEvents[this.hitsoundEventCursor];
|
|
199
|
+
if (event.time > scheduleUntil) break;
|
|
200
|
+
|
|
201
|
+
if (event.time >= transportNow - 25) {
|
|
202
|
+
const when =
|
|
203
|
+
this.audioContext.currentTime +
|
|
204
|
+
Math.max(0, (event.time - transportNow) / 1000);
|
|
205
|
+
this.playHitsoundAt(
|
|
206
|
+
event.normalSet,
|
|
207
|
+
event.additionSet,
|
|
208
|
+
event.hitsound,
|
|
209
|
+
when,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.hitsoundEventCursor += 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
resetFromCurrentTime(currentTime) {
|
|
218
|
+
this.clearScheduledSources();
|
|
219
|
+
this.hitsoundEventCursor = lowerBound(this.hitsoundEventTimes, currentTime);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
clearScheduledSources() {
|
|
223
|
+
for (const source of this.activeScheduledSources) {
|
|
224
|
+
try {
|
|
225
|
+
source.stop();
|
|
226
|
+
} catch (_e) {
|
|
227
|
+
// No-op if source already ended.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.activeScheduledSources.clear();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
playHitsoundAt(normalset, additionSet, hitsound, when) {
|
|
234
|
+
const normal = Number.parseInt(normalset, 10);
|
|
235
|
+
let addition = Number.parseInt(additionSet, 10);
|
|
236
|
+
const soundMask = Number.parseInt(hitsound, 10);
|
|
237
|
+
if (Number.isNaN(normal) || Number.isNaN(soundMask)) return;
|
|
238
|
+
|
|
239
|
+
if (normal === 1) this.playHitsoundBufferAt("normal-hitnormal", when);
|
|
240
|
+
if (normal === 2) this.playHitsoundBufferAt("soft-hitnormal", when);
|
|
241
|
+
if (normal === 3) this.playHitsoundBufferAt("drum-hitnormal", when);
|
|
242
|
+
|
|
243
|
+
if (addition === 0) addition = normal;
|
|
244
|
+
if (addition === 1) {
|
|
245
|
+
if (soundMask & 2) this.playHitsoundBufferAt("normal-hitwhistle", when);
|
|
246
|
+
if (soundMask & 4) this.playHitsoundBufferAt("normal-hitfinish", when);
|
|
247
|
+
if (soundMask & 8) this.playHitsoundBufferAt("normal-hitclap", when);
|
|
248
|
+
}
|
|
249
|
+
if (addition === 2) {
|
|
250
|
+
if (soundMask & 2) this.playHitsoundBufferAt("soft-hitwhistle", when);
|
|
251
|
+
if (soundMask & 4) this.playHitsoundBufferAt("soft-hitfinish", when);
|
|
252
|
+
if (soundMask & 8) this.playHitsoundBufferAt("soft-hitclap", when);
|
|
253
|
+
}
|
|
254
|
+
if (addition === 3) {
|
|
255
|
+
if (soundMask & 2) this.playHitsoundBufferAt("drum-hitwhistle", when);
|
|
256
|
+
if (soundMask & 4) this.playHitsoundBufferAt("drum-hitfinish", when);
|
|
257
|
+
if (soundMask & 8) this.playHitsoundBufferAt("drum-hitclap", when);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
playHitsoundBufferAt(key, when) {
|
|
262
|
+
if (!this.audioContext || !this.hitsoundBuffers.has(key)) return;
|
|
263
|
+
|
|
264
|
+
const source = this.audioContext.createBufferSource();
|
|
265
|
+
const gain = this.audioContext.createGain();
|
|
266
|
+
gain.gain.value = this.hitsoundVolume;
|
|
267
|
+
|
|
268
|
+
source.buffer = this.hitsoundBuffers.get(key);
|
|
269
|
+
source.connect(gain);
|
|
270
|
+
gain.connect(this.audioContext.destination);
|
|
271
|
+
|
|
272
|
+
source.onended = () => {
|
|
273
|
+
this.activeScheduledSources.delete(source);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
this.activeScheduledSources.add(source);
|
|
277
|
+
source.start(when);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
resolveCircleEvent(hitObject, timingPoint, hitTime) {
|
|
281
|
+
const extrasParts = this.getDelimitedParts(hitObject?.extras, ":");
|
|
282
|
+
const rawNormalSet = extrasParts[0];
|
|
283
|
+
const rawAdditionSet = extrasParts[1];
|
|
284
|
+
|
|
285
|
+
const normalSet =
|
|
286
|
+
rawNormalSet && rawNormalSet !== "0"
|
|
287
|
+
? rawNormalSet
|
|
288
|
+
: String(timingPoint.sampleSet || 1);
|
|
289
|
+
const additionSet =
|
|
290
|
+
rawAdditionSet && rawAdditionSet !== "0" ? rawAdditionSet : normalSet;
|
|
291
|
+
return {
|
|
292
|
+
time: hitTime,
|
|
293
|
+
normalSet,
|
|
294
|
+
additionSet,
|
|
295
|
+
hitsound: String(hitObject.hitSound || "0"),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
resolveSliderEdgeEvent(
|
|
300
|
+
hitObject,
|
|
301
|
+
timingPoint,
|
|
302
|
+
_hitTime,
|
|
303
|
+
edgeIndex,
|
|
304
|
+
eventTime,
|
|
305
|
+
) {
|
|
306
|
+
const sliderInfo = hitObject.sliderInfo;
|
|
307
|
+
if (sliderInfo?.edgeSounds && sliderInfo?.edgeSets) {
|
|
308
|
+
const edgeSounds = this.getDelimitedParts(sliderInfo.edgeSounds, "|");
|
|
309
|
+
const edgeSets = this.getDelimitedParts(sliderInfo.edgeSets, "|");
|
|
310
|
+
const hitsound = edgeSounds[edgeIndex] || "0";
|
|
311
|
+
const edgeSetParts = this.getDelimitedParts(
|
|
312
|
+
edgeSets[edgeIndex] || "0:0",
|
|
313
|
+
":",
|
|
314
|
+
);
|
|
315
|
+
const normalSet =
|
|
316
|
+
edgeSetParts[0] === "0"
|
|
317
|
+
? String(timingPoint.sampleSet || 1)
|
|
318
|
+
: edgeSetParts[0] || String(timingPoint.sampleSet || 1);
|
|
319
|
+
const additionSet =
|
|
320
|
+
edgeSetParts[1] === "0" ? normalSet : edgeSetParts[1] || normalSet;
|
|
321
|
+
return { time: eventTime, normalSet, additionSet, hitsound };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const set = String(timingPoint.sampleSet || 1);
|
|
325
|
+
return { time: eventTime, normalSet: set, additionSet: set, hitsound: "0" };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
resolveSpinnerEndEvent(hitObject, timingPoint, hitTime, getHitObjectEndTime) {
|
|
329
|
+
const spinnerEndTime = getHitObjectEndTime(hitObject, hitTime);
|
|
330
|
+
const extras = hitObject?.extras;
|
|
331
|
+
const hitSampleRaw = Array.isArray(extras) ? extras[1] : null;
|
|
332
|
+
const hitSampleParts = this.getDelimitedParts(hitSampleRaw, ":");
|
|
333
|
+
|
|
334
|
+
const rawNormalSet = hitSampleParts[0];
|
|
335
|
+
const rawAdditionSet = hitSampleParts[1];
|
|
336
|
+
|
|
337
|
+
const normalSet =
|
|
338
|
+
rawNormalSet && rawNormalSet !== "0"
|
|
339
|
+
? rawNormalSet
|
|
340
|
+
: String(timingPoint.sampleSet || 1);
|
|
341
|
+
const additionSet =
|
|
342
|
+
rawAdditionSet && rawAdditionSet !== "0" ? rawAdditionSet : normalSet;
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
time: spinnerEndTime,
|
|
346
|
+
normalSet,
|
|
347
|
+
additionSet,
|
|
348
|
+
hitsound: String(hitObject.hitSound || "0"),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
getDelimitedParts(value, delimiter) {
|
|
353
|
+
if (typeof value === "string") {
|
|
354
|
+
return value.split(delimiter);
|
|
355
|
+
}
|
|
356
|
+
if (Array.isArray(value)) {
|
|
357
|
+
return value.map((item) => String(item ?? ""));
|
|
358
|
+
}
|
|
359
|
+
if (value == null) {
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
return String(value).split(delimiter);
|
|
363
|
+
}
|
|
364
|
+
}
|
package/src/PathPoint.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as PIXI from 'pixi.js';
|
|
2
|
+
import { PathPoint } from './PathPoint.js';
|
|
3
|
+
import { Bezier } from './Bezier.js';
|
|
4
|
+
import { getCircleCenter } from './utils.js';
|
|
5
|
+
|
|
6
|
+
export function getReverseArrowAngle(sliderInfo) {
|
|
7
|
+
const anchorPositions = sliderInfo.anchorPositions;
|
|
8
|
+
const endPos = sliderInfo.sliderEndPos;
|
|
9
|
+
if (!anchorPositions || anchorPositions.length < 2 || !endPos) {
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
const secondLastPoint = anchorPositions[Math.max(0, anchorPositions.length - 2)];
|
|
13
|
+
return Math.atan2(secondLastPoint.y - endPos.y, secondLastPoint.x - endPos.x) + Math.PI;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function bezierCurve(sliderInfo, layout) {
|
|
17
|
+
const anchorPositions = sliderInfo.anchorPositions;
|
|
18
|
+
if (!sliderInfo.adjusted) {
|
|
19
|
+
adjustAnchorPositions(anchorPositions, layout);
|
|
20
|
+
sliderInfo.adjusted = true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sliderTotalLength = Number(sliderInfo.sliderLength) * layout.gridUnit;
|
|
24
|
+
let totalLength = 0;
|
|
25
|
+
|
|
26
|
+
let arrPn = [new PathPoint(anchorPositions[0].x, anchorPositions[0].y)];
|
|
27
|
+
let bezier = new Bezier();
|
|
28
|
+
const sliderPath = new PIXI.Graphics();
|
|
29
|
+
sliderPath.moveTo(anchorPositions[0].x, anchorPositions[0].y);
|
|
30
|
+
|
|
31
|
+
for (let i = 1; i < anchorPositions.length; i++) {
|
|
32
|
+
const prevPos = anchorPositions[i - 1];
|
|
33
|
+
const currPos = anchorPositions[i];
|
|
34
|
+
if (i === anchorPositions.length - 1 || PathPoint.compare(prevPos, currPos)) {
|
|
35
|
+
if (i === anchorPositions.length - 1) {
|
|
36
|
+
arrPn.push(new PathPoint(anchorPositions[i].x, anchorPositions[i].y));
|
|
37
|
+
}
|
|
38
|
+
bezier.setBezierN(arrPn);
|
|
39
|
+
|
|
40
|
+
const muGap = 10 / Math.max(1, sliderTotalLength);
|
|
41
|
+
const startP = new PathPoint();
|
|
42
|
+
const endP = new PathPoint();
|
|
43
|
+
|
|
44
|
+
for (let mu = 0; mu <= 1; mu += muGap) {
|
|
45
|
+
startP.x = bezier.getResult().x;
|
|
46
|
+
startP.y = bezier.getResult().y;
|
|
47
|
+
|
|
48
|
+
bezier.setMu(mu);
|
|
49
|
+
bezier.bezierCalc();
|
|
50
|
+
|
|
51
|
+
endP.x = bezier.getResult().x;
|
|
52
|
+
endP.y = bezier.getResult().y;
|
|
53
|
+
|
|
54
|
+
sliderInfo.sliderEndPos = startP;
|
|
55
|
+
if (Number.isFinite(endP.x) && Number.isFinite(endP.y)) {
|
|
56
|
+
sliderPath.lineTo(endP.x, endP.y);
|
|
57
|
+
sliderInfo.sliderEndPos = endP;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (mu === 0) continue;
|
|
61
|
+
totalLength += Math.hypot(startP.x - endP.x, startP.y - endP.y);
|
|
62
|
+
|
|
63
|
+
if (totalLength >= sliderTotalLength) break;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
arrPn = [];
|
|
67
|
+
bezier = new Bezier();
|
|
68
|
+
|
|
69
|
+
if (i < anchorPositions.length - 1) {
|
|
70
|
+
arrPn.push(new PathPoint(prevPos.x, prevPos.y));
|
|
71
|
+
} else {
|
|
72
|
+
arrPn.push(new PathPoint(currPos.x, currPos.y));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
arrPn.push(new PathPoint(anchorPositions[i].x, anchorPositions[i].y));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return sliderPath;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function perfectCurve(sliderInfo, layout) {
|
|
83
|
+
const anchorPositions = sliderInfo.anchorPositions;
|
|
84
|
+
if (!sliderInfo.adjusted) {
|
|
85
|
+
adjustAnchorPositions(anchorPositions, layout);
|
|
86
|
+
sliderInfo.adjusted = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sliderTotalLength = Number(sliderInfo.sliderLength) * layout.gridUnit;
|
|
90
|
+
let totalLength = 0;
|
|
91
|
+
const sliderPath = new PIXI.Graphics();
|
|
92
|
+
sliderPath.moveTo(anchorPositions[0].x, anchorPositions[0].y);
|
|
93
|
+
|
|
94
|
+
const circleCenter = getCircleCenter(anchorPositions[0], anchorPositions[1], anchorPositions[2]);
|
|
95
|
+
const radius = Math.hypot(circleCenter.x - anchorPositions[0].x, circleCenter.y - anchorPositions[0].y);
|
|
96
|
+
|
|
97
|
+
const angleA = Math.atan2(anchorPositions[0].y - circleCenter.y, anchorPositions[0].x - circleCenter.x);
|
|
98
|
+
const angleC = Math.atan2(anchorPositions[2].y - circleCenter.y, anchorPositions[2].x - circleCenter.x);
|
|
99
|
+
|
|
100
|
+
const yDeltaA = anchorPositions[1].y - anchorPositions[0].y;
|
|
101
|
+
const xDeltaA = anchorPositions[1].x - anchorPositions[0].x;
|
|
102
|
+
const yDeltaB = anchorPositions[2].y - anchorPositions[1].y;
|
|
103
|
+
const xDeltaB = anchorPositions[2].x - anchorPositions[1].x;
|
|
104
|
+
|
|
105
|
+
const anticlockwise = xDeltaB * yDeltaA - xDeltaA * yDeltaB > 0;
|
|
106
|
+
|
|
107
|
+
const startAngle = angleA;
|
|
108
|
+
let endAngle = angleC;
|
|
109
|
+
if (!anticlockwise && endAngle - startAngle < 0) endAngle += Math.PI * 2;
|
|
110
|
+
if (anticlockwise && endAngle - startAngle > 0) endAngle -= Math.PI * 2;
|
|
111
|
+
|
|
112
|
+
const angleStep = (endAngle - startAngle) / 100;
|
|
113
|
+
|
|
114
|
+
let prevX;
|
|
115
|
+
let prevY;
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i <= 100; i++) {
|
|
118
|
+
const angle = startAngle + angleStep * i;
|
|
119
|
+
const x = circleCenter.x + radius * Math.cos(angle);
|
|
120
|
+
const y = circleCenter.y + radius * Math.sin(angle);
|
|
121
|
+
sliderPath.lineTo(x, y);
|
|
122
|
+
|
|
123
|
+
if (i !== 0) {
|
|
124
|
+
totalLength += Math.hypot(x - prevX, y - prevY);
|
|
125
|
+
if (totalLength >= sliderTotalLength) {
|
|
126
|
+
prevX = x;
|
|
127
|
+
prevY = y;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
prevX = x;
|
|
133
|
+
prevY = y;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
sliderInfo.sliderEndPos = { x: prevX, y: prevY };
|
|
137
|
+
return sliderPath;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function adjustAnchorPositions(anchorPositions, layout) {
|
|
141
|
+
for (const anchor of anchorPositions) {
|
|
142
|
+
anchor.x = layout.topLeftX + anchor.x * layout.gridUnit;
|
|
143
|
+
anchor.y = layout.topLeftY + anchor.y * layout.gridUnit;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default host for built-in skin textures and hitsound samples (repo `assets/`).
|
|
3
|
+
* @see https://github.com/inix1257/osu-beatmap-renderer/tree/main/assets
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_BUNDLED_ASSET_BASE =
|
|
6
|
+
"https://raw.githubusercontent.com/inix1257/osu-beatmap-renderer/main/assets";
|
|
7
|
+
|
|
8
|
+
/** @type {readonly string[]} */
|
|
9
|
+
export const HITSOUND_SAMPLE_FILES = [
|
|
10
|
+
"normal-hitnormal.wav",
|
|
11
|
+
"normal-hitwhistle.wav",
|
|
12
|
+
"normal-hitfinish.wav",
|
|
13
|
+
"normal-hitclap.wav",
|
|
14
|
+
"soft-hitnormal.wav",
|
|
15
|
+
"soft-hitwhistle.wav",
|
|
16
|
+
"soft-hitfinish.wav",
|
|
17
|
+
"soft-hitclap.wav",
|
|
18
|
+
"drum-hitnormal.wav",
|
|
19
|
+
"drum-hitwhistle.wav",
|
|
20
|
+
"drum-hitfinish.wav",
|
|
21
|
+
"drum-hitclap.wav",
|
|
22
|
+
];
|
package/src/index.js
ADDED