mirror of
https://gitlab.com/skysthelimit.dev/selenite.git
synced 2025-06-16 10:32:08 -05:00
519 lines
25 KiB
JavaScript
519 lines
25 KiB
JavaScript
define(["underscore", "osu-audio", "curves/LinearBezier", "curves/CircumscribedCircle"],
|
|
function (_, OsuAudio, LinearBezier, CircumscribedCircle) {
|
|
var HIT_TYPE_CIRCLE = 1,
|
|
HIT_TYPE_SLIDER = 2,
|
|
HIT_TYPE_NEWCOMBO = 4,
|
|
HIT_TYPE_SPINNER = 8;
|
|
function Track(zip, track) {
|
|
var self = this;
|
|
this.track = track;
|
|
this.zip = zip;
|
|
this.ondecoded = null;
|
|
this.general = {};
|
|
this.metadata = {};
|
|
this.difficulty = {};
|
|
this.colors = [];
|
|
this.events = [];
|
|
this.timingPoints = [];
|
|
this.hitObjects = [];
|
|
this.decode = _.bind(function decode() {
|
|
// Decodes a .osu file
|
|
var lines = self.track.replace("\r", "").split("\n");
|
|
if (lines[0] != "osu file format v14") {
|
|
// TODO: Do we care?
|
|
}
|
|
var section = null;
|
|
var combo = 0,
|
|
index = 0;
|
|
var forceNewCombo = false;
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i].trim();
|
|
if (line === "") continue;
|
|
if (line.indexOf("//") === 0) continue;
|
|
if (line.indexOf("[") === 0) {
|
|
section = line;
|
|
continue;
|
|
}
|
|
switch (section) {
|
|
case "[General]":
|
|
var key = line.substr(0, line.indexOf(":"));
|
|
var value = line.substr(line.indexOf(":") + 1).trim();
|
|
if (isNaN(value)) {
|
|
self.general[key] = value;
|
|
} else {
|
|
self.general[key] = (+value);
|
|
}
|
|
break;
|
|
case "[Metadata]":
|
|
var key = line.substr(0, line.indexOf(":"));
|
|
var value = line.substr(line.indexOf(":") + 1).trim();
|
|
self.metadata[key] = value;
|
|
break;
|
|
case "[Events]":
|
|
self.events.push(line.split(","));
|
|
break;
|
|
case "[Difficulty]":
|
|
var parts = line.split(":");
|
|
var value = parts[1].trim();
|
|
if (isNaN(value)) {
|
|
self.difficulty[parts[0]] = value;
|
|
} else {
|
|
self.difficulty[parts[0]] = (+value);
|
|
}
|
|
break;
|
|
case "[TimingPoints]":
|
|
var parts = line.split(",");
|
|
var t = {
|
|
offset: +parts[0],
|
|
millisecondsPerBeat: +parts[1],
|
|
meter: +parts[2],
|
|
sampleSet: +parts[3],
|
|
sampleIndex: +parts[4],
|
|
volume: +parts[5],
|
|
uninherited: +parts[6],
|
|
kaiMode: +parts[7]
|
|
};
|
|
// fallback to default set if sampleset is illegal
|
|
if (t.sampleSet > 3) t.sampleSet = 0;
|
|
if (t.millisecondsPerBeat < 0) {
|
|
t.uninherited = 0;
|
|
}
|
|
this.timingPoints.push(t);
|
|
break;
|
|
case "[Colours]":
|
|
var parts = line.split(":");
|
|
var key = parts[0].trim();
|
|
var value = parts[1].trim();
|
|
if (key == "SliderTrackOverride")
|
|
self.colors.SliderTrackOverride = value.split(',');
|
|
else if (key == "SliderBorder")
|
|
self.colors.SliderBorder = value.split(',');
|
|
else self.colors.push(value.split(','));
|
|
break;
|
|
case "[HitObjects]":
|
|
var parts = line.split(",");
|
|
var hit = {
|
|
x: +parts[0],
|
|
y: +parts[1],
|
|
time: +parts[2],
|
|
type: +parts[3],
|
|
hitSound: +parts[4]
|
|
};
|
|
// Handle combos
|
|
if ((hit.type & HIT_TYPE_NEWCOMBO) > 0 || forceNewCombo) {
|
|
combo++;
|
|
combo += (hit.type >> 4) & 7; // combo skip
|
|
index = 0;
|
|
}
|
|
forceNewCombo = false;
|
|
hit.combo = combo;
|
|
hit.index = index++;
|
|
|
|
// Decode specific hit object type
|
|
if ((hit.type & HIT_TYPE_CIRCLE) > 0) {
|
|
hit.type = "circle";
|
|
// parse hitSample
|
|
const hitSample = (parts.length > 5 ? parts[5] : '0:0:0:0:').split(":");
|
|
hit.hitSample = {
|
|
normalSet: +hitSample[0],
|
|
additionSet: +hitSample[1],
|
|
index: +hitSample[2],
|
|
volume: +hitSample[3],
|
|
filename: hitSample[4]
|
|
};
|
|
} else if ((hit.type & HIT_TYPE_SLIDER) > 0) {
|
|
hit.type = "slider";
|
|
var sliderKeys = parts[5].split("|");
|
|
hit.sliderType = sliderKeys[0];
|
|
hit.keyframes = [];
|
|
for (var j = 1; j < sliderKeys.length; j++) {
|
|
var p = sliderKeys[j].split(":");
|
|
hit.keyframes.push({
|
|
x: +p[0],
|
|
y: +p[1]
|
|
});
|
|
}
|
|
hit.repeat = +parts[6];
|
|
hit.pixelLength = +parts[7];
|
|
|
|
if (parts.length > 8) {
|
|
hit.edgeHitsounds = parts[8].split("|").map(Number);
|
|
} else {
|
|
hit.edgeHitsounds = new Array();
|
|
for (var wdnmd = 0; wdnmd < hit.repeat + 1; wdnmd++)
|
|
hit.edgeHitsounds.push(0);
|
|
}
|
|
|
|
hit.edgeSets = new Array();
|
|
for (var wdnmd = 0; wdnmd < hit.repeat + 1; wdnmd++)
|
|
hit.edgeSets.push({
|
|
normalSet: 0,
|
|
additionSet: 0
|
|
});
|
|
if (parts.length > 9) {
|
|
var additions = parts[9].split("|");
|
|
for (var wdnmd = 0; wdnmd < additions.length; wdnmd++) {
|
|
var sets = additions[wdnmd].split(":");
|
|
hit.edgeSets[wdnmd].normalSet = +sets[0];
|
|
hit.edgeSets[wdnmd].additionSet = +sets[1]
|
|
}
|
|
}
|
|
// parse hitSample
|
|
const hitSample = (parts.length > 10 ? parts[10] : '0:0:0:0:').split(":");
|
|
hit.hitSample = {
|
|
normalSet: +hitSample[0],
|
|
additionSet: +hitSample[1],
|
|
index: +hitSample[2],
|
|
volume: +hitSample[3],
|
|
filename: hitSample[4]
|
|
};
|
|
} else if ((hit.type & HIT_TYPE_SPINNER) > 0) {
|
|
if (hit.type & HIT_TYPE_NEWCOMBO)
|
|
combo--;
|
|
hit.combo = combo - ((hit.type >> 4) & 7); // force in same combo
|
|
forceNewCombo = true; // force next object in new combo
|
|
hit.type = "spinner";
|
|
hit.endTime = +parts[5];
|
|
if (hit.endTime < hit.time)
|
|
hit.endTime = hit.time + 1;
|
|
// parse hitSample
|
|
const hitSample = (parts.length > 6 ? parts[6] : '0:0:0:0:').split(":");
|
|
hit.hitSample = {
|
|
normalSet: +hitSample[0],
|
|
additionSet: +hitSample[1],
|
|
index: +hitSample[2],
|
|
volume: +hitSample[3],
|
|
filename: hitSample[4]
|
|
};
|
|
} else {
|
|
console.log("Attempted to decode unknown hit object type, get yo catch the fruit playin' ass outta here" + hit.type + ": " + line);
|
|
}
|
|
// fallback to default set if sampleset is illegal
|
|
if (hit.hitSample && hit.hitSample.normalSet > 3)
|
|
hit.hitSample.normalSet = 0;
|
|
if (hit.hitSample && hit.hitSample.additionSet > 3)
|
|
hit.hitSample.additionSet = 0;
|
|
self.hitObjects.push(hit);
|
|
break;
|
|
}
|
|
}
|
|
// Make some corrections
|
|
this.general.PreviewTime /= 10;
|
|
if (this.general.PreviewTime > this.hitObjects[0].time) {
|
|
this.general.PreviewTime = 0;
|
|
}
|
|
|
|
// complete with default values
|
|
if (this.colors.length === 0) {
|
|
this.colors = [
|
|
[96, 159, 159],
|
|
[192, 192, 192],
|
|
[128, 255, 255],
|
|
[139, 191, 222]
|
|
];
|
|
}
|
|
if (this.difficulty.OverallDifficulty) {
|
|
this.difficulty.HPDrainRate = this.difficulty.HPDrainRate || this.difficulty.OverallDifficulty;
|
|
this.difficulty.CircleSize = this.difficulty.CircleSize || this.difficulty.OverallDifficulty;
|
|
this.difficulty.ApproachRate = this.difficulty.ApproachRate || this.difficulty.OverallDifficulty;
|
|
} else {
|
|
console.warn("[preproc]", "Overall Difficulty Undefined");
|
|
}
|
|
|
|
// calculate inherited timing points
|
|
// trueMillisecondsPerBeat represents BPM for song, which affects tick rate
|
|
// millisecondsPerBeat, which affects slider velocity
|
|
var last = this.timingPoints[0];
|
|
for (var i = 0; i < this.timingPoints.length; i++) {
|
|
var point = this.timingPoints[i];
|
|
if (point.uninherited === 0) {
|
|
point.uninherited = 1;
|
|
point.millisecondsPerBeat = Math.min(point.millisecondsPerBeat, -10);
|
|
point.millisecondsPerBeat = Math.max(point.millisecondsPerBeat, -1000);
|
|
point.millisecondsPerBeat *= -0.01 * last.millisecondsPerBeat;
|
|
point.trueMillisecondsPerBeat = last.trueMillisecondsPerBeat;
|
|
} else {
|
|
last = point;
|
|
point.trueMillisecondsPerBeat = point.millisecondsPerBeat;
|
|
}
|
|
}
|
|
preallocateTiming(this);
|
|
// calculate end time of each hit object
|
|
for (let i = 0; i < this.hitObjects.length; i++) {
|
|
let hit = this.hitObjects[i];
|
|
if (hit.type == "circle") hit.endTime = hit.time;
|
|
if (hit.type == "slider") {
|
|
hit.sliderTime = hit.timing.millisecondsPerBeat * (hit.pixelLength / this.difficulty.SliderMultiplier) / 100;
|
|
hit.sliderTimeTotal = hit.sliderTime * hit.repeat;
|
|
hit.endTime = hit.time + hit.sliderTimeTotal;
|
|
}
|
|
// spinners already have an endTime
|
|
}
|
|
// just give an estimated track length
|
|
this.length = Math.round((this.hitObjects[this.hitObjects.length - 1].endTime) / 1000 + 1.5);
|
|
|
|
calculateCurve(this);
|
|
// stack hitobjects
|
|
stackHitObjects(this);
|
|
|
|
// callback
|
|
if (this.ondecoded !== null) {
|
|
this.ondecoded(this);
|
|
}
|
|
}, this);
|
|
}
|
|
|
|
function Osu(zip) {
|
|
var self = this;
|
|
this.zip = zip;
|
|
this.song = null;
|
|
this.ondecoded = null;
|
|
this.onready = null;
|
|
this.tracks = [];
|
|
|
|
var count = 0;
|
|
this.track_decoded = function () {
|
|
count++;
|
|
if (count == self.raw_tracks.length) {
|
|
if (self.ondecoded !== null) {
|
|
self.ondecoded(this);
|
|
}
|
|
}
|
|
};
|
|
|
|
this.load = function load() {
|
|
self.raw_tracks = _.filter(this.zip.children, function (c) {
|
|
return c.name.length >= 4 && c.name.indexOf(".osu") === c.name.length - 4;
|
|
});
|
|
|
|
if (_.isEmpty(self.raw_tracks)) {
|
|
self.onerror("No .osu files found!");
|
|
} else {
|
|
_.each(self.raw_tracks, function (t) {
|
|
console.log("Attempting to load track:", t.name)
|
|
t.getText(function (text) {
|
|
var track = new Track(this.zip, text);
|
|
self.tracks.push(track);
|
|
track.ondecoded = self.track_decoded;
|
|
track.decode();
|
|
})
|
|
});
|
|
}
|
|
};
|
|
|
|
this.getCoverSrc = function (img) {
|
|
let fileentry = null;
|
|
try {
|
|
var file = this.tracks[0].events[0][2];
|
|
if (this.tracks[0].events[0][0] === "Video") {
|
|
file = this.tracks[0].events[1][2];
|
|
}
|
|
file = file.substr(1, file.length - 2);
|
|
fileentry = this.zip.getChildByName(file);
|
|
} catch (error) {
|
|
console.error(error);
|
|
fileentry = null;
|
|
}
|
|
if (fileentry) {
|
|
fileentry.getBlob("image/jpeg", function (blob) {
|
|
img.src = URL.createObjectURL(blob);
|
|
});
|
|
} else {
|
|
img.src = "img/defaultbg.jpg";
|
|
}
|
|
};
|
|
|
|
this.requestStar = function () {
|
|
let xhr = new XMLHttpRequest();
|
|
xhr.open("GET", "https://api.sayobot.cn/beatmapinfo?1=" + this.tracks[0].metadata.BeatmapSetID);
|
|
xhr.responseType = 'text';
|
|
let self = this;
|
|
xhr.onload = function () {
|
|
let info = JSON.parse(xhr.response);
|
|
if (info.status == 0) {
|
|
for (let i = 0; i < info.data.length; ++i) {
|
|
for (let j = 0; j < self.tracks.length; ++j) {
|
|
if (self.tracks[j].metadata.BeatmapID == info.data[i].bid) {
|
|
self.tracks[j].difficulty.star = info.data[i].star;
|
|
self.tracks[j].length = info.data[i].length;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
xhr.send();
|
|
}
|
|
|
|
this.filterTracks = function () {
|
|
self.tracks = self.tracks.filter(function (t) {
|
|
return t.general.Mode == 0;
|
|
});
|
|
}
|
|
this.sortTracks = function () {
|
|
self.tracks.sort(function (a, b) {
|
|
return a.difficulty.OverallDifficulty - b.difficulty.OverallDifficulty;
|
|
});
|
|
}
|
|
|
|
this.load_mp3 = function load_mp3(track) {
|
|
track = track || self.tracks[0];
|
|
var mp3_raw = _.find(self.zip.children, function (c) {
|
|
return c.name.toLowerCase() === track.general.AudioFilename.toLowerCase();
|
|
});
|
|
mp3_raw.getBlob("audio/mpeg", function (blob) {
|
|
var reader = new FileReader();
|
|
reader.onload = function (e) {
|
|
var buffer = e.target.result;
|
|
console.log("Loaded blob");
|
|
self.audio = new OsuAudio(mp3_raw.name.toLowerCase(), buffer, function () {
|
|
if (self.onready) {
|
|
self.onready();
|
|
}
|
|
});
|
|
};
|
|
reader.readAsArrayBuffer(blob);
|
|
});
|
|
}
|
|
};
|
|
|
|
return Osu;
|
|
|
|
function preallocateTiming(track) {
|
|
let currentTimingIndex = 0;
|
|
for (let i = 0; i < track.hitObjects.length; ++i) {
|
|
while (currentTimingIndex + 1 < track.timingPoints.length && track.timingPoints[currentTimingIndex + 1].offset <= track.hitObjects[i].time) {
|
|
currentTimingIndex += 1;
|
|
}
|
|
track.hitObjects[i].timing = track.timingPoints[currentTimingIndex];
|
|
}
|
|
}
|
|
|
|
function calculateCurve(track) {
|
|
for (let i = 0; i < track.hitObjects.length; ++i) {
|
|
let hit = track.hitObjects[i];
|
|
if (hit.type == "slider") {
|
|
if (hit.sliderType === "P" && hit.keyframes.length == 2) {
|
|
// handle straight P slider
|
|
// Vec2f nora = new Vec2f(sliderX[0] - x, sliderY[0] - y).nor();
|
|
// Vec2f norb = new Vec2f(sliderX[0] - sliderX[1], sliderY[0] - sliderY[1]).nor();
|
|
// if (Math.abs(norb.x * nora.y - norb.y * nora.x) < 0.00001)
|
|
// return new LinearBezier(this, false, scaled); // vectors parallel, use linear bezier instead
|
|
// else
|
|
hit.curve = new CircumscribedCircle(hit);
|
|
if (hit.curve.length == 0) // (not sure here) fallback
|
|
hit.curve = new LinearBezier(hit, hit.sliderType === "L");
|
|
} else {
|
|
if (hit.sliderType == "C")
|
|
console.warn("[curve]", track.metadata.BeatmapID || track.metadata.Title + '/' + track.metadata.Version, "Catmull curve unsupported. fallback to bezier");
|
|
hit.curve = new LinearBezier(hit, hit.sliderType === "L");
|
|
}
|
|
if (hit.curve.length < 2) // (not sure here)
|
|
console.error("[curve] slider curve calculation failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function stackHitObjects(track) {
|
|
// stack coinciding objects to make them easier to see.
|
|
// stacked objects form chains (probably not with consecutive index)
|
|
const AR = track.difficulty.ApproachRate;
|
|
const approachTime = AR < 5 ? 1800 - 120 * AR : 1950 - 150 * AR;
|
|
const stackDistance = 3;
|
|
const stackThreshold = approachTime * track.general.StackLeniency;
|
|
|
|
// time interval between hitobject A and hitobject B
|
|
// (it's guaranteed that A and B are not spinners)
|
|
function getintv(A, B) {
|
|
let endTime = A.time;
|
|
if (A.type == "slider") {
|
|
// add slider duration
|
|
endTime += A.repeat * A.timing.millisecondsPerBeat * (A.pixelLength / track.difficulty.SliderMultiplier) / 100;
|
|
}
|
|
return B.time - endTime;
|
|
}
|
|
// distance (in osu! pixels) between hitobject A and hitobject B
|
|
// (it's guaranteed that A and B are not spinners)
|
|
function getdist(A, B) {
|
|
let x = A.x;
|
|
let y = A.y;
|
|
if (A.type == "slider" && (A.repeat % 2 == 1)) {
|
|
x = A.curve.curve[A.curve.curve.length - 1].x;
|
|
y = A.curve.curve[A.curve.curve.length - 1].y;
|
|
}
|
|
return Math.hypot(x - B.x, y - B.y);
|
|
}
|
|
|
|
let chains = new Array(); // array of chains represented by array of index
|
|
let stacked = new Array(track.hitObjects.length); // whether a hitobject has been added to chains
|
|
stacked.fill(false);
|
|
for (let i = 0; i < track.hitObjects.length; ++i) {
|
|
if (stacked[i]) continue;
|
|
let hitI = track.hitObjects[i];
|
|
if (hitI.type == "spinner") continue;
|
|
// start a new chain
|
|
stacked[i] = true;
|
|
let newchain = [hitI];
|
|
// finding chain starting from hitI
|
|
for (let j = i + 1; j < track.hitObjects.length; ++j) {
|
|
let hitJ = track.hitObjects[j];
|
|
if (hitJ.type == "spinner") break;
|
|
if (getintv(newchain[newchain.length - 1], hitJ) > stackThreshold) break;
|
|
// append hitJ to the chain if it's close in space & time
|
|
if (getdist(newchain[newchain.length - 1], hitJ) <= stackDistance) {
|
|
// first check if hitJ is already stacked
|
|
if (stacked[j]) {
|
|
// intersecting with a previous chain.
|
|
// this shouldn't happen in a usual beatmap.
|
|
console.warn("[preproc]", track.metadata.BeatmapID || track.metadata.Title + '/' + track.metadata.Version, "object stacks intersecting", i, j);
|
|
// quit stacking
|
|
break;
|
|
}
|
|
stacked[j] = true;
|
|
newchain.push(hitJ);
|
|
}
|
|
}
|
|
if (newchain.length > 1) { // just ignoring one-element chains
|
|
chains.push(newchain);
|
|
}
|
|
}
|
|
// stack offset
|
|
const stackScale = (1.0 - 0.7 * (track.difficulty.CircleSize - 5) / 5) / 2;
|
|
const scaleX = stackScale * 6.4;
|
|
const scaleY = stackScale * 6.4;
|
|
|
|
function movehit(hit, dep) {
|
|
hit.x += scaleX * dep;
|
|
hit.y += scaleY * dep;
|
|
if (hit.type == "slider") {
|
|
for (let j = 0; j < hit.keyframes.length; ++j) {
|
|
hit.keyframes[j].x += scaleX * dep;
|
|
hit.keyframes[j].y += scaleY * dep;
|
|
}
|
|
for (let j = 0; j < hit.curve.curve.length; ++j) {
|
|
hit.curve.curve[j].x += scaleX * dep;
|
|
hit.curve.curve[j].y += scaleY * dep;
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < chains.length; ++i) {
|
|
if (chains[i][0].type == "slider") {
|
|
// fix this slider and move objects below
|
|
for (let j = 0, dep = 0; j < chains[i].length; ++j) {
|
|
movehit(chains[i][j], dep);
|
|
if (chains[i][j].type != "slider" || chains[i][j].repeat % 2 == 0)
|
|
dep++;
|
|
}
|
|
} else {
|
|
// fix object at bottom
|
|
for (let j = 0, dep = 0; j < chains[i].length; ++j) {
|
|
let cur = chains[i].length - 1 - j;
|
|
if (j > 0 && (chains[i][cur].type == "slider" && chains[i][cur].repeat % 2 == 1))
|
|
dep--;
|
|
movehit(chains[i][cur], -dep);
|
|
dep++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}); |