sonolus-next-rush-engine 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,32 @@
1
+ type Meta = Map<string, string[]>;
2
+ export interface TimeScaleChangeObject {
3
+ tick: number;
4
+ timeScale: number;
5
+ }
6
+ export interface BpmChangeObject {
7
+ tick: number;
8
+ bpm: number;
9
+ }
10
+ export interface NoteObject {
11
+ tick: number;
12
+ lane: number;
13
+ width: number;
14
+ type: number;
15
+ timeScaleGroup: number;
16
+ }
17
+ export interface SlideObject {
18
+ type: number;
19
+ notes: NoteObject[];
20
+ }
21
+ export interface Score {
22
+ offset: number;
23
+ ticksPerBeat: number;
24
+ timeScaleChanges: TimeScaleChangeObject[][];
25
+ bpmChanges: BpmChangeObject[];
26
+ tapNotes: NoteObject[];
27
+ directionalNotes: NoteObject[];
28
+ slides: SlideObject[];
29
+ meta: Meta;
30
+ }
31
+ export declare const analyze: (sus: string) => Score;
32
+ export {};
@@ -0,0 +1,287 @@
1
+ export const analyze = (sus) => {
2
+ if (!sus || sus.trim().length === 0) {
3
+ throw new Error('The sus file does not exist');
4
+ }
5
+ const { lines, measureChanges, timeScaleGroupChanges, meta } = parse(sus);
6
+ const offset = -+(meta.get('WAVEOFFSET') || '0');
7
+ if (Number.isNaN(offset))
8
+ throw new Error('Unexpected offset');
9
+ const ticksPerBeat = getTicksPerBeat(meta);
10
+ if (!ticksPerBeat)
11
+ throw new Error('Missing or unexpected ticks per beat');
12
+ const barLengths = getBarLengths(lines, measureChanges);
13
+ const toTick = getToTick(barLengths, ticksPerBeat);
14
+ const bpms = new Map();
15
+ const bpmChanges = [];
16
+ const timeScaleGroups = new Map();
17
+ const timeScaleChanges = [];
18
+ const tapNotes = [];
19
+ const directionalNotes = [];
20
+ const streams = new Map();
21
+ for (const [, timeScaleGroup] of timeScaleGroupChanges) {
22
+ if (timeScaleGroups.has(timeScaleGroup))
23
+ continue;
24
+ timeScaleGroups.set(timeScaleGroup, timeScaleGroups.size);
25
+ timeScaleChanges.push([]);
26
+ }
27
+ // Time Scale Changes
28
+ for (const line of lines) {
29
+ const [header] = line;
30
+ if (header.length === 5 && header.startsWith('TIL')) {
31
+ const timeScaleGroup = header.substring(3, 5);
32
+ const timeScaleIndex = timeScaleGroups.get(timeScaleGroup);
33
+ if (timeScaleIndex === undefined) {
34
+ continue;
35
+ }
36
+ timeScaleChanges[timeScaleIndex].push(...toTimeScaleChanges(line, toTick));
37
+ }
38
+ }
39
+ const customSpeedGroups = new Map();
40
+ const getCustomGroup = (baseGroup, speedRatio) => {
41
+ const key = `${baseGroup}_${speedRatio}`;
42
+ const group = customSpeedGroups.get(key);
43
+ if (group !== undefined)
44
+ return group;
45
+ const newIndex = timeScaleChanges.length;
46
+ customSpeedGroups.set(key, newIndex);
47
+ const baseChanges = timeScaleChanges[baseGroup] || [];
48
+ let scaledChanges = [];
49
+ if (baseChanges.length === 0) {
50
+ scaledChanges = [{ tick: 0, timeScale: speedRatio }];
51
+ }
52
+ else {
53
+ scaledChanges = baseChanges.map((change) => ({
54
+ tick: change.tick,
55
+ timeScale: change.timeScale * speedRatio,
56
+ }));
57
+ }
58
+ timeScaleChanges.push(scaledChanges);
59
+ return newIndex;
60
+ };
61
+ lines.forEach((line, index) => {
62
+ const [header, data] = line;
63
+ const measureOffset = measureChanges.find(([changeIndex]) => changeIndex <= index)?.[1] ?? 0;
64
+ const timeScaleGroupName = timeScaleGroupChanges.find(([changeIndex]) => changeIndex <= index)?.[1] ?? '00';
65
+ let timeScaleGroup = timeScaleGroups.get(timeScaleGroupName);
66
+ if (timeScaleGroup === undefined) {
67
+ timeScaleGroup = timeScaleGroups.size;
68
+ timeScaleGroups.set(timeScaleGroupName, timeScaleGroups.size);
69
+ timeScaleChanges.push([]);
70
+ }
71
+ // Hispeed definitions
72
+ if (header.length === 5 && header.startsWith('TIL')) {
73
+ return;
74
+ }
75
+ // BPM
76
+ if (header.length === 5 && header.startsWith('BPM')) {
77
+ bpms.set(header.substring(3), +data);
78
+ return;
79
+ }
80
+ // BPM Changes
81
+ if (header.length === 5 && header.endsWith('08')) {
82
+ bpmChanges.push(...toBpmChanges(line, measureOffset, bpms, toTick));
83
+ return;
84
+ }
85
+ // Tap Notes
86
+ if (header.length === 5 && header[3] === '1') {
87
+ tapNotes.push(...toNotes(line, measureOffset, timeScaleGroup, toTick, getCustomGroup));
88
+ return;
89
+ }
90
+ // Streams
91
+ if (header.length === 6 && (header[3] === '3' || header[3] === '9')) {
92
+ const key = `${header[5]}-${header[3]}`;
93
+ const stream = streams.get(key);
94
+ if (stream) {
95
+ stream.notes.push(...toNotes(line, measureOffset, timeScaleGroup, toTick, getCustomGroup));
96
+ }
97
+ else {
98
+ streams.set(key, {
99
+ type: +header[3],
100
+ notes: toNotes(line, measureOffset, timeScaleGroup, toTick, getCustomGroup),
101
+ });
102
+ }
103
+ return;
104
+ }
105
+ // Directional Notes
106
+ if (header.length === 5 && header[3] === '5') {
107
+ directionalNotes.push(...toNotes(line, measureOffset, timeScaleGroup, toTick, getCustomGroup));
108
+ return;
109
+ }
110
+ });
111
+ const slides = [...streams.values()].flatMap(toSlides);
112
+ return {
113
+ offset,
114
+ ticksPerBeat,
115
+ timeScaleChanges,
116
+ bpmChanges,
117
+ tapNotes,
118
+ directionalNotes,
119
+ slides,
120
+ meta,
121
+ };
122
+ };
123
+ const parse = (sus) => {
124
+ const lines = [];
125
+ const measureChanges = [];
126
+ const timeScaleGroupChanges = [];
127
+ const meta = new Map();
128
+ sus.split('\n')
129
+ .map((line) => line.trim())
130
+ .filter((line) => line.startsWith('#'))
131
+ .forEach((line) => {
132
+ const isLine = line.includes(':');
133
+ const index = line.indexOf(isLine ? ':' : ' ');
134
+ if (index === -1)
135
+ return;
136
+ const left = line.substring(1, index).trim();
137
+ const right = line.substring(index + 1).trim();
138
+ if (isLine) {
139
+ lines.push([left, right]);
140
+ }
141
+ else if (left === 'MEASUREBS') {
142
+ measureChanges.unshift([lines.length, +right]);
143
+ }
144
+ else if (left === 'HISPEED') {
145
+ timeScaleGroupChanges.unshift([lines.length, right]);
146
+ }
147
+ else {
148
+ if (!meta.has(left))
149
+ meta.set(left, []);
150
+ meta.get(left)?.push(right);
151
+ }
152
+ });
153
+ return {
154
+ lines,
155
+ measureChanges,
156
+ timeScaleGroupChanges,
157
+ meta,
158
+ };
159
+ };
160
+ const getTicksPerBeat = (meta) => {
161
+ const request = meta.get('REQUEST');
162
+ if (!request)
163
+ return;
164
+ const tpbRequest = request.find((r) => JSON.parse(r).startsWith('ticks_per_beat'));
165
+ if (!tpbRequest)
166
+ return;
167
+ return +JSON.parse(tpbRequest).split(' ')[1];
168
+ };
169
+ const getBarLengths = (lines, measureChanges) => {
170
+ const barLengths = [];
171
+ lines.forEach((line, index) => {
172
+ const [header, data] = line;
173
+ if (header.length !== 5)
174
+ return;
175
+ if (!header.endsWith('02'))
176
+ return;
177
+ const measure = +header.substring(0, 3) +
178
+ (measureChanges.find(([changeIndex]) => changeIndex <= index)?.[1] ?? 0);
179
+ if (Number.isNaN(measure))
180
+ return;
181
+ barLengths.push({ measure, length: +data });
182
+ });
183
+ return barLengths;
184
+ };
185
+ const getToTick = (barLengths, ticksPerBeat) => {
186
+ let ticks = 0;
187
+ const bars = barLengths
188
+ .sort((a, b) => a.measure - b.measure)
189
+ .map(({ measure, length }, i, values) => {
190
+ if (i) {
191
+ const prev = values[i - 1];
192
+ ticks += (measure - prev.measure) * prev.length * ticksPerBeat;
193
+ }
194
+ return { measure, ticksPerMeasure: length * ticksPerBeat, ticks };
195
+ })
196
+ .reverse();
197
+ return (measure, p, q) => {
198
+ const bar = bars.find((bar) => measure >= bar.measure);
199
+ if (!bar)
200
+ throw new Error('Unexpected missing bar');
201
+ return (bar.ticks +
202
+ (measure - bar.measure) * bar.ticksPerMeasure +
203
+ (p * bar.ticksPerMeasure) / q);
204
+ };
205
+ };
206
+ const toBpmChanges = (line, measureOffset, bpms, toTick) => toRaws(line, measureOffset, toTick).map(({ tick, value }) => ({
207
+ tick,
208
+ bpm: bpms.get(value) ?? 0,
209
+ }));
210
+ const toTimeScaleChanges = ([, data], toTick) => {
211
+ if (!data.startsWith('"') || !data.endsWith('"'))
212
+ throw new Error('Unexpected time scale changes');
213
+ return data
214
+ .slice(1, -1)
215
+ .split(',')
216
+ .map((segment) => segment.trim())
217
+ .filter((segment) => !!segment)
218
+ .map((segment) => {
219
+ const [l, rest] = segment.split("'");
220
+ const [m, r] = rest.split(':');
221
+ const measure = +l;
222
+ const tick = +m;
223
+ const timeScale = +r;
224
+ if (Number.isNaN(measure) || Number.isNaN(tick) || Number.isNaN(timeScale))
225
+ throw new Error('Unexpected time scale change');
226
+ return {
227
+ tick: toTick(measure, 0, 1) + tick,
228
+ timeScale,
229
+ };
230
+ })
231
+ .sort((a, b) => a.tick - b.tick);
232
+ };
233
+ const toNotes = (line, measureOffset, baseTimeScaleGroup, toTick, getCustomGroup) => {
234
+ const [header] = line;
235
+ const lane = parseInt(header[4], 36);
236
+ return toRaws(line, measureOffset, toTick).map(({ tick, value, speedRatio }) => {
237
+ const width = parseInt(value[1], 36);
238
+ const timeScaleGroup = speedRatio !== undefined
239
+ ? getCustomGroup(baseTimeScaleGroup, speedRatio)
240
+ : baseTimeScaleGroup;
241
+ return {
242
+ tick,
243
+ lane,
244
+ width,
245
+ type: parseInt(value[0], 36),
246
+ timeScaleGroup,
247
+ };
248
+ });
249
+ };
250
+ const toSlides = (stream) => {
251
+ const slides = [];
252
+ let notes;
253
+ for (const note of stream.notes.sort((a, b) => a.tick - b.tick)) {
254
+ if (!notes) {
255
+ notes = [];
256
+ slides.push({
257
+ type: stream.type,
258
+ notes,
259
+ });
260
+ }
261
+ notes.push(note);
262
+ if (note.type === 2) {
263
+ notes = undefined;
264
+ }
265
+ }
266
+ return slides;
267
+ };
268
+ const toRaws = ([header, data], measureOffset, toTick) => {
269
+ const measure = +header.substring(0, 3) + measureOffset;
270
+ const matches = [...data.matchAll(/([0-9a-zA-Z]{2})(?:,([-+]?\d*\.?\d+))?/g)];
271
+ return matches
272
+ .map((match, i, values) => {
273
+ const value = match[1];
274
+ if (value === '00')
275
+ return null;
276
+ const speedRatio = match[2] !== undefined ? parseFloat(match[2]) : undefined;
277
+ const rawObject = {
278
+ tick: toTick(measure, i, values.length),
279
+ value,
280
+ };
281
+ if (speedRatio !== undefined) {
282
+ rawObject.speedRatio = speedRatio;
283
+ }
284
+ return rawObject;
285
+ })
286
+ .filter((object) => object !== null);
287
+ };
@@ -0,0 +1,5 @@
1
+ import { USC } from '../usc/index.js';
2
+ import { Score } from './analyze.js';
3
+ /** Convert a SUS to a USC */
4
+ export declare const susToUSC: (sus: string) => USC;
5
+ export declare const chsLikeToUSC: (score: Score) => USC;
@@ -0,0 +1,320 @@
1
+ import { analyze } from './analyze.js';
2
+ /** Convert a SUS to a USC */
3
+ export const susToUSC = (sus) => chsLikeToUSC(analyze(sus));
4
+ export const chsLikeToUSC = (score) => {
5
+ const flickMods = new Map();
6
+ const traceMods = new Set();
7
+ const criticalMods = new Set();
8
+ const tickRemoveMods = new Set();
9
+ const slideStartEndRemoveMods = new Set();
10
+ const easeMods = new Map();
11
+ const preventSingles = new Set();
12
+ const dedupeSingles = new Set();
13
+ const dedupeSlides = new Map();
14
+ const requests = {
15
+ sideLane: false,
16
+ laneOffset: 0,
17
+ };
18
+ const requestsRaw = score.meta.get('REQUEST');
19
+ if (requestsRaw) {
20
+ for (const request of requestsRaw) {
21
+ try {
22
+ const [key, value] = JSON.parse(request).split(' ', 2);
23
+ switch (key) {
24
+ case 'side_lane':
25
+ requests.sideLane = value === 'true';
26
+ break;
27
+ case 'lane_offset':
28
+ requests.laneOffset = Number(value);
29
+ break;
30
+ }
31
+ }
32
+ catch (_e) {
33
+ // Noop
34
+ }
35
+ }
36
+ }
37
+ for (const slide of score.slides) {
38
+ if (slide.type !== 3)
39
+ continue;
40
+ for (const note of slide.notes) {
41
+ const key = getKey(note);
42
+ switch (note.type) {
43
+ case 1:
44
+ case 2:
45
+ case 3:
46
+ case 5:
47
+ preventSingles.add(key);
48
+ break;
49
+ }
50
+ }
51
+ }
52
+ for (const note of score.directionalNotes) {
53
+ const key = getKey(note);
54
+ switch (note.type) {
55
+ case 1:
56
+ flickMods.set(key, 'up');
57
+ break;
58
+ case 3:
59
+ flickMods.set(key, 'left');
60
+ break;
61
+ case 4:
62
+ flickMods.set(key, 'right');
63
+ break;
64
+ case 2:
65
+ easeMods.set(key, 'in');
66
+ break;
67
+ case 5:
68
+ case 6:
69
+ easeMods.set(key, 'out');
70
+ break;
71
+ }
72
+ }
73
+ for (const note of score.tapNotes) {
74
+ const key = getKey(note);
75
+ switch (note.type) {
76
+ case 2:
77
+ criticalMods.add(key);
78
+ break;
79
+ case 5:
80
+ traceMods.add(key);
81
+ break;
82
+ case 6:
83
+ traceMods.add(key);
84
+ criticalMods.add(key);
85
+ break;
86
+ case 3:
87
+ tickRemoveMods.add(key);
88
+ break;
89
+ case 7:
90
+ slideStartEndRemoveMods.add(key);
91
+ break;
92
+ case 8:
93
+ criticalMods.add(key);
94
+ slideStartEndRemoveMods.add(key);
95
+ break;
96
+ }
97
+ }
98
+ const objects = [];
99
+ for (const timeScaleChanges of score.timeScaleChanges) {
100
+ objects.push({
101
+ type: 'timeScaleGroup',
102
+ changes: timeScaleChanges.map((timeScaleChange) => ({
103
+ beat: timeScaleChange.tick / score.ticksPerBeat,
104
+ timeScale: timeScaleChange.timeScale,
105
+ })),
106
+ });
107
+ }
108
+ for (const bpmChange of score.bpmChanges) {
109
+ objects.push({
110
+ type: 'bpm',
111
+ beat: bpmChange.tick / score.ticksPerBeat,
112
+ bpm: bpmChange.bpm,
113
+ });
114
+ }
115
+ for (const note of score.tapNotes) {
116
+ if (note.lane === 0 && note.type === 4) {
117
+ objects.push({
118
+ type: 'skill',
119
+ beat: note.tick / score.ticksPerBeat,
120
+ });
121
+ continue;
122
+ }
123
+ if (note.lane === 15 && note.type === 1) {
124
+ objects.push({
125
+ type: 'feverChance',
126
+ beat: note.tick / score.ticksPerBeat,
127
+ });
128
+ continue;
129
+ }
130
+ if (note.lane === 15 && note.type === 2) {
131
+ objects.push({
132
+ type: 'feverStart',
133
+ beat: note.tick / score.ticksPerBeat,
134
+ });
135
+ continue;
136
+ }
137
+ if (!requests.sideLane && (note.lane <= 1 || note.lane >= 14))
138
+ continue;
139
+ if (note.type !== 1 &&
140
+ note.type !== 2 &&
141
+ note.type !== 5 &&
142
+ note.type !== 6 &&
143
+ note.type !== 4)
144
+ continue;
145
+ const key = getKey(note);
146
+ if (preventSingles.has(key))
147
+ continue;
148
+ if (dedupeSingles.has(key))
149
+ continue;
150
+ dedupeSingles.add(key);
151
+ let object;
152
+ switch (note.type) {
153
+ case 1:
154
+ case 2:
155
+ case 5:
156
+ case 6: {
157
+ object = {
158
+ type: 'single',
159
+ beat: note.tick / score.ticksPerBeat,
160
+ lane: note.lane - 8 + note.width / 2 + requests.laneOffset,
161
+ size: note.width / 2,
162
+ critical: [2, 6].includes(note.type),
163
+ trace: [5, 6].includes(note.type),
164
+ timeScaleGroup: note.timeScaleGroup,
165
+ };
166
+ const flickMod = flickMods.get(key);
167
+ if (flickMod)
168
+ object.direction = flickMod;
169
+ break;
170
+ }
171
+ case 4:
172
+ object = {
173
+ type: 'damage',
174
+ beat: note.tick / score.ticksPerBeat,
175
+ lane: note.lane - 8 + note.width / 2 + requests.laneOffset,
176
+ size: note.width / 2,
177
+ timeScaleGroup: note.timeScaleGroup,
178
+ };
179
+ break;
180
+ default:
181
+ continue;
182
+ }
183
+ objects.push(object);
184
+ }
185
+ for (const slide of score.slides) {
186
+ const startNote = slide.notes.find(({ type }) => type === 1 || type === 2);
187
+ if (!startNote)
188
+ continue;
189
+ const object = {
190
+ type: slide.type === 3 ? 'slide' : 'guide',
191
+ critical: criticalMods.has(getKey(startNote)),
192
+ connections: [],
193
+ };
194
+ for (const note of slide.notes) {
195
+ const key = getKey(note);
196
+ const beat = note.tick / score.ticksPerBeat;
197
+ const lane = note.lane - 8 + note.width / 2 + requests.laneOffset;
198
+ const size = note.width / 2;
199
+ const timeScaleGroup = note.timeScaleGroup;
200
+ const critical = ('critical' in object && object.critical) || criticalMods.has(key);
201
+ const ease = easeMods.get(key) ?? 'linear';
202
+ let judgeType = 'normal';
203
+ if (traceMods.has(key))
204
+ judgeType = 'trace';
205
+ switch (note.type) {
206
+ case 1: {
207
+ if (object.type == 'guide' || slideStartEndRemoveMods.has(key)) {
208
+ const connection = {
209
+ type: 'start',
210
+ beat,
211
+ lane,
212
+ size,
213
+ critical,
214
+ ease,
215
+ judgeType: 'none',
216
+ timeScaleGroup,
217
+ };
218
+ object.connections.push(connection);
219
+ }
220
+ else {
221
+ const connection = {
222
+ type: 'start',
223
+ beat,
224
+ lane,
225
+ size,
226
+ critical,
227
+ ease: easeMods.get(key) ?? 'linear',
228
+ judgeType,
229
+ timeScaleGroup,
230
+ };
231
+ object.connections.push(connection);
232
+ }
233
+ break;
234
+ }
235
+ case 2: {
236
+ if (object.type == 'guide' || slideStartEndRemoveMods.has(key)) {
237
+ const connection = {
238
+ type: 'end',
239
+ beat,
240
+ lane,
241
+ size,
242
+ critical,
243
+ judgeType: 'none',
244
+ timeScaleGroup,
245
+ };
246
+ object.connections.push(connection);
247
+ }
248
+ else {
249
+ const connection = {
250
+ type: 'end',
251
+ beat,
252
+ lane,
253
+ size,
254
+ critical,
255
+ judgeType,
256
+ timeScaleGroup,
257
+ };
258
+ const flickMod = flickMods.get(key);
259
+ if (flickMod)
260
+ connection.direction = flickMod;
261
+ object.connections.push(connection);
262
+ }
263
+ break;
264
+ }
265
+ case 3: {
266
+ if (tickRemoveMods.has(key)) {
267
+ const connection = {
268
+ type: 'attach',
269
+ beat,
270
+ critical,
271
+ timeScaleGroup,
272
+ };
273
+ object.connections.push(connection);
274
+ }
275
+ else {
276
+ const connection = {
277
+ type: 'tick',
278
+ beat,
279
+ lane,
280
+ size,
281
+ critical,
282
+ ease,
283
+ judgeType,
284
+ timeScaleGroup,
285
+ };
286
+ object.connections.push(connection);
287
+ }
288
+ break;
289
+ }
290
+ case 5: {
291
+ if (tickRemoveMods.has(key))
292
+ break;
293
+ const connection = {
294
+ type: 'tick',
295
+ beat,
296
+ lane,
297
+ size,
298
+ ease,
299
+ timeScaleGroup,
300
+ };
301
+ object.connections.push(connection);
302
+ break;
303
+ }
304
+ }
305
+ }
306
+ objects.push(object);
307
+ if (object.type == 'guide')
308
+ continue;
309
+ const key = getKey(startNote);
310
+ const dupe = dedupeSlides.get(key);
311
+ if (dupe)
312
+ objects.splice(objects.indexOf(dupe), 1);
313
+ dedupeSlides.set(key, object);
314
+ }
315
+ return {
316
+ offset: score.offset,
317
+ objects,
318
+ };
319
+ };
320
+ const getKey = (note) => `${note.lane}-${note.tick}`;
Binary file
@@ -0,0 +1,3 @@
1
+ import { USC } from './index.js';
2
+ /** Check if it is USC */
3
+ export declare function isUSC(data: unknown): data is USC;
@@ -0,0 +1,7 @@
1
+ /** Check if it is USC */
2
+ export function isUSC(data) {
3
+ return (typeof data === 'object' &&
4
+ data !== null &&
5
+ 'objects' in data &&
6
+ Array.isArray(data.objects));
7
+ }
@@ -0,0 +1,4 @@
1
+ import { type LevelData } from '@sonolus/core';
2
+ import { USC } from './index.js';
3
+ /** Convert a USC to a Level Data */
4
+ export declare const uscToLevelData: (usc: USC, offset?: number, smoothGuideFade?: boolean, useGuideLayer?: boolean) => LevelData;