您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Parse osu beatmap text
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/441171/1025786/BeatmapParser.js
//#region Enums const FileSection = { UNKNOWN : 0, GENERAL : 1 << 0, COLOURS : 1 << 1, EDITOR : 1 << 2, METADATA : 1 << 3, TIMINGPOINTS : 1 << 4, EVENTS : 1 << 5, HITOBJECTS : 1 << 6, DIFFICULTY : 1 << 7, VARIABLES : 1 << 8, } const PlayModes = { OSU : 0, TAIKO : 1, FRUITS : 2, MANIA : 3, } const HitObjectType = { NORMAL : 1, SLIDER : 2, NEWCOMBO : 4, SPINNER : 8, COLOURHAX : 112, HOLD : 128, } const Mod = { NM : 0, NF : 1 << 0, EZ : 1 << 1, TD : 1 << 2, HD : 1 << 3, HR : 1 << 4, SD : 1 << 5, DT : 1 << 6, RX : 1 << 7, HT : 1 << 8, NC : 1 << 9, FL : 1 << 10, AT : 1 << 11, SO : 1 << 12, AP : 1 << 13, PF : 1 << 14, K4 : 1 << 15, K5 : 1 << 16, K6 : 1 << 17, K7 : 1 << 18, K8 : 1 << 19, FI : 1 << 20, RD : 1 << 21, CN : 1 << 22, TP : 1 << 23, K9 : 1 << 24, CO : 1 << 25, K1 : 1 << 26, K3 : 1 << 27, K2 : 1 << 28, V2 : 1 << 29, MR : 1 << 30, } //#endregion function clamp(value, min, max) { if (value > max) return max; if (value < min) return min; return value; } function applyModsToDiff(diff, mods) { if (Mod.EZ & mods) diff = Math.max(0, diff / 2); if (Mod.HR & mods) diff = Math.min(10, diff * 1.4); return diff; } function removeModsFromTime(time, mods) { if (Mod.DT & mods) return time * 1.5; else if (Mod.HT & mods) return time * 0.75; return time; } function applyModsToTime(time, mods) { if (Mod.DT & mods) return time / 1.5; else if (Mod.HT & mods) return time / 0.75; return time; } function diffRange(diff, min, mid, max, mods) { diff = applyModsToDiff(diff, mods); if (diff > 5) return mid + (max - mid) * (diff - 5) / 5; if (diff < 5) return mid - (mid - min) * (5 - diff) / 5; return mid; } function modsMultiplier(mods) { let multiplier = 1.0; if (Mod.NF & mods) multiplier *= 0.5; if (Mod.EZ & mods) multiplier *= 0.5; if (Mod.HT & mods) multiplier *= 0.3; if (Mod.HD & mods) multiplier *= 1.06; if (Mod.HR & mods) multiplier *= 1.06; if (Mod.DT & mods) multiplier *= 1.12; if (Mod.FL & mods) multiplier *= 1.12; if (Mod.SO & mods) multiplier *= 0.9; if ((Mod.RX & mods) || (Mod.AP & mods)) multiplier *= 0; return multiplier; } class Vector2 { constructor(x, y) { this.x = x; this.y = y; } } class HitObject { constructor(pos, startTime, endTime) { this.pos = pos; this.startTime = startTime; this.endTime = endTime; } } class HitCircle extends HitObject { constructor(pos, startTime, endTime) { super(pos, startTime, endTime); this.objType = HitObjectType.NORMAL; } } class Slider extends HitObject { constructor(pos, startTime, endTime, repeatCount, length) { super(pos, startTime, endTime); this.repeatCount = repeatCount; this.pixelLength = length; this.ticks = 0; this.extraScore = false; this.objType = HitObjectType.SLIDER; } } class Spinner extends HitObject { constructor(pos, startTime, endTime) { super(pos, startTime, endTime); this.length = endTime - startTime; this.bonusPoints = 0; this.objType = HitObjectType.SPINNER } } class TimingPoint { constructor(offset, beatLength, timingChange) { this.offset = offset; this.beatLength = beatLength; this.timingChange = timingChange; } _bpmMultiplier() { if (this.beatLength >= 0) return 1; return clamp(-this.beatLength, 10, 1000) / 100.0; } } class BeatmapBase { //#region General mode = PlayModes.OSU; //#endregion //#region Metadata title = ""; titleUnicode; artist = ""; artistUnicode; creator = ""; version = ""; source = ""; tags = []; beatmapId = 0; beatmapsetId = -1; //#endregion //#region Difficulty hp = 5.0; cs = 5.0; od = 5.0; ar = 5.0; sliderMultiplier = 1.4; sliderTickRate = 1.0; sliderScoringPointDistance; //#endregion //#region HitObjects countCircles = 0; countSliders = 0; countSpinners = 0; hitObjects = [] //#endregion //#region Others beatmapVersion = 14; drainLength = 0; totalLength = 0; timingPoints = []; maxCombo; maxScore; //#endregion } class Beatmap extends BeatmapBase { constructor(beatmapString, mods = 0) { super(); this.parseData(beatmapString, mods); } /** * Parse beatmap file * @param {string} filename - Path of .osu file * @param {number} mods - Integer value of the mods, defaults to `0` (NoMod) */ parseFile(filename, mods = 0) { const data = fs.readFileSync(filename, "utf8"); const lines = data.split(/\r?\n/); this._processHeaders(lines); this._parse(lines); this._parseObjects(mods); } /** * Parse beatmap data * @param {string} [data] String data of .osu file * @param {number} [mods=0] Integer value of the mods, defaults to `0` (NoMod) */ parseData(data, mods = 0) { const lines = data.split(/\r?\n/); this._processHeaders(lines); this._parse(lines); this._parseObjects(mods); } _processHeaders(lines) { let arIsOd = true; let currentSection = FileSection.UNKNOWN; let firstTime = -1; let lastTime = -1; let realLastTime = -1; let lastTimeStr = ""; let realLastTimeStr = ""; let breakTime = 0; try { try { let line = lines[0]; if (line.indexOf("osu file format") == 0) { this.beatmapVersion = parseInt(line.substring(line.lastIndexOf("v") + 1)); } } catch (e) { console.log(`Missing file format for ${this.filename}`); } for (let i = 1; i < lines.length; i++) { let line = lines[i].trim(); let left, right = ""; if (line.length == 0 || line.startsWith("//")) continue; if (currentSection != FileSection.HITOBJECTS) { let kv = line.split(":", 2); if (kv.length > 1) { left = kv[0].trim(); right = kv[1].trim(); } else if (line.charAt(0) == '[') { try { currentSection = FileSection[line.replace(/^\[+|\]+$/g, '').toUpperCase()] } catch { } continue; } } switch (currentSection) { case FileSection.GENERAL: if (left == "Mode") this.mode = parseInt(right); break; case FileSection.METADATA: switch (left) { case "Artist": this.artist = right break; case "ArtistUnicode": this.artistUnicode = right break; case "Title": this.title = right break; case "TitleUnicode": this.titleUnicode = right break; case "Creator": this.creator = right break; case "Version": this.version = right break; case "Tags": this.tags = right break; case "Source": this.source = right break; case "BeatmapID": this.beatmapId = parseInt(right) break; case "BeatmapSetID": this.beatmapsetId = parseInt(right) break; } break; case FileSection.DIFFICULTY: switch (left) { case "HPDrainRate": this.hp = Math.min(10, Math.max(0, parseFloat(right))); break; case "CircleSize": if (this.mode == PlayModes.MANIA) this.cs = Math.min(18, Math.max(1, parseFloat(right))); else this.cs = Math.min(10, Math.max(0, parseFloat(right))); break; case "OverallDifficulty": this.od = Math.min(10, Math.max(0, parseFloat(right))); if (arIsOd) this.ar = this.od; break; case "SliderMultiplier": this.sliderMultiplier = Math.max(0.4, Math.min(3.6, parseFloat(right))); break; case "SliderTickRate": this.sliderTickRate = Math.max(0.5, Math.min(8, parseFloat(right))); break; case "ApproachRate": this.ar = Math.min(10, Math.max(0, parseFloat(right))); arIsOd = false; break; } break; case FileSection.EVENTS: if (line.charAt(0) == '2') { let split = line.split(","); breakTime += parseInt(split[2]) - parseInt(split[1]); } break; case FileSection.TIMINGPOINTS: try { let split = line.split(","); if (split.length < 2) continue; let offset = parseFloat(split[0].trim()); let beatLength = parseFloat(split[1].trim()); let timingChange = true; if (split.length > 6) timingChange = (split[6].charAt(0) == '1'); let tp = new TimingPoint(offset, beatLength, timingChange); this.timingPoints.push(tp); } catch (e) { console.log(`Error parsing timing points for ${this.filename}\n${e}`); } break; case FileSection.HITOBJECTS: let split = line.split(",", 7); if (firstTime == -1) firstTime = parseInt(split[2]); let objType = parseInt(split[3]) & 139; switch (objType) { case HitObjectType.NORMAL: this.countCircles++; lastTimeStr = split[2]; realLastTimeStr = lastTimeStr; break; case HitObjectType.SLIDER: this.countSliders++; lastTimeStr = split[2]; realLastTimeStr = lastTimeStr; break; case HitObjectType.SPINNER: this.countSpinners++; lastTimeStr = split[2]; realLastTimeStr = split[5]; break; case HitObjectType.HOLD: this.countSliders++; lastTimeStr = split[5].split(":")[0]; realLastTimeStr = lastTimeStr; break; } break; } } } catch (e) { console.log(`An error occured while processing ${this.filename}\n${e}`); } if (lastTimeStr.length > 0) lastTime = parseInt(lastTimeStr); if (realLastTimeStr.length > 0) realLastTime = parseInt(realLastTimeStr); this.drainLength = Math.trunc((lastTime - firstTime - breakTime) / 1000); this.totalLength = realLastTime; this.sliderScoringPointDistance = (100 * this.sliderMultiplier / this.sliderTickRate); } _parse(lines) { let currentSection = FileSection.UNKNOWN; for (let i = 0; i < lines.length; i++) { let line = lines[i]; if (line.length == 0 || line.startsWith(" ") || line.startsWith("_") || line.startsWith("//")) continue; if (line.charAt(0) == '[') { try { currentSection = FileSection[line.replace(/^\[+|\]+$/g, '').toUpperCase()] } catch { } continue; } if (currentSection == FileSection.HITOBJECTS) { let split = line.split(","); let objType = parseInt(split[3]) & 139; let x = Math.max(0, Math.min(512, parseInt(split[0]))); let y = Math.max(0, Math.min(512, parseInt(split[1]))); let pos = new Vector2(x, y); let time = parseInt(split[2]); let ho = null; switch (objType) { case HitObjectType.NORMAL: ho = new HitCircle(pos, time, time); break; case HitObjectType.SLIDER: let length = 0; let repeatCount = parseInt(split[6]); if (split.length > 7) length = parseFloat(split[7]); ho = new Slider(pos, time, time, Math.max(1, repeatCount), length); break; case HitObjectType.SPINNER: let end_time = parseInt(split[5]); ho = new Spinner(pos, time, end_time); break; } if (ho != null) this.hitObjects.push(ho); } } } _parseObjects(mods) { this.maxCombo = 0; this.maxScore = 0; let scoreMult = this._diffPpyStars() * modsMultiplier(mods); for (let i = 0; i < this.hitObjects.length; i++) { let ho = this.hitObjects[i]; switch (ho.objType) { case HitObjectType.NORMAL: this.maxScore += 300; this.maxScore += Math.trunc(Math.max(0, this.maxCombo - 1) * (300 * scoreMult) / 25); this.maxCombo++; break; case HitObjectType.SLIDER: this.maxScore += 30; if (!this.parsed) this._parseSlider(ho); this.maxScore += 10 * ho.ticks + 20 * ho.repeatCount; if (ho.extraScore) this.maxScore += 20; this.maxCombo += 1 + ho.ticks; this.maxScore += 300 this.maxScore += Math.trunc(Math.max(0, this.maxCombo - 1) * (300 * scoreMult) / 25); break; case HitObjectType.SPINNER: this._parseSpinner(ho, mods); this.maxScore += ho.bonusPoints; this.maxScore += 300; this.maxScore += Math.trunc(Math.max(0, this.maxCombo - 1) * (300 * scoreMult) / 25); this.maxCombo++; break; } } this.maxScore = Math.min(this.maxScore, 2147483647); this.parsed = true; this.mods = mods; } _parseSpinner(ho, mods) { ho.bonusPoints = 0; let rotRatio = diffRange(this.od, 3, 5, 7.5, mods); let rotReq = Math.trunc(ho.length / 1000 * rotRatio); let length = ho.length; let firstFrame = Math.floor(removeModsFromTime(1000 / 60, mods)); let maxAccel = applyModsToTime(0.00008 + Math.max(0, (5000 - length) / 1000 / 2000), mods); if (!(Mod.SO & mods)) length = Math.max(0, length - firstFrame); let rot1 = 0.0; if (0.05 / maxAccel <= length) rot1 = (0.05 / maxAccel * 0.05 / 2) / Math.PI; else rot1 = (length * 0.05 / 2) / Math.PI; let rot2 = (Math.max(0, (length - 0.05 / maxAccel)) * 0.05) / Math.PI; let adj = 0.0; // We want to do riemann sum (with 32-bit floats), but looping through every ms of the spinner is rather inefficient // Instead we take the integral/area (`rot1` + `rot2`) and add a small adjustment // https://www.desmos.com/calculator/q2fmcg2wqy // Using step-wise functions // DT: https://www.desmos.com/calculator/c4fj2mbx9k if (ho.length < 25) adj = 0.0; else if (ho.length < 54) adj = -0.000270059419975 * Math.pow(ho.length, 2) + 0.0211619792196 * ho.length - 0.360204188548; else if (ho.length < 550) adj = 7.08877768273e-8 * ho.length - 0.00792123896377; else if (ho.length < 1039) adj = -3.87996955927e-7 * ho.length - 0.00766882330492; else if (ho.length < 4300) adj = 5.56455532781e-7 * ho.length - 0.00864999032506; else if (ho.length < 5003) adj = -1.52204906849e-157 * Math.pow(ho.length, 41.3873070645) + 1.55461382298e-8 * Math.pow(ho.length, 1.36603917014) - 0.00768603737329; else if (ho.length < 16579) adj = 0.000000576271509962 * ho.length - 0.00900373898631; else if (ho.length < 64789) adj = -0.0000146814720605 * ho.length + 0.243958571556; else if (ho.length < 258373) adj = 0.0000463528165568 * ho.length - 3.71039008873; else if (ho.length < 512573) adj = -0.00019778694081 * ho.length + 59.3687754661; else adj = 0.00029049430919 * ho.length - 190.91100969; let rot = Math.trunc(Math.max(0, rot1 + rot2 - adj)); for (let i = 1; i <= rot; i++) { if (i > rotReq + 3 && (i - (rotReq + 3)) % 2 == 0) ho.bonusPoints += 1100; else if (i > 1 && i % 2 == 0) ho.bonusPoints += 100; } } _parseSlider(ho) { let velocity = this._sliderVecityAt(ho.startTime); let beatLength = this._beatLengthAt(ho.startTime); let tickDist; if (this.beatmapVersion < 8) tickDist = this.sliderScoringPointDistance; else tickDist = this.sliderScoringPointDistance / this._bpmMultAt(ho.startTime); let minTickDist = 0.01 * velocity; let scoringDist = ho.pixelLength; while (scoringDist >= tickDist) { scoringDist -= tickDist; if (scoringDist <= minTickDist) break; ho.ticks += 1 } let duration = Math.trunc(ho.pixelLength / (100 * this.sliderMultiplier) * beatLength); if (ho.ticks > 0) { let tickDuration = Math.trunc(ho.ticks * tickDist / (100 * this.sliderMultiplier) * beatLength); if (tickDuration >= duration - 36 && ho.repeatCount % 2) ho.extraScore = true; } ho.ticks++; ho.ticks *= ho.repeatCount; ho.endTime = ho.startTime + duration * ho.repeatCount; } _sliderVecityAt(time) { let beatLength = this._beatLengthAt(time); if (beatLength > 0) return this.sliderScoringPointDistance * this.sliderTickRate * (1000 / beatLength) return this.sliderScoringPointDistance * this.sliderTickRate; } _beatLengthAt(time) { if (this.timingPoints.length == 0) return 0; let point = 0; let samplePoint = 0; for (let i = 0; i < this.timingPoints.length; i++) { if (this.timingPoints[i].offset <= time) { if (this.timingPoints[i].timingChange) point = i; else samplePoint = i; } } let mult = 1.0; if (samplePoint > point && this.timingPoints[samplePoint].beatLength< 0) mult = this.timingPoints[samplePoint]._bpmMultiplier(); return this.timingPoints[point].beatLength * mult; } _bpmMultAt(time) { let tp = this._timingPointAt(time); if (tp == null) return 1; return tp._bpmMultiplier(); } _timingPointAt(time) { if (this.timingPoints.length == 0) return null; let point = 0; for (let i = 0; i < this.timingPoints.length; i++) { if (this.timingPoints[i].offset <= time) point = i; } return this.timingPoints[point]; } _diffPpyStars() { let objFactor = clamp((this.hitObjects.length / Math.fround(this.drainLength)) * 8, 0, 16); return Math.round((Math.fround(this.hp) + Math.fround(this.od) + Math.fround(this.cs) + Math.fround(objFactor)) / 38 * 5); } }