sonolus-next-rush-engine 1.0.3 → 1.0.5

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.
@@ -87,6 +87,7 @@ const guideKindMapping = {
87
87
  6: ConnectorKind.GUIDE_CYAN,
88
88
  7: ConnectorKind.GUIDE_BLACK,
89
89
  };
90
+ const LEGACY_HIDDEN_POP_WINDOW = 1 / 30;
90
91
  class ExtData {
91
92
  constructor(entities) {
92
93
  this.byArch = new Map();
@@ -146,6 +147,32 @@ function getNum(e, name, def = 0) {
146
147
  function nearlyEqual(a, b) {
147
148
  return Math.abs(a - b) < 1e-6;
148
149
  }
150
+ function lerp(a, b, t) {
151
+ return a + (b - a) * t;
152
+ }
153
+ function unlerp(a, b, x) {
154
+ return (x - a) / (b - a);
155
+ }
156
+ function clamp01(x) {
157
+ return Math.min(1, Math.max(0, x));
158
+ }
159
+ function applyEase(type, x) {
160
+ const t = clamp01(x);
161
+ switch (type) {
162
+ case EaseType.IN_QUAD:
163
+ return t * t;
164
+ case EaseType.OUT_QUAD:
165
+ return 1 - (1 - t) * (1 - t);
166
+ case EaseType.IN_OUT_QUAD:
167
+ return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
168
+ case EaseType.OUT_IN_QUAD:
169
+ return t < 0.5
170
+ ? (1 - (1 - 2 * t) * (1 - 2 * t)) / 2
171
+ : 0.5 + ((2 * t - 1) * (2 * t - 1)) / 2;
172
+ default:
173
+ return t;
174
+ }
175
+ }
149
176
  function resolveOriginal(ext, ref) {
150
177
  if (typeof ref === 'number')
151
178
  return ext.get(ref);
@@ -160,6 +187,42 @@ export const extendedToLevelData = (data, offset = 0) => {
160
187
  finalEntities.push(defaultTsg);
161
188
  const init = new EntityBuilder('Initialization');
162
189
  finalEntities.push(init);
190
+ const bpmChanges = ext
191
+ .getByArch('#BPM_CHANGE')
192
+ .map(({ e }) => ({ beat: getNum(e, '#BEAT'), bpm: getNum(e, '#BPM') }))
193
+ .sort((a, b) => a.beat - b.beat);
194
+ const bpmChangeInfos = [];
195
+ let lastBeat = 0;
196
+ let lastTime = 0;
197
+ let lastBpm = bpmChanges[0]?.bpm ?? 120;
198
+ for (const change of bpmChanges) {
199
+ lastTime += ((change.beat - lastBeat) * 60) / lastBpm;
200
+ bpmChangeInfos.push({ ...change, time: lastTime });
201
+ lastBeat = change.beat;
202
+ lastBpm = change.bpm;
203
+ }
204
+ function beatToTime(beat) {
205
+ if (bpmChangeInfos.length === 0)
206
+ return (beat * 60) / 120;
207
+ let current = bpmChangeInfos[0];
208
+ for (const change of bpmChangeInfos) {
209
+ if (change.beat > beat)
210
+ break;
211
+ current = change;
212
+ }
213
+ return current.time + ((beat - current.beat) * 60) / current.bpm;
214
+ }
215
+ function timeToBeat(time) {
216
+ if (bpmChangeInfos.length === 0)
217
+ return (time * 120) / 60;
218
+ let current = bpmChangeInfos[0];
219
+ for (const change of bpmChangeInfos) {
220
+ if (change.time > time)
221
+ break;
222
+ current = change;
223
+ }
224
+ return current.beat + ((time - current.time) * current.bpm) / 60;
225
+ }
163
226
  for (const { e } of ext.getByArch('#BPM_CHANGE')) {
164
227
  const bpm = new EntityBuilder('#BPM_CHANGE');
165
228
  bpm.set('#BEAT', getNum(e, '#BEAT'));
@@ -168,6 +231,8 @@ export const extendedToLevelData = (data, offset = 0) => {
168
231
  }
169
232
  const timescaleGroupsByIndex = new Map();
170
233
  const timescaleGroupsByName = new Map();
234
+ const timescaleChangesByIndex = new Map();
235
+ const timescaleChangesByName = new Map();
171
236
  for (const { idx, e } of ext.getByArch('TimeScaleGroup')) {
172
237
  const group = new EntityBuilder('#TIMESCALE_GROUP');
173
238
  finalEntities.push(group);
@@ -176,10 +241,15 @@ export const extendedToLevelData = (data, offset = 0) => {
176
241
  timescaleGroupsByName.set(e.name, group);
177
242
  let rawRef = getField(e, 'first');
178
243
  const changes = [];
244
+ const changeInfos = [];
179
245
  while (rawRef !== undefined) {
180
246
  const raw = resolveOriginal(ext, rawRef);
181
247
  if (!raw)
182
248
  break;
249
+ changeInfos.push({
250
+ beat: getNum(raw, '#BEAT'),
251
+ timeScale: getNum(raw, 'timeScale'),
252
+ });
183
253
  const change = new EntityBuilder('#TIMESCALE_CHANGE');
184
254
  change.set('#BEAT', getNum(raw, '#BEAT'));
185
255
  change.set('#TIMESCALE', getNum(raw, 'timeScale'));
@@ -200,6 +270,10 @@ export const extendedToLevelData = (data, offset = 0) => {
200
270
  group.set('first', changes[0]);
201
271
  }
202
272
  finalEntities.push(...changes);
273
+ changeInfos.sort((a, b) => a.beat - b.beat);
274
+ timescaleChangesByIndex.set(idx, changeInfos);
275
+ if (e.name)
276
+ timescaleChangesByName.set(e.name, changeInfos);
203
277
  }
204
278
  function getTSG(ref) {
205
279
  if (typeof ref === 'number')
@@ -208,6 +282,81 @@ export const extendedToLevelData = (data, offset = 0) => {
208
282
  return timescaleGroupsByName.get(ref);
209
283
  return undefined;
210
284
  }
285
+ function getTSGChanges(ref) {
286
+ if (typeof ref === 'number')
287
+ return timescaleChangesByIndex.get(ref) ?? [];
288
+ if (typeof ref === 'string')
289
+ return timescaleChangesByName.get(ref) ?? [];
290
+ return [];
291
+ }
292
+ function timeToScaledTime(time, changes) {
293
+ if (changes.length === 0)
294
+ return time;
295
+ const firstTime = beatToTime(changes[0].beat);
296
+ if (time < firstTime)
297
+ return time;
298
+ let scaledTime = firstTime;
299
+ for (let i = 0; i < changes.length; i++) {
300
+ const start = changes[i];
301
+ const startTime = beatToTime(start.beat);
302
+ const endTime = i === changes.length - 1 ? undefined : beatToTime(changes[i + 1].beat);
303
+ if (endTime === undefined || time < endTime) {
304
+ return scaledTime + (time - startTime) * start.timeScale;
305
+ }
306
+ scaledTime += (endTime - startTime) * start.timeScale;
307
+ }
308
+ return time;
309
+ }
310
+ function scaledTimeToTime(scaledTime, changes) {
311
+ if (changes.length === 0)
312
+ return scaledTime;
313
+ const firstTime = beatToTime(changes[0].beat);
314
+ if (scaledTime < firstTime)
315
+ return scaledTime;
316
+ let currentScaledTime = firstTime;
317
+ for (let i = 0; i < changes.length; i++) {
318
+ const start = changes[i];
319
+ const startTime = beatToTime(start.beat);
320
+ const endTime = i === changes.length - 1 ? undefined : beatToTime(changes[i + 1].beat);
321
+ if (endTime === undefined) {
322
+ if (start.timeScale === 0)
323
+ return Number.POSITIVE_INFINITY;
324
+ return startTime + (scaledTime - currentScaledTime) / start.timeScale;
325
+ }
326
+ const nextScaledTime = currentScaledTime + (endTime - startTime) * start.timeScale;
327
+ const minScaledTime = Math.min(currentScaledTime, nextScaledTime);
328
+ const maxScaledTime = Math.max(currentScaledTime, nextScaledTime);
329
+ if (minScaledTime <= scaledTime && scaledTime <= maxScaledTime) {
330
+ if (Math.abs(nextScaledTime - currentScaledTime) < 1e-6)
331
+ return startTime;
332
+ return lerp(startTime, endTime, unlerp(currentScaledTime, nextScaledTime, scaledTime));
333
+ }
334
+ currentScaledTime = nextScaledTime;
335
+ }
336
+ return scaledTime;
337
+ }
338
+ function createHideUntilTimescaleGroup(showBeat) {
339
+ const group = new EntityBuilder('#TIMESCALE_GROUP');
340
+ const hide = new EntityBuilder('#TIMESCALE_CHANGE');
341
+ const show = new EntityBuilder('#TIMESCALE_CHANGE');
342
+ const hideBeat = Math.min(0, showBeat - 1e-6);
343
+ hide.set('#BEAT', hideBeat);
344
+ hide.set('#TIMESCALE', 1);
345
+ hide.set('#TIMESCALE_SKIP', 0);
346
+ hide.set('#TIMESCALE_GROUP', group);
347
+ hide.set('#TIMESCALE_EASE', 0);
348
+ hide.set('hideNotes', 1);
349
+ hide.set('next', show);
350
+ show.set('#BEAT', showBeat);
351
+ show.set('#TIMESCALE', 1);
352
+ show.set('#TIMESCALE_SKIP', 0);
353
+ show.set('#TIMESCALE_GROUP', group);
354
+ show.set('#TIMESCALE_EASE', 0);
355
+ show.set('hideNotes', 0);
356
+ group.set('first', hide);
357
+ finalEntities.push(group, hide, show);
358
+ return group;
359
+ }
211
360
  const notesByIndex = new Map();
212
361
  const notesByName = new Map();
213
362
  const connectorsByIndex = new Map();
@@ -252,16 +401,88 @@ export const extendedToLevelData = (data, offset = 0) => {
252
401
  nearlyEqual(getNum(start, 'lane'), getNum(head, 'lane')) &&
253
402
  nearlyEqual(getNum(start, 'size'), getNum(head, 'size')));
254
403
  }
404
+ function createConnectorAnchor(beat, lane, size, tsg, kind) {
405
+ const anchor = new EntityBuilder('AnchorNote');
406
+ anchor.set('#BEAT', beat);
407
+ anchor.set('lane', lane);
408
+ anchor.set('size', size);
409
+ anchor.set('direction', FlickDirection.UP_OMNI);
410
+ anchor.set('#TIMESCALE_GROUP', tsg || defaultTsg);
411
+ anchor.set('isAttached', 0);
412
+ anchor.set('connectorEase', EaseType.LINEAR);
413
+ anchor.set('isSeparator', 1);
414
+ anchor.set('segmentKind', kind);
415
+ anchor.set('segmentAlpha', 1);
416
+ anchor.set('segmentLayer', 0);
417
+ finalEntities.push(anchor);
418
+ return anchor;
419
+ }
420
+ function getConnectorSplitAnchors(headOriginal, tailOriginal, tsg, kind, ease) {
421
+ const headBeat = getNum(headOriginal, '#BEAT');
422
+ const tailBeat = getNum(tailOriginal, '#BEAT');
423
+ if (tailBeat <= headBeat)
424
+ return [];
425
+ const headTsgRef = getField(headOriginal, 'timeScaleGroup');
426
+ const tailTsgRef = getField(tailOriginal, 'timeScaleGroup');
427
+ const headChanges = getTSGChanges(headTsgRef);
428
+ const tailChanges = getTSGChanges(tailTsgRef);
429
+ const splitBeats = headChanges
430
+ .map(({ beat }) => beat)
431
+ .filter((beat) => headBeat + 1e-6 < beat && beat < tailBeat - 1e-6);
432
+ if (splitBeats.length === 0)
433
+ return [];
434
+ const headScaledTime = timeToScaledTime(beatToTime(headBeat), headChanges);
435
+ const tailScaledTime = timeToScaledTime(beatToTime(tailBeat), tailChanges);
436
+ if (Math.abs(tailScaledTime - headScaledTime) < 1e-6)
437
+ return [];
438
+ if (ease !== EaseType.LINEAR) {
439
+ const sampleCount = 8;
440
+ for (let i = 1; i < sampleCount; i++) {
441
+ const scaledTime = lerp(headScaledTime, tailScaledTime, i / sampleCount);
442
+ const beat = timeToBeat(scaledTimeToTime(scaledTime, headChanges));
443
+ if (Number.isFinite(beat) && headBeat + 1e-6 < beat && beat < tailBeat - 1e-6) {
444
+ splitBeats.push(beat);
445
+ }
446
+ }
447
+ }
448
+ const uniqueSplitBeats = [...splitBeats]
449
+ .sort((a, b) => a - b)
450
+ .filter((beat, i, beats) => i === 0 || !nearlyEqual(beat, beats[i - 1]));
451
+ const headLane = getNum(headOriginal, 'lane');
452
+ const tailLane = getNum(tailOriginal, 'lane');
453
+ const headSize = getNum(headOriginal, 'size');
454
+ const tailSize = getNum(tailOriginal, 'size');
455
+ return uniqueSplitBeats.map((beat) => {
456
+ const scaledTime = timeToScaledTime(beatToTime(beat), headChanges);
457
+ const frac = unlerp(headScaledTime, tailScaledTime, scaledTime);
458
+ const easedFrac = applyEase(ease, frac);
459
+ return createConnectorAnchor(beat, lerp(headLane, tailLane, easedFrac), lerp(headSize, tailSize, easedFrac), tsg, kind);
460
+ });
461
+ }
462
+ function isReverseHiddenPopConnector(headOriginal, tailOriginal) {
463
+ if (!headOriginal || !tailOriginal)
464
+ return false;
465
+ if (headOriginal.archetype !== 'HiddenSlideStartNote')
466
+ return false;
467
+ if (tailOriginal.archetype !== 'HiddenSlideTickNote')
468
+ return false;
469
+ return getNum(tailOriginal, '#BEAT') < getNum(headOriginal, '#BEAT') - 1e-6;
470
+ }
255
471
  for (const { idx, e } of ext.connectors) {
256
472
  const startRef = getField(e, 'start');
257
473
  const headRef = getField(e, 'head');
474
+ const tailRef = getField(e, 'tail');
258
475
  const rawHead = getNote(headRef);
259
- const tail = getNote(getField(e, 'tail'));
260
- const segmentHead = getNote(startRef);
261
- const head = shouldUseStartAsHead(startRef, headRef) ? segmentHead : rawHead;
476
+ const tail = getNote(tailRef);
477
+ const rawHeadOriginal = resolveOriginal(ext, headRef);
478
+ const tailOriginal = resolveOriginal(ext, tailRef);
479
+ const activeHead = getNote(startRef);
480
+ const usesStartAsHead = shouldUseStartAsHead(startRef, headRef);
481
+ const head = usesStartAsHead ? activeHead : rawHead;
482
+ const headOriginal = resolveOriginal(ext, usesStartAsHead ? startRef : headRef);
262
483
  const endRef = getField(e, 'end');
263
- let segmentTail = getNote(endRef);
264
- if (!segmentTail) {
484
+ let activeTail = getNote(endRef);
485
+ if (!activeTail) {
265
486
  const currentTailRef = getField(e, 'tail');
266
487
  let ultimateTailRef = currentTailRef;
267
488
  const visited = new Set();
@@ -276,32 +497,66 @@ export const extendedToLevelData = (data, offset = 0) => {
276
497
  break;
277
498
  }
278
499
  }
279
- segmentTail = getNote(ultimateTailRef);
500
+ activeTail = getNote(ultimateTailRef);
280
501
  }
281
- if (!segmentTail) {
282
- segmentTail = tail;
502
+ if (!activeTail) {
503
+ activeTail = tail;
283
504
  }
284
- if (!head || !tail || !segmentHead || !segmentTail)
505
+ if (!head || !tail || !activeHead || !activeTail)
285
506
  continue;
286
507
  const kind = activeConnectorKindMapping[e.archetype];
287
508
  const ease = easeTypeMapping[getNum(e, 'ease')] ?? EaseType.LINEAR;
288
- const connector = new EntityBuilder('Connector');
289
- connector.set('head', head);
290
- connector.set('tail', tail);
291
- connector.set('segmentHead', segmentHead);
292
- connector.set('segmentTail', segmentTail);
293
- connector.set('activeHead', segmentHead);
294
- connector.set('activeTail', segmentTail);
295
- head.set('connectorEase', ease);
296
- head.set('segmentKind', kind);
509
+ const tsg = headOriginal ? getTSG(getField(headOriginal, 'timeScaleGroup')) : undefined;
510
+ const reverseHiddenPopConnector = isReverseHiddenPopConnector(rawHeadOriginal, tailOriginal);
511
+ const splitAnchors = headOriginal && tailOriginal && !reverseHiddenPopConnector
512
+ ? getConnectorSplitAnchors(headOriginal, tailOriginal, tsg, kind, ease)
513
+ : [];
514
+ const segmentEase = splitAnchors.length > 0 ? EaseType.LINEAR : ease;
515
+ const segmentNotes = [head, ...splitAnchors, tail];
516
+ if (reverseHiddenPopConnector && rawHeadOriginal && tailOriginal) {
517
+ const showTime = beatToTime(getNum(tailOriginal, '#BEAT')) - LEGACY_HIDDEN_POP_WINDOW;
518
+ const showBeat = timeToBeat(showTime);
519
+ const gateTsg = createHideUntilTimescaleGroup(showBeat);
520
+ const segmentHead = createConnectorAnchor(getNum(rawHeadOriginal, '#BEAT'), getNum(rawHeadOriginal, 'lane'), getNum(rawHeadOriginal, 'size'), gateTsg, kind);
521
+ const connector = new EntityBuilder('Connector');
522
+ connector.set('head', head);
523
+ connector.set('tail', tail);
524
+ connector.set('segmentHead', segmentHead);
525
+ connector.set('segmentTail', tail);
526
+ connector.set('activeHead', activeHead);
527
+ connector.set('activeTail', activeTail);
528
+ finalEntities.push(connector);
529
+ }
530
+ else {
531
+ for (let i = 0; i < segmentNotes.length - 1; i++) {
532
+ const segmentHead = segmentNotes[i];
533
+ const segmentTail = segmentNotes[i + 1];
534
+ const connector = new EntityBuilder('Connector');
535
+ connector.set('head', segmentHead);
536
+ connector.set('tail', segmentTail);
537
+ connector.set('segmentHead', segmentHead);
538
+ connector.set('segmentTail', segmentTail);
539
+ connector.set('activeHead', activeHead);
540
+ connector.set('activeTail', activeTail);
541
+ finalEntities.push(connector);
542
+ }
543
+ }
544
+ const connectorLink = new EntityBuilder('Connector');
545
+ connectorLink.set('head', head);
546
+ connectorLink.set('tail', tail);
547
+ connectorLink.set('activeHead', activeHead);
548
+ connectorLink.set('activeTail', activeTail);
549
+ for (const segmentHead of segmentNotes.slice(0, -1)) {
550
+ segmentHead.set('connectorEase', segmentEase);
551
+ segmentHead.set('segmentKind', kind);
552
+ segmentHead.set('segmentAlpha', 1);
553
+ }
297
554
  tail.set('segmentKind', kind);
298
- segmentHead.set('segmentKind', kind);
299
- segmentHead.set('segmentAlpha', 1);
300
- segmentTail.set('segmentAlpha', 1);
301
- finalEntities.push(connector);
302
- connectorsByIndex.set(idx, connector);
555
+ tail.set('segmentAlpha', 1);
556
+ activeHead.set('segmentKind', kind);
557
+ connectorsByIndex.set(idx, connectorLink);
303
558
  if (e.name)
304
- connectorsByName.set(e.name, connector);
559
+ connectorsByName.set(e.name, connectorLink);
305
560
  }
306
561
  function getConn(ref) {
307
562
  if (typeof ref === 'number')
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ import { USC } from './usc/index.js';
7
7
  export * from './usc/index.js';
8
8
  export { type ExtendedEntityData, type ExtendedEntityDataField, extendedToLevelData, mmwsToUSC, susToUSC, ucmmwsToLevelData, uscToLevelData, };
9
9
  export declare const convertToLevelData: (input: string | Uint8Array | USC | LevelData, offset?: number) => LevelData;
10
- export declare const version = "1.0.3";
10
+ export declare const version = "1.0.5";
11
11
  export declare const databaseEngineItem: {
12
12
  readonly name: "next-rush";
13
13
  readonly version: 13;
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ export const convertToLevelData = (input, offset = 0) => {
65
65
  }
66
66
  return uscToLevelData(usc, offset, true, true);
67
67
  };
68
- export const version = '1.0.3';
68
+ export const version = '1.0.5';
69
69
  export const databaseEngineItem = {
70
70
  name: 'next-rush',
71
71
  version: 13,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonolus-next-rush-engine",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Perspective-lane rhythm game for Sonolus",
5
5
  "author": "Hyeon2",
6
6
  "repository": {