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