@osbjs/hitobjects-tiny-osbjs
Version:
Hit objects parser for tiny-osbjs.
322 lines (302 loc) • 14.6 kB
JavaScript
import { readFileSync } from 'fs';
import { addVec, lengthVec, subVec, cloneVec, areEqualVecs, lengthSqrVec, interpolateVec } from '@osbjs/tiny-osbjs';
const PlayfieldToStoryboardOffset = [64, 56];
function parseCircles(rawHitObjs) {
const circles = [];
const match = rawHitObjs.match(/-*\d+,-*\d+,-*\d+,\d+,\d+,\d+:\d+:\d+:\d+:\w*/g);
if (match) {
for (const cL of match) {
const cAttr = cL.split(',');
const playfieldPosition = [parseInt(cAttr[0]), parseInt(cAttr[1])];
circles.push({
position: addVec(playfieldPosition, PlayfieldToStoryboardOffset),
time: parseInt(cAttr[2]),
});
}
}
return circles;
}
function parseSliderMultiplier(raw) {
const sliderMultiplier = parseFloat(raw.match(/(?<=SliderMultiplier:)\d+\.?\d*/)[0]);
return sliderMultiplier;
}
function calculateTravelDuration(beatLength, beatmapMultiplier, currentMultiplier, visualLength) {
return Math.round((beatLength * ((visualLength * currentMultiplier) / beatmapMultiplier)) / 100);
}
function createBezierSegments(points, visualLength) {
const startPoint = points[0], endPoint = points[points.length - 1];
const segments = [];
const segmentCount = visualLength;
let distance = 0;
let prevPosition = startPoint;
for (let i = 1; i <= segmentCount; i++) {
let delta = i / segmentCount;
let nextPosition = getPositionAtDelta$1(delta, points);
distance += lengthVec(subVec(nextPosition, prevPosition));
segments.push({ distance, position: nextPosition });
prevPosition = nextPosition;
}
distance += lengthVec(subVec(endPoint, prevPosition));
segments.push({ distance, position: endPoint });
return segments;
}
// de_casteljau algorithm
function getPositionAtDelta$1(delta, points) {
let intermediatePoints = points.map((point) => cloneVec(point));
for (let i = 1; i < points.length; i++)
for (let j = 0; j < points.length - i; j++) {
intermediatePoints[j] = [
intermediatePoints[j][0] * (1 - delta) + intermediatePoints[j + 1][0] * delta,
intermediatePoints[j][1] * (1 - delta) + intermediatePoints[j + 1][1] * delta,
];
}
return intermediatePoints[0];
}
function createBezierSegmentGroup(points, visualLength) {
const curves = [];
const startPoint = points[0];
let _points = [startPoint];
let prevPoint = startPoint;
points.forEach((currentPoint) => {
if (areEqualVecs(currentPoint, prevPoint)) {
if (_points.length > 1)
curves.push(createBezierSegments(_points, visualLength));
_points = [];
}
_points.push(cloneVec(currentPoint));
prevPoint = currentPoint;
});
if (_points.length > 1)
curves.push(createBezierSegments(_points, visualLength));
return curves;
}
function createCatmulSegments(points, visualLength) {
const startPoint = points[0], endPoint = points[points.length - 1];
const segmentCountEachGroup = visualLength / points.length;
const segments = [];
let distance = 0.0;
let prevPosition = startPoint;
for (let lineIndex = 0; lineIndex < points.length - 1; lineIndex++) {
for (let i = 1; i <= segmentCountEachGroup; i++) {
let delta = i / (segmentCountEachGroup + 1);
const p1 = lineIndex > 0 ? points[lineIndex - 1] : points[lineIndex], p2 = points[lineIndex], p3 = points[lineIndex + 1], p4 = lineIndex < points.length - 2 ? points[lineIndex + 2] : p3;
let nextPosition = getPositionAtDelta(delta, p1, p2, p3, p4);
distance += lengthVec(subVec(nextPosition, prevPosition));
segments.push({ distance, position: nextPosition });
prevPosition = nextPosition;
}
}
distance += lengthVec(subVec(endPoint, prevPosition));
segments.push({ distance, position: endPoint });
return segments;
}
function getPointPositionAtDelta(delta, p1, p2, p3, p4) {
return (0.5 * ((-p1 + 3 * p2 - 3 * p3 + p4) * delta * delta * delta + (2 * p1 - 5 * p2 + 4 * p3 - p4) * delta * delta + (-p1 + p3) * delta + 2 * p2));
}
function getPositionAtDelta(delta, p1, p2, p3, p4) {
return [getPointPositionAtDelta(p1[0], p2[0], p3[0], p4[0], delta), getPointPositionAtDelta(p1[1], p2[1], p3[1], p4[1], delta)];
}
function createCircleSegments(points) {
const startPoint = points[0], midPoint = points[1], endPoint = points[2];
const d = 2 * (startPoint[0] * (midPoint[1] - endPoint[1]) + midPoint[0] * (endPoint[1] - startPoint[1]) + endPoint[0] * (startPoint[1] - midPoint[1]));
const center = [
(lengthSqrVec(startPoint) * (midPoint[1] - endPoint[1]) +
lengthSqrVec(midPoint) * (endPoint[1] - startPoint[1]) +
lengthSqrVec(endPoint) * (startPoint[1] - midPoint[1])) /
d,
(lengthSqrVec(startPoint) * (endPoint[0] - midPoint[0]) +
lengthSqrVec(midPoint) * (startPoint[0] - endPoint[0]) +
lengthSqrVec(endPoint) * (midPoint[0] - startPoint[0])) /
d,
];
const radius = lengthVec(subVec(startPoint, center));
let startAngle = Math.atan2(startPoint[1] - center[1], startPoint[0] - center[0]);
let midAngle = Math.atan2(midPoint[1] - center[1], midPoint[0] - center[0]);
let endAngle = Math.atan2(endPoint[1] - center[1], endPoint[0] - center[0]);
while (midAngle < startAngle)
midAngle += 2 * Math.PI;
while (endAngle < startAngle)
endAngle += 2 * Math.PI;
if (midAngle > endAngle)
endAngle -= 2 * Math.PI;
const length = Math.abs((endAngle - startAngle) * radius);
const segmentCount = Math.floor(length / 8);
const segments = [];
for (let i = 1; i < segmentCount; i++) {
const progress = i / segmentCount;
const angle = endAngle * progress + startAngle * (1 - progress);
const position = [Math.cos(angle) * radius + center[0], Math.sin(angle) * radius + center[1]];
segments.push({ distance: progress * length, position });
}
segments.push({ distance: length, position: endPoint });
return segments;
}
function createLineSegments(points, visualLength) {
const startPoint = points[0], endPoint = points[1];
return [
{ distance: 0, position: startPoint },
{ distance: visualLength, position: endPoint },
];
}
function getPositionAtDistance(distance, segments) {
console.log(distance, segments[segments.length - 1].distance);
if (distance >= segments[segments.length - 1].distance)
return segments[segments.length - 1].position;
if (distance <= segments[0].distance)
return segments[0].position;
const nextSegmentIndex = segments.findIndex((segment) => segment.distance > distance);
const { distance: nextDistance, position: nextPosition } = segments[nextSegmentIndex];
const { distance: prevDistance, position: prevPosition } = segments[nextSegmentIndex - 1];
// linearly interpolate
const alpha = (distance - prevDistance) / (nextDistance - prevDistance);
const positionAtDistance = interpolateVec(prevPosition, nextPosition, alpha);
return positionAtDistance;
}
function getBezierGroupPositionAtDistance(distance, curves) {
let _distance = distance;
for (let i = 0; i < curves.length; i++) {
const segments = curves[i];
if (_distance < segments[segments.length - 1].distance)
return getPositionAtDistance(_distance, segments);
_distance -= segments[segments.length - 1].distance;
}
return getPositionAtDistance(_distance, curves[curves.length - 1]);
}
var CurveType;
(function (CurveType) {
CurveType["Bezier"] = "B";
CurveType["Catmull"] = "C";
CurveType["Linear"] = "L";
CurveType["Perfect"] = "P";
})(CurveType || (CurveType = {}));
function createSlider(startTime, curveType, points, repeatCount, visualLength, beatLength, beatMultiplier, currentMultiplier) {
const travelDuration = calculateTravelDuration(beatLength, beatMultiplier, currentMultiplier, visualLength);
const startPoint = points[0], endPoint = points[points.length - 1];
const endTime = startTime + travelDuration * repeatCount;
const segments = curveType == CurveType.Bezier
? createBezierSegmentGroup(points, visualLength)
: curveType == CurveType.Catmull
? createCatmulSegments(points, visualLength)
: curveType == CurveType.Perfect
? createCircleSegments(points)
: createLineSegments(points, visualLength);
return {
startTime,
endTime,
positionAtTime: (time) => {
if (time <= startTime)
return startPoint;
if (endTime <= time)
return repeatCount % 2 == 0 ? startPoint : endPoint;
let elapsedSinceStart = time - startTime;
let repeatAtTime = 1;
let progressDuration = elapsedSinceStart;
while (progressDuration > travelDuration) {
progressDuration -= travelDuration;
repeatAtTime++;
}
const progress = repeatAtTime % 2 != 0 ? progressDuration / travelDuration : 1 - progressDuration / travelDuration;
if (curveType == CurveType.Bezier) {
return getBezierGroupPositionAtDistance(visualLength * progress, segments);
}
else {
return getPositionAtDistance(visualLength * progress, segments);
}
},
};
}
function parseSliders(rawHitObjs, beatMultiplier, timingPoints) {
const sliders = [];
const match = rawHitObjs.match(/-*\d+,-*\d+,-*\d+,\d+,\d+,(L|B|C|P)(\|-*\d+:-*\d+)+,\d+,\d+\.*\d*(,\d+(\|\d*)*,\d+:\d+(\|\d+:\d+)*,\d+:\d+:\d+:\d+:\w*)*/g);
if (match) {
for (const sL of match) {
const sAttr = sL.split(',');
const startTime = parseInt(sAttr[2]);
const curves = sAttr[5].split('|');
const curveType = curves[0];
const curvePoints = curves
.slice(1)
.map((cp) => addVec([parseInt(cp.split(':')[0]), parseInt(cp.split(':')[1])], PlayfieldToStoryboardOffset));
const points = [addVec([parseInt(sAttr[0]), parseInt(sAttr[1])], PlayfieldToStoryboardOffset), ...curvePoints];
const repeatCount = parseInt(sAttr[6]);
const visualLength = parseInt(sAttr[7]);
const currentTimingPoint = timingPoints
.filter((tP) => tP.startTime <= startTime && tP.uninherited)
.sort((t1, t2) => t2.startTime - t1.startTime)[0];
const currentMultipliers = timingPoints
.filter((tP) => tP.startTime <= startTime && !tP.uninherited && tP.startTime >= currentTimingPoint.startTime)
.sort((t1, t2) => t2.startTime - t1.startTime);
const currentMultiplier = currentMultipliers.length ? -currentMultipliers[0].beatLength / 100 : 1;
sliders.push(createSlider(startTime, curveType, points, repeatCount, visualLength, currentTimingPoint.beatLength, beatMultiplier, currentMultiplier));
}
}
return sliders;
}
function parseTimingPoints(raw) {
let match = raw
.match(/(?<=\[TimingPoints]\r?\n)(.+\r?\n?)+/g)
?.toString()
.match(/-?\d+,-?\d+\.?\d*,\d+,\d+,\d+,\d+,\d+,\d+/g);
const timingPoints = [];
if (match) {
for (const timingPointLine of match) {
const timingPointAttr = timingPointLine.split(',');
timingPoints.push({
startTime: parseInt(timingPointAttr[0]),
beatLength: parseFloat(timingPointAttr[1]),
uninherited: timingPointAttr[6] === '1',
});
}
}
return timingPoints;
}
/**
* Get all beatmap hitobjects.
* @param filepath Full path to osu file.
*/
function loadBeatmapHitobjects(filepath) {
const raw = readFileSync(filepath, 'utf-8');
const rawHitObjs = raw
.match(/(?<=\[HitObjects]\r?\n)(.+\r?\n?)+/g)
?.toString()
.trim();
if (!rawHitObjs)
return { sliders: [], circles: [] };
const timingPoints = parseTimingPoints(raw);
const beatMultiplier = parseSliderMultiplier(raw);
const sliders = parseSliders(rawHitObjs, beatMultiplier, timingPoints);
const circles = parseCircles(rawHitObjs);
return { sliders, circles };
}
/**
* Find the first circle with a specific timestamp.
* @param time Time in millisecond
* @param circles Array of circles
* @param maxAcceptableOffset Accept result in range [time - maxAcceptableOffset, time + maxAcceptableOffset]
*/
function findCircleAtTime(time, circles, maxAcceptableOffset = 5) {
return circles.find((circle) => circle.time <= time + maxAcceptableOffset && circle.time >= time - maxAcceptableOffset);
}
/**
* Find the first slider with a specific timestamp.
* @param time Time in millisecond
* @param sliders Array of sliders
* @param maxAcceptableOffset Accept result in range [time - maxAcceptableOffset, time + maxAcceptableOffset]
*/
function findSliderAtTime(time, sliders, maxAcceptableOffset = 5) {
return sliders.find((slider) => slider.startTime <= time + maxAcceptableOffset && slider.startTime >= time - maxAcceptableOffset);
}
/**
* Returns hitobjects in a specific period.
* @param startTime Start time in millisecond
* @param endTime End time in millisecond
* @param hitobjects Array of hit objects
* @param maxAcceptableOffset Accept result in range [startTime - maxAcceptableOffset, endTime + maxAcceptableOffset]
*/
function filterHitObjectsInPeriod(startTime, endTime, hitobjects, maxAcceptableOffset = 5) {
const { circles, sliders } = hitobjects;
const filteredCircles = circles.filter((circle) => circle.time >= startTime - maxAcceptableOffset && circle.time <= endTime + maxAcceptableOffset);
const filteredSliders = sliders.filter((slider) => slider.startTime >= startTime - maxAcceptableOffset && slider.startTime <= endTime + maxAcceptableOffset);
return { circles: filteredCircles, sliders: filteredSliders };
}
export { filterHitObjectsInPeriod, findCircleAtTime, findSliderAtTime, loadBeatmapHitobjects };