frontend/osu/js/osu.js
2023-05-22 17:12:06 -04:00

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++;
}
}
}
}
});