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/src/parser.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Parse raw .osu file text into a structured beatmap object.
3
+ *
4
+ * @param {string} text - Raw contents of a .osu file.
5
+ * @returns {object|null} Parsed beatmap sections, or null on failure.
6
+ */
7
+ export function parseOsuText(text) {
8
+ try {
9
+ const lines = text.split(/\r?\n/);
10
+ const result = {};
11
+ let currentSection = "";
12
+
13
+ let comboIndex = 0;
14
+ let comboColorIndex = 0;
15
+
16
+ lines.forEach((line) => {
17
+ if (line.startsWith("[") && line.endsWith("]")) {
18
+ currentSection = line.slice(1, -1);
19
+ result[currentSection] = currentSection === "Colours" ? [] : {};
20
+ } else {
21
+ if (
22
+ currentSection === "TimingPoints" ||
23
+ currentSection === "HitObjects"
24
+ ) {
25
+ if (currentSection === "TimingPoints") {
26
+ const timingPoint = line.split(",");
27
+ const offset = timingPoint[0];
28
+ const beatLength = timingPoint[1];
29
+ const meter = timingPoint[2];
30
+ const sampleset = parseInt(timingPoint[3]);
31
+ const sampleIndex = timingPoint[4];
32
+ const volume = timingPoint[5];
33
+ const inherited = timingPoint[6];
34
+ const kiai = timingPoint[7];
35
+
36
+ if (!offset || !beatLength) {
37
+ return;
38
+ }
39
+
40
+ if (!result[currentSection][offset]) {
41
+ result[currentSection][offset] = [];
42
+ }
43
+
44
+ if (!result[currentSection][offset][inherited]) {
45
+ result[currentSection][offset][inherited] = [];
46
+ }
47
+
48
+ result[currentSection][offset][inherited].push(beatLength);
49
+ result[currentSection][offset][inherited].push(sampleset);
50
+ } else {
51
+ const hitObject = line.split(",");
52
+ const x = hitObject[0];
53
+ const y = hitObject[1];
54
+ const time = hitObject[2];
55
+ const type = parseHitObjectType(hitObject[3]);
56
+ const hitSound = hitObject[4];
57
+ const extras = type.includes("Circle")
58
+ ? hitObject[5]
59
+ : hitObject.slice(5);
60
+
61
+ if (type.includes("New Combo")) {
62
+ comboIndex = 1;
63
+ comboColorIndex++;
64
+ }
65
+
66
+ const combo = comboIndex;
67
+ const comboColor = comboColorIndex;
68
+
69
+ var sliderInfo;
70
+
71
+ if (type.includes("Slider")) {
72
+ sliderInfo = parseSliderInfo(hitObject.slice(5));
73
+ sliderInfo.anchorPositions.unshift({
74
+ x: Number(x),
75
+ y: Number(y),
76
+ });
77
+ }
78
+
79
+ result[currentSection][time] = {
80
+ x,
81
+ y,
82
+ time,
83
+ type,
84
+ hitSound,
85
+ extras,
86
+ };
87
+
88
+ result[currentSection][time].combo = combo;
89
+ result[currentSection][time].comboColor = comboColor;
90
+
91
+ result[currentSection][time].hitsound = {};
92
+
93
+ comboIndex++;
94
+
95
+ if (sliderInfo) {
96
+ result[currentSection][time].sliderInfo = sliderInfo;
97
+ }
98
+ }
99
+ } else {
100
+ if (!currentSection || !result[currentSection]) {
101
+ return;
102
+ }
103
+ const [key, value] = line.split(":").map((part) => part.trim());
104
+ if (key && value) {
105
+ if (value.includes(",")) {
106
+ result[currentSection][key] = value
107
+ .split(",")
108
+ .map((v) => v.trim());
109
+ } else {
110
+ result[currentSection][key] = value;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ });
116
+
117
+ if (!result["Difficulty"]["ApproachRate"]) {
118
+ result["Difficulty"]["ApproachRate"] =
119
+ result["Difficulty"]["OverallDifficulty"];
120
+ }
121
+
122
+ return result;
123
+ } catch (error) {
124
+ console.error("Error parsing .osu text:", error);
125
+ return null;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Fetch a .osu file from a URL and parse it.
131
+ *
132
+ * @param {string} url - URL to fetch the .osu file from.
133
+ * @returns {Promise<object|null>} Parsed beatmap, or null on failure.
134
+ */
135
+ export async function fetchAndParseOsu(url) {
136
+ try {
137
+ const response = await fetch(url);
138
+ if (!response.ok) {
139
+ throw new Error(`HTTP error! status: ${response.status}`);
140
+ }
141
+ const text = await response.text();
142
+ return parseOsuText(text);
143
+ } catch (error) {
144
+ console.error("Error downloading or parsing the file:", error);
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * @deprecated Use {@link fetchAndParseOsu} instead.
151
+ */
152
+ export const downloadAndParseTextFile = fetchAndParseOsu;
153
+
154
+ function parseHitObjectType(type) {
155
+ const hitObjectTypes = {
156
+ 0: "Circle",
157
+ 1: "Slider",
158
+ 2: "New Combo",
159
+ 3: "Spinner",
160
+ };
161
+
162
+ let parsedTypes = [];
163
+ let typeValue = parseInt(type, 10);
164
+
165
+ if (typeValue & 1) parsedTypes.push(hitObjectTypes[0]);
166
+ if (typeValue & 2) parsedTypes.push(hitObjectTypes[1]);
167
+ if (typeValue & 4) parsedTypes.push(hitObjectTypes[2]);
168
+ if (typeValue & 8) parsedTypes.push(hitObjectTypes[3]);
169
+
170
+ return parsedTypes.length > 0 ? parsedTypes : "Unknown";
171
+ }
172
+
173
+ function parseSliderInfo(extras) {
174
+ const sliderInfo = extras[0];
175
+ const parts = sliderInfo.split("|");
176
+ const sliderType = parts[0];
177
+ const anchorPositions = parts.slice(1).map((pos) => {
178
+ const [x, y] = pos.split(":").map(Number);
179
+ return { x, y };
180
+ });
181
+
182
+ let deepCopyAnchorPositions = JSON.parse(
183
+ JSON.stringify(anchorPositions[anchorPositions.length - 1]),
184
+ );
185
+
186
+ anchorPositions.push(deepCopyAnchorPositions);
187
+
188
+ const sliderRepeat = extras[1];
189
+ const sliderLength = extras[2];
190
+ const edgeSounds = extras[3];
191
+ const edgeSets = extras[4];
192
+
193
+ return {
194
+ sliderType,
195
+ sliderRepeat,
196
+ sliderLength,
197
+ anchorPositions,
198
+ edgeSounds,
199
+ edgeSets,
200
+ };
201
+ }
package/src/utils.js ADDED
@@ -0,0 +1,259 @@
1
+ import { PathPoint } from "./PathPoint.js";
2
+ import { Bezier } from "./Bezier.js";
3
+
4
+ export function getTimingPointAt(givenTime, beatmap) {
5
+ const timingPoints = beatmap["TimingPoints"];
6
+ let closestBPM = null;
7
+ let closestSV = null;
8
+ let sampleSet = null;
9
+
10
+ Object.entries(timingPoints).forEach(([time, beatLengths]) => {
11
+ const timeInt = parseInt(time);
12
+
13
+ beatLengths.forEach((beatLength) => {
14
+ const beatLengthValue = parseFloat(beatLength[0]);
15
+ if (!sampleSet) sampleSet = parseInt(beatLength[1]);
16
+
17
+ if (timeInt <= givenTime) {
18
+ if (beatLengthValue > 0) {
19
+ const bpm = 60000 / beatLengthValue;
20
+ if (!closestBPM || timeInt > closestBPM.time) {
21
+ closestBPM = { time: timeInt, bpm, beatLengthValue };
22
+ }
23
+ } else {
24
+ if (!closestSV || timeInt > closestSV.time) {
25
+ closestSV = { time: timeInt, sv: beatLengthValue };
26
+ sampleSet = parseInt(beatLength[1]);
27
+ }
28
+ }
29
+ }
30
+ });
31
+ });
32
+
33
+ return { closestBPM, closestSV, sampleSet };
34
+ }
35
+
36
+ export function getFollowPosition(hitObject, hitTime, currentTime, grid_unit) {
37
+ const rawProgress =
38
+ ((currentTime - hitTime) / hitObject.sliderDuration) *
39
+ hitObject.sliderRepeat;
40
+ let sliderInfo = hitObject.sliderInfo;
41
+
42
+ if (
43
+ !sliderInfo ||
44
+ !sliderInfo.anchorPositions ||
45
+ sliderInfo.anchorPositions.length === 0
46
+ ) {
47
+ return {
48
+ x:
49
+ sliderInfo?.anchorPositions?.[0]?.x ||
50
+ hitObject.hitCircleSprite?.x ||
51
+ 0,
52
+ y:
53
+ sliderInfo?.anchorPositions?.[0]?.y ||
54
+ hitObject.hitCircleSprite?.y ||
55
+ 0,
56
+ };
57
+ }
58
+
59
+ const maxProgress = Number(hitObject.sliderRepeat) || 0;
60
+ const clampedProgress = Math.max(0, Math.min(maxProgress, rawProgress));
61
+ let wrappedProgress = clampedProgress % 2;
62
+ if (wrappedProgress < 0) {
63
+ wrappedProgress += 2;
64
+ }
65
+ const sliderProgress =
66
+ wrappedProgress > 1 ? 2 - wrappedProgress : wrappedProgress;
67
+
68
+ let anchorPositions = sliderInfo.anchorPositions;
69
+ const targetLength = sliderProgress * (hitObject.sliderLength * grid_unit);
70
+ const startPos = anchorPositions[0];
71
+ const endPos =
72
+ sliderInfo.sliderEndPos ||
73
+ anchorPositions[anchorPositions.length - 1] ||
74
+ startPos;
75
+ const EDGE_EPSILON = 1e-4;
76
+
77
+ if (sliderProgress <= EDGE_EPSILON) {
78
+ return { x: startPos.x, y: startPos.y };
79
+ }
80
+ if (sliderProgress >= 1 - EDGE_EPSILON) {
81
+ return { x: endPos.x, y: endPos.y };
82
+ }
83
+
84
+ let position = {
85
+ x: startPos.x,
86
+ y: startPos.y,
87
+ };
88
+ let accumulatedLength = 0;
89
+
90
+ switch (sliderInfo.sliderType) {
91
+ case "P": {
92
+ try {
93
+ const circleCenter = getCircleCenter(
94
+ anchorPositions[0],
95
+ anchorPositions[1],
96
+ anchorPositions[2],
97
+ );
98
+ const radius = Math.sqrt(
99
+ Math.pow(circleCenter.x - anchorPositions[0].x, 2) +
100
+ Math.pow(circleCenter.y - anchorPositions[0].y, 2),
101
+ );
102
+
103
+ let yDeltaA = anchorPositions[1].y - anchorPositions[0].y;
104
+ let xDeltaA = anchorPositions[1].x - anchorPositions[0].x;
105
+ let yDeltaB = anchorPositions[2].y - anchorPositions[1].y;
106
+ let xDeltaB = anchorPositions[2].x - anchorPositions[1].x;
107
+
108
+ const angleA = Math.atan2(
109
+ anchorPositions[0].y - circleCenter.y,
110
+ anchorPositions[0].x - circleCenter.x,
111
+ );
112
+ const angleC = Math.atan2(
113
+ anchorPositions[2].y - circleCenter.y,
114
+ anchorPositions[2].x - circleCenter.x,
115
+ );
116
+
117
+ const anticlockwise = xDeltaB * yDeltaA - xDeltaA * yDeltaB > 0;
118
+ const startAngle = angleA;
119
+ let endAngle = angleC;
120
+
121
+ if (!anticlockwise && endAngle - startAngle < 0) {
122
+ endAngle += 2 * Math.PI;
123
+ }
124
+ if (anticlockwise && endAngle - startAngle > 0) {
125
+ endAngle -= 2 * Math.PI;
126
+ }
127
+
128
+ let angleStep = (endAngle - startAngle) / 100;
129
+
130
+ let prevX = anchorPositions[0].x;
131
+ let prevY = anchorPositions[0].y;
132
+ let totalLength = 0;
133
+
134
+ for (let i = 0; i <= 100; i++) {
135
+ const currentAngle = startAngle + angleStep * i;
136
+ const x = circleCenter.x + radius * Math.cos(currentAngle);
137
+ const y = circleCenter.y + radius * Math.sin(currentAngle);
138
+
139
+ if (i > 0) {
140
+ totalLength += Math.sqrt(
141
+ Math.pow(Math.abs(x - prevX), 2) +
142
+ Math.pow(Math.abs(y - prevY), 2),
143
+ );
144
+ if (totalLength >= targetLength) {
145
+ position = { x, y };
146
+ return position;
147
+ }
148
+ }
149
+
150
+ prevX = x;
151
+ prevY = y;
152
+ }
153
+
154
+ position = { x: prevX, y: prevY };
155
+ } catch (e) {
156
+ console.error("Error calculating perfect curve position", e);
157
+ }
158
+ break;
159
+ }
160
+ default: {
161
+ try {
162
+ let arrPn = [];
163
+ let bezier = new Bezier();
164
+ arrPn.push(new PathPoint(anchorPositions[0].x, anchorPositions[0].y));
165
+
166
+ for (let i = 1; i < anchorPositions.length; i++) {
167
+ let prevPos = anchorPositions[i - 1];
168
+ let currPos = anchorPositions[i];
169
+ if (
170
+ i === anchorPositions.length - 1 ||
171
+ PathPoint.compare(prevPos, currPos)
172
+ ) {
173
+ if (i === anchorPositions.length - 1) {
174
+ let p = new PathPoint();
175
+ p.x = anchorPositions[i].x;
176
+ p.y = anchorPositions[i].y;
177
+ arrPn.push(p);
178
+ }
179
+ bezier.setBezierN(arrPn);
180
+ const segmentLength = bezier.arcLength;
181
+ if (accumulatedLength + segmentLength >= targetLength) {
182
+ const segmentTargetLength = targetLength - accumulatedLength;
183
+ const mu = bezier.getMuForArcLength(segmentTargetLength);
184
+ bezier.setMu(mu);
185
+ bezier.bezierCalc();
186
+ const result = bezier.getResult();
187
+ if (result && result.x !== undefined && result.y !== undefined) {
188
+ position = result;
189
+ break;
190
+ }
191
+ }
192
+ accumulatedLength += segmentLength;
193
+ arrPn = [];
194
+ bezier = new Bezier();
195
+ if (i < anchorPositions.length - 1) {
196
+ let p = new PathPoint();
197
+ p.x = prevPos.x;
198
+ p.y = prevPos.y;
199
+ arrPn.push(p);
200
+ }
201
+ } else {
202
+ let p = new PathPoint();
203
+ p.x = anchorPositions[i].x;
204
+ p.y = anchorPositions[i].y;
205
+ arrPn.push(p);
206
+ }
207
+ }
208
+ } catch (e) {
209
+ console.error("Error calculating bezier curve position", e);
210
+ }
211
+ if (
212
+ !Number.isFinite(position.x) ||
213
+ !Number.isFinite(position.y) ||
214
+ (position.x === startPos.x &&
215
+ position.y === startPos.y &&
216
+ sliderProgress > 0.5)
217
+ ) {
218
+ position =
219
+ sliderProgress > 0.5
220
+ ? { x: endPos.x, y: endPos.y }
221
+ : { x: startPos.x, y: startPos.y };
222
+ }
223
+ break;
224
+ }
225
+ }
226
+
227
+ return position;
228
+ }
229
+
230
+ export function getCircleCenter(p1, p2, p3) {
231
+ const mid1 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
232
+ const mid2 = { x: (p2.x + p3.x) / 2, y: (p2.y + p3.y) / 2 };
233
+
234
+ const slope1 = (p2.y - p1.y) / (p2.x - p1.x);
235
+ const slope2 = (p3.y - p2.y) / (p3.x - p2.x);
236
+
237
+ let perpSlope1 = -1 / slope1;
238
+ let perpSlope2 = -1 / slope2;
239
+
240
+ if (p1.x === p2.x) {
241
+ perpSlope1 = 0;
242
+ }
243
+ if (p2.x === p3.x) {
244
+ perpSlope2 = 0;
245
+ }
246
+ if (p1.y === p2.y) {
247
+ perpSlope1 = -1000;
248
+ }
249
+ if (p2.y === p3.y) {
250
+ perpSlope2 = -1000;
251
+ }
252
+
253
+ const centerX =
254
+ (perpSlope1 * mid1.x - perpSlope2 * mid2.x + mid2.y - mid1.y) /
255
+ (perpSlope1 - perpSlope2);
256
+ const centerY = perpSlope1 * (centerX - mid1.x) + mid1.y;
257
+
258
+ return { x: centerX, y: centerY };
259
+ }