1819 lines
47 KiB
JavaScript
1819 lines
47 KiB
JavaScript
// this is free and unencumbered software released into the public
|
|
// domain. refer to the attached UNLICENSE or http://unlicense.org/
|
|
//
|
|
// [![Build Status](
|
|
// https://travis-ci.org/Francesco149/ojsama.svg?branch=master)](
|
|
// https://travis-ci.org/Francesco149/ojsama)
|
|
//
|
|
// pure javascript implementation of
|
|
// https://github.com/Francesco149/oppai-ng intended to be easier
|
|
// to use and set up for js developers as well as more portable
|
|
// than straight up bindings at the cost of some performance
|
|
//
|
|
// installation:
|
|
// ----------------------------------------------------------------
|
|
// since this is a single-file library, you can just drop the file
|
|
// into your project:
|
|
// ```sh
|
|
// cd my/project
|
|
// curl https://waa.ai/ojsama > ojsama.js
|
|
// ```
|
|
//
|
|
// or include it directly in a html page:
|
|
// ```html
|
|
// <script type="text/javascript" src="ojsama.min.js"></script>
|
|
// ```
|
|
//
|
|
// it's also available as a npm package:
|
|
// ```sh
|
|
// npm install ojsama
|
|
// ```
|
|
//
|
|
// you can find full documentation of the code at
|
|
// http://hnng.moe/stuff/ojsama.html or simply read ojsama.js
|
|
//
|
|
// usage (nodejs):
|
|
// ----------------------------------------------------------------
|
|
// (change ./ojsama to ojsama if you installed through npm)
|
|
//
|
|
// ```js
|
|
// var readline = require("readline");
|
|
// var osu = require("./ojsama");
|
|
//
|
|
// var parser = new osu.parser();
|
|
// readline.createInterface({
|
|
// input: process.stdin, terminal: falsei
|
|
// })
|
|
// .on("line", parser.feed_line.bind(parser))
|
|
// .on("close", function() {
|
|
// console.log(osu.ppv2({map: parser.map}).toString());
|
|
// });
|
|
// ```
|
|
//
|
|
// ```sh
|
|
// $ curl https://osu.ppy.sh/osu/67079 | node minexample.js
|
|
// 133.24 pp (36.23 aim, 40.61 speed, 54.42 acc)
|
|
// ```
|
|
//
|
|
// advanced usage (nodejs with acc, mods, combo...):
|
|
// ----------------------------------------------------------------
|
|
// ```js
|
|
// var readline = require("readline");
|
|
// var osu = require("./ojsama");
|
|
//
|
|
// var mods = osu.modbits.none;
|
|
// var acc_percent;
|
|
// var combo;
|
|
// var nmiss;
|
|
//
|
|
// // get mods, acc, combo, misses from command line arguments
|
|
// // format: +HDDT 95% 300x 1m
|
|
// var argv = process.argv;
|
|
//
|
|
// for (var i = 2; i < argv.length; ++i)
|
|
// {
|
|
// if (argv[i].startsWith("+")) {
|
|
// mods = osu.modbits.from_string(argv[i].slice(1) || "");
|
|
// }
|
|
//
|
|
// else if (argv[i].endsWith("%")) {
|
|
// acc_percent = parseFloat(argv[i]);
|
|
// }
|
|
//
|
|
// else if (argv[i].endsWith("x")) {
|
|
// combo = parseInt(argv[i]);
|
|
// }
|
|
//
|
|
// else if (argv[i].endsWith("m")) {
|
|
// nmiss = parseInt(argv[i]);
|
|
// }
|
|
// }
|
|
//
|
|
// var parser = new osu.parser();
|
|
// readline.createInterface({
|
|
// input: process.stdin, terminal: false
|
|
// })
|
|
// .on("line", parser.feed_line.bind(parser))
|
|
// .on("close", function() {
|
|
// var map = parser.map;
|
|
// console.log(map.toString());
|
|
//
|
|
// if (mods) {
|
|
// console.log("+" + osu.modbits.string(mods));
|
|
// }
|
|
//
|
|
// var stars = new osu.diff().calc({map: map, mods: mods});
|
|
// console.log(stars.toString());
|
|
//
|
|
// var pp = osu.ppv2({
|
|
// stars: stars,
|
|
// combo: combo,
|
|
// nmiss: nmiss,
|
|
// acc_percent: acc_percent,
|
|
// });
|
|
//
|
|
// var max_combo = map.max_combo();
|
|
// combo = combo || max_combo;
|
|
//
|
|
// console.log(pp.computed_accuracy.toString());
|
|
// console.log(combo + "/" + max_combo + "x");
|
|
//
|
|
// console.log(pp.toString());
|
|
// });
|
|
// ```
|
|
//
|
|
// ```sh
|
|
// $ curl https://osu.ppy.sh/osu/67079 | node example.js
|
|
// TERRA - Tenjou no Hoshi ~Reimeiki~ [BMax] mapped by ouranhshc
|
|
//
|
|
// AR5 OD8 CS4 HP8
|
|
// 262 circles, 69 sliders, 5 spinners
|
|
// 469 max combo
|
|
//
|
|
// 4.33 stars (2.09 aim, 2.19 speed)
|
|
// 100.00% 0x100 0x50 0xmiss
|
|
// 469/469x
|
|
// 133.24 pp (36.23 aim, 40.61 speed, 54.42 acc)
|
|
//
|
|
// $ curl https://osu.ppy.sh/osu/67079 \
|
|
// | node example.js +HDDT 98% 400x 1m
|
|
// ...
|
|
// +HDDT
|
|
// 6.13 stars (2.92 aim, 3.11 speed)
|
|
// 97.92% 9x100 0x50 1xmiss
|
|
// 400/469x
|
|
// 266.01 pp (99.70 aim, 101.68 speed, 60.41 acc)
|
|
// ```
|
|
//
|
|
// usage (in the browser)
|
|
// ----------------------------------------------------------------
|
|
// ```html
|
|
// <!DOCTYPE html>
|
|
// <html>
|
|
// <head>
|
|
// <meta charset="utf-8" />
|
|
// <script type="text/javascript" src="ojsama.min.js"></script>
|
|
// <script type="text/javascript">
|
|
// function load_file()
|
|
// {
|
|
// var frame = document.getElementById("osufile");
|
|
// var contents = frame.contentWindow
|
|
// .document.body.childNodes[0].innerHTML;
|
|
//
|
|
// var parser = new osu.parser().feed(contents);
|
|
// console.log(parser.toString());
|
|
//
|
|
// var str = parser.map.toString();
|
|
// str += osu.ppv2({map: parser.map}).toString();
|
|
//
|
|
// document.getElementById("result").innerHTML = str;
|
|
// }
|
|
// </script>
|
|
// </head>
|
|
// <body>
|
|
// <iframe id="osufile" src="test.osu" onload="load_file();"
|
|
// style="display: none;">
|
|
// </iframe>
|
|
// <blockquote><pre id="result">calculating...</pre></blockquote>
|
|
// </body>
|
|
// </html>
|
|
// ```
|
|
//
|
|
// (this example assumes you have a test.osu beatmap in the same
|
|
// directory)
|
|
//
|
|
// performance
|
|
// ----------------------------------------------------------------
|
|
// this is around 50-60% slower than the C implementation and uses
|
|
// ~10 times more memory.
|
|
// ```sh
|
|
// $ busybox time -v node --use_strict test.js
|
|
// ...
|
|
// User time (seconds): 16.58
|
|
// System time (seconds): 0.43
|
|
// Percent of CPU this job got: 101%
|
|
// Elapsed (wall clock) time (h:mm:ss or m:ss): 0m 16.70s
|
|
// ...
|
|
// Maximum resident set size (kbytes): 314080
|
|
// Minor (reclaiming a frame) page faults: 20928
|
|
// Voluntary context switches: 72138
|
|
// Involuntary context switches: 16689
|
|
// ```
|
|
|
|
// # code documentation
|
|
|
|
// when used outside of node, a osu namespace will be exposed
|
|
// without polluting the global scope
|
|
|
|
var osu = {};
|
|
|
|
if (typeof exports !== "undefined") {
|
|
osu = exports;
|
|
}
|
|
|
|
(function() {
|
|
|
|
osu.VERSION_MAJOR = 2;
|
|
osu.VERSION_MINOR = 2;
|
|
osu.VERSION_PATCH = 0;
|
|
|
|
// internal utilities
|
|
// ----------------------------------------------------------------
|
|
|
|
// override console with nop when running in a browser
|
|
|
|
var log = {warn: Function.prototype};
|
|
|
|
if (typeof exports !== "undefined") {
|
|
log = console;
|
|
}
|
|
|
|
var array_toFixed = function(arr, n) {
|
|
var res = new Array(arr.length);
|
|
for (var i = 0; i < res.length; ++i) {
|
|
res[i] = arr[i].toFixed(n);
|
|
}
|
|
return res;
|
|
};
|
|
|
|
function isUndefined(val) {
|
|
return typeof val === "undefined";
|
|
}
|
|
|
|
// timing point
|
|
// ----------------------------------------------------------------
|
|
// defines parameters such as timing and sampleset for an interval.
|
|
// for pp calculation we only need time and ms_per_beat
|
|
//
|
|
// it can inherit from its preceeding point by having
|
|
// change = false and setting ms_per_beat to a negative value which
|
|
// represents the bpm multiplier as ```-100 * bpm_multiplier```
|
|
|
|
function timing(values) {
|
|
this.time = values.time || 0.0;
|
|
this.ms_per_beat = values.ms_per_beat;
|
|
if (this.ms_per_beat === undefined) {
|
|
this.ms_per_beat = 600.0;
|
|
}
|
|
this.change = values.change;
|
|
if (this.change === undefined) {
|
|
this.change = true;
|
|
}
|
|
}
|
|
|
|
timing.prototype.toString = function() {
|
|
return "{ time: " + this.time.toFixed(2) + ", "
|
|
+ "ms_per_beat: " + this.ms_per_beat.toFixed(2) + " }";
|
|
}
|
|
|
|
// hit objects
|
|
// ----------------------------------------------------------------
|
|
// partial structure of osu! hitobjects with just enough data for
|
|
// pp calculation
|
|
|
|
// bitmask constants for object types. note that the type can
|
|
// contain other flags so you should always check type with
|
|
// ```if (type & objtypes.circle) { ... }```
|
|
|
|
var objtypes = {
|
|
circle: 1<<0,
|
|
slider: 1<<1,
|
|
spinner: 1<<3,
|
|
};
|
|
|
|
// all we need from circles is their position. all positions
|
|
// stored in the objects are in playfield coordinates (512*384
|
|
// rect)
|
|
|
|
function circle(values) {
|
|
this.pos = values.pos || [0, 0];
|
|
}
|
|
|
|
circle.prototype.toString = function() {
|
|
return "pos: [" + array_toFixed(this.pos, 2) + "]";
|
|
};
|
|
|
|
// to calculate max combo we need to compute slider ticks
|
|
//
|
|
// the beatmap stores the distance travelled in one repetition and
|
|
// the number of repetitions. this is enough to calculate distance
|
|
// per tick using timing information and slider velocity.
|
|
//
|
|
// note that 1 repetition means no repeats (1 loop)
|
|
|
|
function slider(values) {
|
|
this.pos = values.pos || [0, 0];
|
|
this.distance = values.distance || 0.0;
|
|
this.repetitions = values.repetitions || 1;
|
|
}
|
|
|
|
slider.prototype.toString = function() {
|
|
return (
|
|
"pos: " + array_toFixed(this.pos, 2) + ", " +
|
|
"distance: " + this.distance.toFixed(2) + ", " +
|
|
"repetitions: " + this.repetitions
|
|
);
|
|
};
|
|
|
|
// generic hitobject
|
|
//
|
|
// the only common property is start time (in millisecond).
|
|
// object-specific properties are stored in data, which can be
|
|
// an instance of circle, slider, or null
|
|
|
|
function hitobject(values) {
|
|
this.time = values.time || 0.0;
|
|
this.type = values.type || 0;
|
|
if (!isUndefined(values.data)) this.data = values.data;
|
|
}
|
|
|
|
hitobject.prototype.typestr = function() {
|
|
var res = "";
|
|
if (this.type & objtypes.circle) res += "circle | ";
|
|
if (this.type & objtypes.slider) res += "slider | ";
|
|
if (this.type & objtypes.spinner) res += "spinner | ";
|
|
return res.substring(0, Math.max(0, res.length - 3));
|
|
};
|
|
|
|
hitobject.prototype.toString = function() {
|
|
return (
|
|
"{ time: " + this.time.toFixed(2) + ", " +
|
|
"type: " + this.typestr() +
|
|
(this.data ? ", " + this.data.toString() : "") +
|
|
" }"
|
|
);
|
|
};
|
|
|
|
// beatmap
|
|
// ----------------------------------------------------------------
|
|
|
|
// gamemode constants
|
|
|
|
var modes = {
|
|
std: 0,
|
|
};
|
|
|
|
// partial beatmap structure with just enough data for pp
|
|
// calculation
|
|
|
|
function beatmap() {
|
|
this.reset();
|
|
}
|
|
|
|
beatmap.prototype.reset = function() {
|
|
this.format_version = 1;
|
|
this.mode = modes.std;
|
|
this.title = this.title_unicode = "";
|
|
this.artist = this.artist_unicode = "";
|
|
this.creator = "";
|
|
this.version = "";
|
|
this.ar = undefined;
|
|
this.cs = this.od = this.hp = 5.0;
|
|
this.sv = this.tick_rate = 1.0;
|
|
this.ncircles = this.nsliders = this.nspinners = 0;
|
|
if (!this.objects) {
|
|
this.objects = [];
|
|
} else {
|
|
this.objects.length = 0;
|
|
}
|
|
if (!this.timing_points) {
|
|
this.timing_points = [];
|
|
} else {
|
|
this.timing_points.length = 0;
|
|
}
|
|
return this;
|
|
};
|
|
|
|
// max combo calculation
|
|
//
|
|
// this is given by ncircles + nspinners + nsliders * 2
|
|
// (heads and tails) + nsliderticks
|
|
//
|
|
// we approximate slider ticks by calculating the
|
|
// playfield pixels per beat for the current section
|
|
// and dividing the total distance travelled by
|
|
// pixels per beat. this gives us the number of beats,
|
|
// which multiplied by the tick rate gives use the
|
|
// tick count.
|
|
|
|
beatmap.prototype.max_combo = function() {
|
|
var res = this.ncircles + this.nspinners;
|
|
var tindex = -1;
|
|
var tnext = Number.NEGATIVE_INFINITY;
|
|
var px_per_beat = 0.0;
|
|
|
|
for (var i = 0; i < this.objects.length; ++i) {
|
|
var obj = this.objects[i];
|
|
if (!(obj.type & objtypes.slider)) {
|
|
continue;
|
|
}
|
|
|
|
// keep track of the current timing point without
|
|
// looping through all of them for every object
|
|
while (obj.time >= tnext) {
|
|
++tindex;
|
|
if (this.timing_points.length > tindex + 1) {
|
|
tnext = this.timing_points[tindex + 1].time;
|
|
} else {
|
|
tnext = Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
var t = this.timing_points[tindex];
|
|
var sv_multiplier = 1.0;
|
|
if (!t.change && t.ms_per_beat < 0) {
|
|
sv_multiplier = -100.0 / t.ms_per_beat;
|
|
}
|
|
|
|
// beatmaps older than format v8 don't apply
|
|
// the bpm multiplier to slider ticks
|
|
if (this.format_version < 8) {
|
|
px_per_beat = this.sv * 100.0;
|
|
} else {
|
|
px_per_beat = this.sv * 100.0 * sv_multiplier;
|
|
}
|
|
}
|
|
|
|
var sl = obj.data;
|
|
var num_beats = (sl.distance * sl.repetitions) / px_per_beat;
|
|
|
|
// subtract an epsilon to prevent accidental
|
|
// ceiling of whole values such as 2.00....1 -> 3 due
|
|
// to rounding errors
|
|
|
|
var ticks = Math.ceil(
|
|
(num_beats - 0.1) / sl.repetitions
|
|
* this.tick_rate
|
|
);
|
|
|
|
--ticks;
|
|
ticks *= sl.repetitions;
|
|
ticks += sl.repetitions + 1;
|
|
|
|
res += Math.max(0, ticks);
|
|
}
|
|
|
|
return res;
|
|
};
|
|
|
|
beatmap.prototype.toString = function() {
|
|
var res = this.artist + " - " + this.title + " [";
|
|
if (this.title_unicode || this.artist_unicode) {
|
|
res += "(" + this.artist_unicode + " - "
|
|
+ this.title_unicode + ")";
|
|
}
|
|
res += (
|
|
this.version + "] mapped by " + this.creator + "\n"
|
|
+ "\n"
|
|
+ "AR" + parseFloat(this.ar.toFixed(2)) + " "
|
|
+ "OD" + parseFloat(this.od.toFixed(2)) + " "
|
|
+ "CS" + parseFloat(this.cs.toFixed(2)) + " "
|
|
+ "HP" + parseFloat(this.hp.toFixed(2)) + "\n"
|
|
+ this.ncircles + " circles, "
|
|
+ this.nsliders + " sliders, "
|
|
+ this.nspinners + " spinners" + "\n"
|
|
+ this.max_combo() + " max combo" + "\n"
|
|
);
|
|
return res;
|
|
};
|
|
|
|
// beatmap parser
|
|
// ----------------------------------------------------------------
|
|
|
|
// partial .osu file parser built around pp calculation
|
|
|
|
function parser() {
|
|
// once you're done feeding data to the parser, you will find
|
|
// the parsed beatmap in this object
|
|
this.map = new beatmap();
|
|
this.reset();
|
|
}
|
|
|
|
parser.prototype.reset = function() {
|
|
this.map.reset();
|
|
|
|
// parser state: number of lines fed, last touched line,
|
|
// last touched substring and the current section name
|
|
//
|
|
// these can be used to debug syntax errors
|
|
|
|
this.nline = 0;
|
|
this.curline = "";
|
|
this.lastpos = "";
|
|
this.section = "";
|
|
return this;
|
|
};
|
|
|
|
// you can feed a single line or a whole block of text which
|
|
// will be split into lines. partial lines are not allowed
|
|
//
|
|
// both feed functions return the parser instance for easy
|
|
// chaining
|
|
|
|
parser.prototype.feed_line = function(line) {
|
|
this.curline = this.lastpos = line;
|
|
++this.nline;
|
|
|
|
// comments
|
|
if (line.startsWith(" ") || line.startsWith("_")) {
|
|
return this;
|
|
}
|
|
|
|
// now that we've handled space comments we can trim space
|
|
line = this.curline = line.trim();
|
|
if (line.length <= 0) {
|
|
return this;
|
|
}
|
|
|
|
// c++ style comments
|
|
if (line.startsWith("//")) {
|
|
return this;
|
|
}
|
|
|
|
// [SectionName]
|
|
if (line.startsWith("[")) {
|
|
// on old maps there's no ar and ar = od
|
|
if (this.section == "Difficulty" && isUndefined(this.map.ar)) {
|
|
this.map.ar = this.map.od;
|
|
}
|
|
this.section = line.substring(1, line.length - 1);
|
|
return this;
|
|
}
|
|
|
|
if (!line) {
|
|
return this;
|
|
}
|
|
|
|
switch (this.section) {
|
|
case "Metadata": this._metadata(); break;
|
|
case "General": this._general(); break;
|
|
case "Difficulty": this._difficulty(); break;
|
|
case "TimingPoints": this._timing_points(); break;
|
|
case "HitObjects": this._objects(); break;
|
|
default:
|
|
var fmtpos = line.indexOf("file format v");
|
|
if (fmtpos< 0) {
|
|
break;
|
|
}
|
|
this.map.format_version = parseInt(line.substring(fmtpos + 13));
|
|
break;
|
|
}
|
|
return this;
|
|
};
|
|
|
|
parser.prototype.feed = function(str) {
|
|
var lines = lines = str.split("\n");
|
|
for (var i = 0; i < lines.length; ++i) {
|
|
this.feed_line(lines[i]);
|
|
}
|
|
return this;
|
|
};
|
|
|
|
// returns the parser state formatted into a string. useful
|
|
// for debugging syntax errors
|
|
|
|
parser.prototype.toString = function() {
|
|
return (
|
|
"at line " + this.nline + "\n" +
|
|
this.curline + "\n" +
|
|
"-> " + this.lastpos + " <-"
|
|
);
|
|
};
|
|
|
|
// _(internal)_ parser utilities
|
|
|
|
parser.prototype._setpos = function(str) {
|
|
this.lastpos = str.trim();
|
|
return this.lastpos;
|
|
};
|
|
|
|
parser.prototype._warn = function() {
|
|
log.warn.apply(null, Array.prototype.slice.call(arguments));
|
|
log.warn(this.toString());
|
|
};
|
|
|
|
parser.prototype._property = function() {
|
|
var s = this.curline.split(":", 2);
|
|
s[0] = s[0] && this._setpos(s[0]);
|
|
s[1] = s[1] && this._setpos(s[1]);
|
|
return s;
|
|
};
|
|
|
|
// _(internal)_ line parsers for each section
|
|
|
|
parser.prototype._metadata = function() {
|
|
var p = this._property();
|
|
switch (p[0]) {
|
|
case "Title":
|
|
this.map.title = p[1];
|
|
break;
|
|
case "TitleUnicode":
|
|
this.map.title_unicode = p[1];
|
|
break;
|
|
case "Artist":
|
|
this.map.artist = p[1];
|
|
break;
|
|
case "ArtistUnicode":
|
|
this.map.artist_unicode = p[1];
|
|
break;
|
|
case "Creator":
|
|
this.map.creator = p[1];
|
|
break;
|
|
case "Version":
|
|
this.map.version = p[1];
|
|
break;
|
|
case "BeatmapID":
|
|
this.map.beatmapId = parseInt(p[1]);
|
|
break;
|
|
case "BeatmapSetID":
|
|
this.map.beatmapsetId = parseInt(p[1]);
|
|
break;
|
|
}
|
|
};
|
|
|
|
parser.prototype._general = function() {
|
|
var p = this._property();
|
|
if (p[0] !== "Mode") {
|
|
return;
|
|
}
|
|
this.map.mode = parseInt(this._setpos(p[1]));
|
|
};
|
|
|
|
parser.prototype._difficulty = function() {
|
|
var p = this._property();
|
|
switch (p[0]) {
|
|
case "CircleSize":
|
|
this.map.cs = parseFloat(this._setpos(p[1]));
|
|
break;
|
|
case "OverallDifficulty":
|
|
this.map.od = parseFloat(this._setpos(p[1]));
|
|
break;
|
|
case "ApproachRate":
|
|
this.map.ar = parseFloat(this._setpos(p[1]));
|
|
break;
|
|
case "HPDrainRate":
|
|
this.map.hp = parseFloat(this._setpos(p[1]));
|
|
break;
|
|
case "SliderMultiplier":
|
|
this.map.sv = parseFloat(this._setpos(p[1]));
|
|
break;
|
|
case "SliderTickRate":
|
|
this.map.tick_rate = parseFloat(this._setpos(p[1]));
|
|
break;
|
|
}
|
|
};
|
|
|
|
parser.prototype._timing_points = function() {
|
|
var s = this.curline.split(",");
|
|
if (s.length > 8) {
|
|
this._warn("timing point with trailing values");
|
|
} else if (s.length < 2) {
|
|
this._warn("ignoring malformed timing point");
|
|
return;
|
|
}
|
|
var t = new timing({
|
|
time: parseFloat(this._setpos(s[0])),
|
|
ms_per_beat: parseFloat(this._setpos(s[1])),
|
|
});
|
|
if (s.length >= 7) {
|
|
t.change = s[6].trim() !== "0";
|
|
}
|
|
this.map.timing_points.push(t);
|
|
};
|
|
|
|
parser.prototype._objects = function() {
|
|
var s = this.curline.split(",");
|
|
var d;
|
|
if (s.length > 11) {
|
|
this._warn("object with trailing values");
|
|
} else if (s.length < 4) {
|
|
this._warn("ignoring malformed hitobject");
|
|
return;
|
|
}
|
|
var obj = new hitobject({
|
|
time: parseFloat(this._setpos(s[2])),
|
|
type: parseInt(this._setpos(s[3])),
|
|
});
|
|
if (isNaN(obj.time) || isNaN(obj.type)) {
|
|
this._warn("ignoring malformed hitobject");
|
|
return;
|
|
}
|
|
if ((obj.type & objtypes.circle) != 0) {
|
|
++this.map.ncircles;
|
|
d = obj.data = new circle({
|
|
pos: [
|
|
parseFloat(this._setpos(s[0])),
|
|
parseFloat(this._setpos(s[1])),
|
|
]
|
|
});
|
|
if (isNaN(d.pos[0]) || isNaN(d.pos[1])) {
|
|
this._warn("ignoring malformed circle");
|
|
return;
|
|
}
|
|
} else if ((obj.type & osu.objtypes.spinner) != 0) {
|
|
++this.map.nspinners;
|
|
} else if ((obj.type & osu.objtypes.slider) != 0) {
|
|
if (s.length < 8) {
|
|
this._warn("ignoring malformed slider");
|
|
return;
|
|
}
|
|
++this.map.nsliders;
|
|
d = obj.data = new slider({
|
|
pos: [
|
|
parseFloat(this._setpos(s[0])),
|
|
parseFloat(this._setpos(s[1])),
|
|
],
|
|
repetitions: parseInt(this._setpos(s[6])),
|
|
distance: parseFloat(this._setpos(s[7])),
|
|
});
|
|
if (isNaN(d.pos[0]) || isNaN(d.pos[1]) ||
|
|
isNaN(d.repetitions) || isNaN(d.distance))
|
|
{
|
|
this._warn("ignoring malformed slider");
|
|
return;
|
|
}
|
|
}
|
|
this.map.objects.push(obj);
|
|
}
|
|
|
|
// difficulty calculation
|
|
// ----------------------------------------------------------------
|
|
|
|
// mods bitmask constants
|
|
// NOTE: td is touch device, but it's also the value for the
|
|
// legacy no video mod
|
|
|
|
var modbits = {
|
|
nomod: 0,
|
|
nf: 1<<0,
|
|
ez: 1<<1,
|
|
td: 1<<2,
|
|
hd: 1<<3,
|
|
hr: 1<<4,
|
|
dt: 1<<6,
|
|
ht: 1<<8,
|
|
nc: 1<<9,
|
|
fl: 1<<10,
|
|
so: 1<<12,
|
|
};
|
|
|
|
// construct the mods bitmask from a string such as "HDHR"
|
|
|
|
modbits.from_string = function(str) {
|
|
var mask = 0;
|
|
str = str.toLowerCase();
|
|
while (str != "") {
|
|
var nchars = 1;
|
|
for (var property in modbits) {
|
|
if (property.length != 2) {
|
|
continue;
|
|
}
|
|
if (!modbits.hasOwnProperty(property)) {
|
|
continue;
|
|
}
|
|
if (str.startsWith(property)) {
|
|
mask |= modbits[property];
|
|
nchars = 2;
|
|
break;
|
|
}
|
|
}
|
|
str = str.slice(nchars);
|
|
}
|
|
return mask;
|
|
};
|
|
|
|
// convert mods bitmask into a string, such as "HDHR"
|
|
|
|
modbits.string = function(mods) {
|
|
var res = "";
|
|
for (var property in modbits) {
|
|
if (property.length != 2) {
|
|
continue;
|
|
}
|
|
if (!modbits.hasOwnProperty(property)) {
|
|
continue;
|
|
}
|
|
if (mods & modbits[property]) {
|
|
res += property.toUpperCase();
|
|
}
|
|
}
|
|
if (res.indexOf("DT") >= 0 && res.indexOf("NC") >= 0) {
|
|
res = res.replace("DT", "");
|
|
}
|
|
return res;
|
|
};
|
|
|
|
modbits.speed_changing = modbits.dt | modbits.ht | modbits.nc;
|
|
modbits.map_changing = modbits.hr | modbits.ez | modbits.speed_changing;
|
|
|
|
// _(internal)_
|
|
// osu!standard stats constants
|
|
|
|
var OD0_MS = 80;
|
|
var OD10_MS = 20;
|
|
var AR0_MS = 1800.0;
|
|
var AR5_MS = 1200.0;
|
|
var AR10_MS = 450.0;
|
|
|
|
var OD_MS_STEP = (OD0_MS - OD10_MS) / 10.0;
|
|
var AR_MS_STEP1 = (AR0_MS - AR5_MS) / 5.0;
|
|
var AR_MS_STEP2 = (AR5_MS - AR10_MS) / 5.0;
|
|
|
|
// _(internal)_
|
|
// utility functions to apply speed and flat multipliers to
|
|
// stats where speed changes apply (ar and od)
|
|
|
|
function modify_ar(base_ar, speed_mul, multiplier) {
|
|
var ar = base_ar;
|
|
ar *= multiplier;
|
|
|
|
// convert AR into milliseconds window
|
|
|
|
var arms = (
|
|
ar < 5.0 ?
|
|
AR0_MS - AR_MS_STEP1 * ar
|
|
: AR5_MS - AR_MS_STEP2 * (ar - 5.0)
|
|
);
|
|
|
|
// stats must be capped to 0-10 before HT/DT which
|
|
// brings them to a range of -4.42->11.08 for OD and
|
|
// -5->11 for AR
|
|
|
|
arms = Math.min(AR0_MS, Math.max(AR10_MS, arms));
|
|
arms /= speed_mul;
|
|
|
|
ar = (
|
|
arms > AR5_MS ?
|
|
(AR0_MS - arms) / AR_MS_STEP1
|
|
: 5.0 + (AR5_MS - arms) / AR_MS_STEP2
|
|
);
|
|
return ar;
|
|
}
|
|
|
|
function modify_od(base_od, speed_mul, multiplier) {
|
|
var od = base_od;
|
|
od *= multiplier;
|
|
var odms = OD0_MS - Math.ceil(OD_MS_STEP * od);
|
|
odms = Math.min(OD0_MS, Math.max(OD10_MS, odms));
|
|
odms /= speed_mul;
|
|
od = (OD0_MS - odms) / OD_MS_STEP;
|
|
return od;
|
|
}
|
|
|
|
// stores osu!standard beatmap stats
|
|
|
|
function std_beatmap_stats(values) {
|
|
this.ar = values.ar;
|
|
this.od = values.od;
|
|
this.hp = values.hp;
|
|
this.cs = values.cs;
|
|
this.speed_mul = 1.0;
|
|
|
|
// previously calculated mod combinations are cached in a map
|
|
|
|
this._mods_cache = {};
|
|
}
|
|
|
|
// applies difficulty modifiers to a map's ar, od, cs, hp and
|
|
// returns the modified stats and the speed multiplier.
|
|
//
|
|
// unspecified stats are ignored and not returned
|
|
|
|
std_beatmap_stats.prototype.with_mods = function(mods) {
|
|
if (this._mods_cache[mods]) {
|
|
return this._mods_cache[mods];
|
|
}
|
|
var stats = this._mods_cache[mods] = new std_beatmap_stats(this);
|
|
if (!(mods & modbits.map_changing)) {
|
|
return stats;
|
|
}
|
|
if (mods & (modbits.dt|modbits.nc)) {
|
|
stats.speed_mul = 1.5;
|
|
}
|
|
if (mods & modbits.ht) {
|
|
stats.speed_mul *= 0.75;
|
|
}
|
|
|
|
var od_ar_hp_multiplier = 1.0;
|
|
if (mods & modbits.hr) od_ar_hp_multiplier = 1.4;
|
|
if (mods & modbits.ez) od_ar_hp_multiplier *= 0.5;
|
|
if (stats.ar) {
|
|
stats.ar = modify_ar(stats.ar, stats.speed_mul, od_ar_hp_multiplier);
|
|
}
|
|
if (stats.od) {
|
|
stats.od = modify_od(stats.od, stats.speed_mul, od_ar_hp_multiplier);
|
|
}
|
|
|
|
if (stats.cs) {
|
|
if (mods & modbits.hr) stats.cs *= 1.3;
|
|
if (mods & modbits.ez) stats.cs *= 0.5;
|
|
stats.cs = Math.min(10.0, stats.cs);
|
|
}
|
|
if (stats.hp) {
|
|
stats.hp *= od_ar_hp_multiplier;
|
|
stats.hp = Math.min(10.0, stats.hp);
|
|
}
|
|
return stats;
|
|
};
|
|
|
|
// osu! standard hit object with difficulty calculation values
|
|
// obj is the underlying hitobject
|
|
|
|
function std_diff_hitobject(obj) {
|
|
this.obj = obj;
|
|
this.reset();
|
|
}
|
|
|
|
std_diff_hitobject.prototype.reset = function() {
|
|
this.strains = [ 0.0, 0.0 ];
|
|
this.normpos = [ 0.0, 0.0 ];
|
|
this.angle = 0.0;
|
|
this.is_single = false;
|
|
this.delta_time = 0.0;
|
|
this.d_distance = 0.0;
|
|
return this;
|
|
};
|
|
|
|
std_diff_hitobject.prototype.toString = function() {
|
|
return (
|
|
"{ strains: [" + array_toFixed(this.strains, 2)
|
|
+ "], normpos: [" + array_toFixed(this.normpos, 2)
|
|
+ "], is_single: " + this.is_single + " }"
|
|
);
|
|
};
|
|
|
|
// _(internal)_
|
|
// 2D point operations
|
|
|
|
function vec_sub(a, b) { return [a[0] - b[0], a[1] - b[1]]; }
|
|
function vec_mul(a, b) { return [a[0] * b[0], a[1] * b[1]]; }
|
|
function vec_len(v) { return Math.sqrt(v[0] * v[0] + v[1] * v[1]); }
|
|
function vec_dot(a, b) { return a[0] * b[0] + a[1] * b[1]; }
|
|
|
|
// _(internal)_
|
|
// difficulty calculation constants
|
|
|
|
var DIFF_SPEED = 0;
|
|
var DIFF_AIM = 1;
|
|
var SINGLE_SPACING = 125.0;
|
|
var DECAY_BASE = [ 0.3, 0.15 ];
|
|
var WEIGHT_SCALING = [ 1400.0, 26.25 ];
|
|
var DECAY_WEIGHT = 0.9;
|
|
var STRAIN_STEP = 400.0;
|
|
var CIRCLESIZE_BUFF_THRESHOLD = 30.0;
|
|
var STAR_SCALING_FACTOR = 0.0675;
|
|
var PLAYFIELD_SIZE = [512.0, 384.0];
|
|
var PLAYFIELD_CENTER = vec_mul(PLAYFIELD_SIZE, [0.5, 0.5]);
|
|
var EXTREME_SCALING_FACTOR = 0.5;
|
|
|
|
// osu!standard difficulty calculator
|
|
//
|
|
// does not account for sliders because slider calculations are
|
|
// expensive and not worth the small accuracy increase
|
|
|
|
function std_diff() {
|
|
this.objects = [];
|
|
this.reset();
|
|
|
|
// make some parameters persist so they can be
|
|
// re-used in subsequent calls if no new value is specified
|
|
|
|
this.map = undefined;
|
|
this.mods = modbits.nomod;
|
|
this.singletap_threshold = 125.0;
|
|
}
|
|
|
|
std_diff.prototype.reset = function() {
|
|
// star rating
|
|
|
|
this.total = 0.0;
|
|
this.aim = 0.0;
|
|
this.aim_difficulty = 0.0;
|
|
this.aim_length_bonus = 0.0;
|
|
this.speed = 0.0;
|
|
this.speed_difficulty = 0.0;
|
|
this.speed_length_bonus = 0.0;
|
|
|
|
// number of notes that are seen as singletaps by the
|
|
// difficulty calculator
|
|
|
|
this.nsingles = 0;
|
|
|
|
// number of notes that are faster than the interval given
|
|
// in calc(). these singletap statistic are not required in
|
|
// star rating, but they are a free byproduct of the
|
|
// calculation which could be useful
|
|
|
|
this.nsingles_threshold = 0;
|
|
};
|
|
|
|
std_diff.prototype._length_bonus = function(stars, difficulty) {
|
|
return 0.32 + 0.5 * (Math.log10(difficulty + stars) - Math.log10(stars));
|
|
};
|
|
|
|
// calculate difficulty and return current instance, which
|
|
// contains the results
|
|
//
|
|
// params:
|
|
// * map: the beatmap we want to calculate difficulty for. if
|
|
// unspecified, it will default to the last map used
|
|
// in previous calls.
|
|
// * mods: mods bitmask, defaults to modbits.nomod
|
|
// * singletap_threshold: interval threshold in milliseconds
|
|
// for singletaps. defaults to 240 bpm 1/2 singletaps
|
|
// ```(60000 / 240) / 2``` .
|
|
// see nsingles_threshold
|
|
|
|
std_diff.prototype.calc = function(params) {
|
|
var map = this.map = params.map || this.map;
|
|
if (!map) {
|
|
throw new TypeError("no map given");
|
|
}
|
|
|
|
var mods = this.mods = params.mods || this.mods;
|
|
var singletap_threshold = this.singletap_threshold
|
|
= params.singletap_threshold || singletap_threshold;
|
|
|
|
// apply mods to the beatmap's stats
|
|
|
|
var stats = new std_beatmap_stats({cs: map.cs}).with_mods(mods);
|
|
var speed_mul = stats.speed_mul;
|
|
this._init_objects(this.objects, map, stats.cs);
|
|
|
|
var speed = this._calc_individual(DIFF_SPEED, this.objects, speed_mul);
|
|
this.speed = speed.difficulty;
|
|
this.speed_difficulty = speed.total;
|
|
|
|
var aim = this._calc_individual(DIFF_AIM, this.objects, speed_mul);
|
|
this.aim = aim.difficulty;
|
|
this.aim_difficulty = aim.total;
|
|
|
|
this.aim_length_bonus = this._length_bonus(this.aim, this.aim_difficulty);
|
|
this.speed_length_bonus = this._length_bonus(this.speed,
|
|
this.speed_difficulty);
|
|
this.aim = Math.sqrt(this.aim) * STAR_SCALING_FACTOR;
|
|
this.speed = Math.sqrt(this.speed) * STAR_SCALING_FACTOR;
|
|
|
|
if (mods & modbits.td) {
|
|
this.aim = Math.pow(this.aim, 0.8);
|
|
}
|
|
|
|
// total stars mixes speed and aim in such a way that
|
|
// heavily aim or speed focused maps get a bonus
|
|
|
|
this.total = (
|
|
this.aim + this.speed + Math.abs(this.speed - this.aim) *
|
|
EXTREME_SCALING_FACTOR
|
|
);
|
|
|
|
// singletap stats
|
|
|
|
this.nsingles = 0;
|
|
this.nsingles_threshold = 0;
|
|
|
|
for (var i = 1; i < this.objects.length; ++i) {
|
|
var obj = this.objects[i].obj;
|
|
var prev = this.objects[i - 1].obj;
|
|
if (this.objects[i].is_single) {
|
|
++this.nsingles;
|
|
}
|
|
if (!(obj.type & (objtypes.circle|objtypes.slider))) {
|
|
continue;
|
|
}
|
|
var interval = (obj.time - prev.time) / speed_mul;
|
|
if (interval >= singletap_threshold) {
|
|
++this.nsingles_threshold;
|
|
}
|
|
}
|
|
return this;
|
|
};
|
|
|
|
std_diff.prototype.toString = function() {
|
|
return (
|
|
this.total.toFixed(2) + " stars (" + this.aim.toFixed(2) +
|
|
" aim, " + this.speed.toFixed(2) + " speed)"
|
|
);
|
|
};
|
|
|
|
// _(internal)_
|
|
// calculate spacing weight for a difficulty type
|
|
|
|
// ~200BPM 1/4 streams
|
|
var MIN_SPEED_BONUS = 75.0;
|
|
|
|
// ~330BPM 1/4 streams
|
|
var MAX_SPEED_BONUS = 45.0;
|
|
|
|
var ANGLE_BONUS_SCALE = 90;
|
|
var AIM_TIMING_THRESHOLD = 107;
|
|
var SPEED_ANGLE_BONUS_BEGIN = 5 * Math.PI / 6;
|
|
var AIM_ANGLE_BONUS_BEGIN = Math.PI / 3;
|
|
|
|
std_diff.prototype._spacing_weight = function(type, distance, delta_time,
|
|
prev_distance, prev_delta_time, angle)
|
|
{
|
|
var angle_bonus;
|
|
var strain_time = Math.max(delta_time, 50.0);
|
|
switch (type) {
|
|
case DIFF_AIM: {
|
|
var prev_strain_time = Math.max(prev_delta_time, 50.0);
|
|
var result = 0.0;
|
|
if (angle !== null && angle > AIM_ANGLE_BONUS_BEGIN) {
|
|
angle_bonus = Math.sqrt(
|
|
Math.max(prev_distance - ANGLE_BONUS_SCALE, 0.0) *
|
|
Math.pow(Math.sin(angle - AIM_ANGLE_BONUS_BEGIN), 2.0) *
|
|
Math.max(distance - ANGLE_BONUS_SCALE, 0.0)
|
|
);
|
|
result = 1.5 * Math.pow(Math.max(0.0, angle_bonus), 0.99) /
|
|
Math.max(AIM_TIMING_THRESHOLD, prev_strain_time);
|
|
}
|
|
var weighted_distance = Math.pow(distance, 0.99);
|
|
return Math.max(
|
|
result +
|
|
weighted_distance / Math.max(AIM_TIMING_THRESHOLD, strain_time),
|
|
weighted_distance / strain_time
|
|
);
|
|
}
|
|
case DIFF_SPEED: {
|
|
distance = Math.min(distance, SINGLE_SPACING);
|
|
delta_time = Math.max(delta_time, MAX_SPEED_BONUS);
|
|
var speed_bonus = 1.0;
|
|
if (delta_time < MIN_SPEED_BONUS) {
|
|
speed_bonus += Math.pow((MIN_SPEED_BONUS - delta_time) / 40.0, 2);
|
|
}
|
|
angle_bonus = 1.0;
|
|
if (angle !== null && angle < SPEED_ANGLE_BONUS_BEGIN) {
|
|
var s = Math.sin(1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle));
|
|
angle_bonus += Math.pow(s, 2) / 3.57;
|
|
if (angle < Math.PI / 2.0) {
|
|
angle_bonus = 1.28;
|
|
if (distance < ANGLE_BONUS_SCALE && angle < Math.PI / 4.0) {
|
|
angle_bonus += (1.0 - angle_bonus) *
|
|
Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0);
|
|
}
|
|
else if (distance < ANGLE_BONUS_SCALE) {
|
|
angle_bonus += (1.0 - angle_bonus) *
|
|
Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0) *
|
|
Math.sin((Math.PI / 2.0 - angle) * 4.0 / Math.PI);
|
|
}
|
|
}
|
|
}
|
|
return (
|
|
(1 + (speed_bonus - 1) * 0.75) * angle_bonus *
|
|
(0.95 + speed_bonus * Math.pow(distance / SINGLE_SPACING, 3.5))
|
|
) / strain_time;
|
|
}
|
|
}
|
|
throw {
|
|
name: "NotImplementedError",
|
|
message: "this difficulty type does not exist"
|
|
};
|
|
};
|
|
|
|
// _(internal)_
|
|
// calculate a single strain and store it in the diffobj
|
|
|
|
std_diff.prototype._calc_strain = function(type, diffobj, prev_diffobj,
|
|
speed_mul)
|
|
{
|
|
var obj = diffobj.obj;
|
|
var prev_obj = prev_diffobj.obj;
|
|
|
|
var value = 0.0;
|
|
var time_elapsed = (obj.time - prev_obj.time) / speed_mul;
|
|
var decay = Math.pow(DECAY_BASE[type],
|
|
time_elapsed / 1000.0);
|
|
|
|
diffobj.delta_time = time_elapsed;
|
|
|
|
if ((obj.type & (objtypes.slider|objtypes.circle)) != 0) {
|
|
var distance = vec_len(vec_sub(diffobj.normpos, prev_diffobj.normpos));
|
|
diffobj.d_distance = distance;
|
|
if (type == DIFF_SPEED) {
|
|
diffobj.is_single = distance > SINGLE_SPACING;
|
|
}
|
|
value = this._spacing_weight(type, distance, time_elapsed,
|
|
prev_diffobj.d_distance, prev_diffobj.delta_time, diffobj.angle);
|
|
value *= WEIGHT_SCALING[type];
|
|
}
|
|
|
|
diffobj.strains[type] = prev_diffobj.strains[type] * decay + value;
|
|
};
|
|
|
|
// _(internal)_
|
|
// calculate a specific type of difficulty
|
|
//
|
|
// the map is analyzed in chunks of STRAIN_STEP duration.
|
|
// for each chunk the highest hitobject strains are added to
|
|
// a list which is then collapsed into a weighted sum, much
|
|
// like scores are weighted on a user's profile.
|
|
//
|
|
// for subsequent chunks, the initial max strain is calculated
|
|
// by decaying the previous hitobject's strain until the
|
|
// beginning of the new chunk
|
|
//
|
|
// the first object doesn't generate a strain
|
|
// so we begin with an incremented interval end
|
|
//
|
|
// also don't forget to manually add the peak strain for the last
|
|
// section which would otherwise be ignored
|
|
|
|
std_diff.prototype._calc_individual = function(type, diffobjs, speed_mul) {
|
|
var strains = [];
|
|
var strain_step = STRAIN_STEP * speed_mul;
|
|
var interval_end = (
|
|
Math.ceil(diffobjs[0].obj.time / strain_step) * strain_step
|
|
);
|
|
var max_strain = 0.0;
|
|
var i;
|
|
|
|
for (i = 0; i < diffobjs.length; ++i) {
|
|
if (i > 0) {
|
|
this._calc_strain(type, diffobjs[i], diffobjs[i - 1],
|
|
speed_mul);
|
|
}
|
|
while (diffobjs[i].obj.time > interval_end) {
|
|
strains.push(max_strain);
|
|
if (i > 0) {
|
|
var decay = Math.pow(DECAY_BASE[type],
|
|
(interval_end - diffobjs[i - 1].obj.time) / 1000.0);
|
|
max_strain = diffobjs[i - 1].strains[type] * decay;
|
|
} else {
|
|
max_strain = 0.0;
|
|
}
|
|
interval_end += strain_step;
|
|
}
|
|
max_strain = Math.max(max_strain, diffobjs[i].strains[type]);
|
|
}
|
|
|
|
strains.push(max_strain);
|
|
|
|
var weight = 1.0;
|
|
var total = 0.0;
|
|
var difficulty = 0.0;
|
|
|
|
strains.sort(function (a, b) { return b - a; });
|
|
|
|
for (i = 0; i < strains.length; ++i) {
|
|
total += Math.pow(strains[i], 1.2);
|
|
difficulty += strains[i] * weight;
|
|
weight *= DECAY_WEIGHT;
|
|
}
|
|
|
|
return {difficulty: difficulty, total: total};
|
|
};
|
|
|
|
// _(internal)_
|
|
// positions are normalized on circle radius so that we can
|
|
// calc as if everything was the same circlesize.
|
|
//
|
|
// this creates a scaling vector that normalizes positions
|
|
|
|
std_diff.prototype._normalizer_vector = function(circlesize) {
|
|
var radius = (PLAYFIELD_SIZE[0] / 16.0)
|
|
* (1.0 - 0.7 * (circlesize - 5.0) / 5.0);
|
|
var scaling_factor = 52.0 / radius;
|
|
|
|
// high circlesize (small circles) bonus
|
|
|
|
if (radius < CIRCLESIZE_BUFF_THRESHOLD) {
|
|
scaling_factor *= 1.0
|
|
+ Math.min(CIRCLESIZE_BUFF_THRESHOLD - radius, 5.0) / 50.0;
|
|
}
|
|
return [scaling_factor, scaling_factor];
|
|
}
|
|
|
|
// _(internal)_
|
|
// initialize diffobjs (or reset if already initialized) and
|
|
// populate it with the normalized position of the map's
|
|
// objects
|
|
|
|
std_diff.prototype._init_objects = function(diffobjs, map,
|
|
circlesize)
|
|
{
|
|
if (diffobjs.length != map.objects.length) {
|
|
diffobjs.length = map.objects.length;
|
|
}
|
|
|
|
var scaling_vec = this._normalizer_vector(circlesize);
|
|
var normalized_center = vec_mul(PLAYFIELD_CENTER, scaling_vec);
|
|
|
|
for (var i = 0; i < diffobjs.length; ++i) {
|
|
if (!diffobjs[i]) {
|
|
diffobjs[i] = new std_diff_hitobject(map.objects[i]);
|
|
} else {
|
|
diffobjs[i].reset();
|
|
}
|
|
|
|
var pos;
|
|
var obj = diffobjs[i].obj;
|
|
if (obj.type & objtypes.spinner) {
|
|
diffobjs[i].normpos = normalized_center.slice();
|
|
} else if (obj.type & (objtypes.slider|objtypes.circle)) {
|
|
diffobjs[i].normpos = vec_mul(obj.data.pos, scaling_vec);
|
|
}
|
|
if (i >= 2) {
|
|
var prev1 = diffobjs[i - 1];
|
|
var prev2 = diffobjs[i - 2];
|
|
var v1 = vec_sub(prev2.normpos, prev1.normpos);
|
|
var v2 = vec_sub(diffobjs[i].normpos, prev1.normpos);
|
|
var dot = vec_dot(v1, v2);
|
|
var det = v1[0] * v2[1] - v1[1] * v2[0];
|
|
diffobjs[i].angle = Math.abs(Math.atan2(det, dot));
|
|
} else {
|
|
diffobjs[i].angle = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
// generic difficulty calculator that creates and uses
|
|
// mode-specific calculators based on the map's mode field
|
|
|
|
function diff() {
|
|
// calculators for different modes are cached for reuse within
|
|
// this instance
|
|
|
|
this.calculators = [];
|
|
this.map = undefined;
|
|
}
|
|
|
|
// figures out what difficulty calculator to use based on the
|
|
// beatmap's gamemode and calls it with params
|
|
//
|
|
// if no map is specified in params, the last map used in
|
|
// previous calls will be used. this simplifies subsequent
|
|
// calls for the same beatmap
|
|
//
|
|
// see gamemode-specific calculators above for params
|
|
//
|
|
// returns the chosen gamemode-specific difficulty calculator
|
|
|
|
diff.prototype.calc = function(params) {
|
|
var calculator;
|
|
var map = this.map = params.map || this.map;
|
|
if (!map) {
|
|
throw new TypeError("no map given");
|
|
}
|
|
if (!this.calculators[map.mode]) {
|
|
switch (map.mode) {
|
|
case modes.std:
|
|
calculator = new std_diff();
|
|
break;
|
|
default:
|
|
throw {
|
|
name: "NotImplementedError",
|
|
message: "this gamemode is not yet supported"
|
|
};
|
|
}
|
|
this.calculators[map.mode] = calculator;
|
|
} else {
|
|
calculator = this.calculators[map.mode];
|
|
}
|
|
return calculator.calc(params);
|
|
};
|
|
|
|
// pp calculation
|
|
// ----------------------------------------------------------------
|
|
|
|
// osu!standard accuracy calculator
|
|
//
|
|
// if percent and nobjects are specified, n300, n100 and n50 will
|
|
// be automatically calculated to be the closest to the given
|
|
// acc percent
|
|
|
|
function std_accuracy(values) {
|
|
this.nmiss = values.nmiss || 0;
|
|
|
|
if (values.n300 === undefined) {
|
|
this.n300 = -1;
|
|
} else {
|
|
this.n300 = values.n300;
|
|
}
|
|
|
|
this.n100 = values.n100 || 0;
|
|
this.n50 = values.n50 || 0;
|
|
|
|
var nobjects;
|
|
|
|
if (values.nobjects) {
|
|
var n300 = this.n300;
|
|
nobjects = values.nobjects;
|
|
var hitcount;
|
|
|
|
if (n300 < 0) {
|
|
n300 = Math.max(0, nobjects - this.n100 - this.n50 - this.nmiss);
|
|
}
|
|
|
|
hitcount = n300 + this.n100 + this.n50 + this.nmiss;
|
|
|
|
if (hitcount > nobjects) {
|
|
n300 -= Math.min(n300, hitcount - nobjects);
|
|
}
|
|
|
|
hitcount = n300 + this.n100 + this.n50 + this.nmiss;
|
|
|
|
if (hitcount > nobjects) {
|
|
this.n100 -= Math.min(this.n100, hitcount - nobjects);
|
|
}
|
|
|
|
hitcount = n300 + this.n100 + this.n50 + this.nmiss;
|
|
|
|
if (hitcount > nobjects) {
|
|
this.n50 -= Math.min(this.n50, hitcount - nobjects);
|
|
}
|
|
|
|
hitcount = n300 + this.n100 + this.n50 + this.nmiss;
|
|
|
|
if (hitcount > nobjects) {
|
|
this.nmiss -= Math.min(this.nmiss, hitcount - nobjects);
|
|
}
|
|
|
|
this.n300 = nobjects - this.n100 - this.n50 - this.nmiss;
|
|
}
|
|
|
|
if (values.percent !== undefined) {
|
|
nobjects = values.nobjects;
|
|
if (nobjects === undefined) {
|
|
throw new TypeError("nobjects is required when specifying percent");
|
|
}
|
|
|
|
var max300 = nobjects - this.nmiss;
|
|
|
|
var maxacc = new std_accuracy({
|
|
n300: max300, n100: 0, n50: 0, nmiss: this.nmiss
|
|
}).value() * 100.0;
|
|
|
|
var acc_percent = values.percent;
|
|
acc_percent = Math.max(0.0, Math.min(maxacc, acc_percent));
|
|
|
|
// just some black magic maths from wolfram alpha
|
|
|
|
this.n100 = Math.round(
|
|
-3.0 * ((acc_percent * 0.01 - 1.0) * nobjects + this.nmiss) * 0.5
|
|
);
|
|
|
|
if (this.n100 > max300) {
|
|
// acc lower than all 100s, use 50s
|
|
this.n100 = 0;
|
|
this.n50 = Math.round(
|
|
-6.0 * ((acc_percent * 0.01 - 1.0) * nobjects + this.nmiss) * 0.5
|
|
);
|
|
this.n50 = Math.min(max300, this.n50);
|
|
}
|
|
|
|
this.n300 = nobjects - this.n100 - this.n50 - this.nmiss;
|
|
}
|
|
}
|
|
|
|
// computes the accuracy value (0.0-1.0)
|
|
//
|
|
// if n300 was specified in the constructor, nobjects is not
|
|
// required and will be automatically computed
|
|
|
|
std_accuracy.prototype.value = function(nobjects) {
|
|
var n300 = this.n300;
|
|
if (n300 < 0) {
|
|
if (!nobjects) {
|
|
throw new TypeError("either n300 or nobjects must be specified");
|
|
}
|
|
n300 = nobjects - this.n100 - this.n50 - this.nmiss;
|
|
} else {
|
|
nobjects = n300 + this.n100 + this.n50 + this.nmiss;
|
|
}
|
|
var res = (
|
|
(n300 * 300.0 + this.n100 * 100.0 + this.n50 * 50.0) /
|
|
(nobjects * 300.0)
|
|
);
|
|
return Math.max(0, Math.min(res, 1.0));
|
|
};
|
|
|
|
std_accuracy.prototype.toString = function() {
|
|
return (
|
|
(this.value() * 100.0).toFixed(2) + "% "
|
|
+ this.n100 + "x100 " + this.n50 + "x50 "
|
|
+ this.nmiss + "xmiss"
|
|
);
|
|
}
|
|
|
|
// osu! standard ppv2 calculator
|
|
|
|
function std_ppv2() {
|
|
this.aim = 0.0;
|
|
this.speed = 0.0;
|
|
this.acc = 0.0;
|
|
|
|
// accuracy used in the last calc() call
|
|
|
|
this.computed_accuracy = undefined;
|
|
}
|
|
|
|
// metaparams:
|
|
// map, stars, acc_percent
|
|
//
|
|
// params:
|
|
// aim_stars, speed_stars, max_combo, nsliders, ncircles,
|
|
// nobjects, base_ar = 5, base_od = 5, mode = modes.std,
|
|
// mods = modbits.nomod, combo = max_combo - nmiss,
|
|
// n300 = nobjects - n100 - n50 - nmiss, n100 = 0, n50 = 0,
|
|
// nmiss = 0, score_version = 1
|
|
//
|
|
// if stars is defined, map and mods are obtained from stars as
|
|
// well as aim_stars and speed_stars
|
|
//
|
|
// if map is defined, max_combo, nsliders, ncircles, nobjects,
|
|
// base_ar, base_od will be obtained from this beatmap
|
|
//
|
|
// if map is defined and stars is not defined, a new difficulty
|
|
// calculator will be created on the fly to compute stars for map
|
|
//
|
|
// if acc_percent is defined, n300, n100, n50 will be automatically
|
|
// calculated to be as close as possible to this value
|
|
|
|
std_ppv2.prototype.calc = function(params) {
|
|
// parameters handling
|
|
|
|
var stars = params.stars;
|
|
var map = params.map;
|
|
var max_combo, nsliders, ncircles, nobjects, base_ar, base_od;
|
|
var mods;
|
|
var aim_stars, speed_stars;
|
|
|
|
if (stars) {
|
|
map = stars.map;
|
|
}
|
|
|
|
if (map) {
|
|
max_combo = map.max_combo();
|
|
nsliders = map.nsliders;
|
|
ncircles = map.ncircles;
|
|
nobjects = map.objects.length;
|
|
base_ar = map.ar;
|
|
base_od = map.od;
|
|
|
|
if (!stars) {
|
|
stars = new std_diff().calc(params);
|
|
}
|
|
} else {
|
|
max_combo = params.max_combo;
|
|
if (!max_combo || max_combo < 0) {
|
|
throw new TypeError("max_combo must be > 0");
|
|
}
|
|
|
|
nsliders = params.nsliders;
|
|
ncircles = params.ncircles;
|
|
nobjects = params.nobjects;
|
|
if ([nsliders, ncircles, nobjects].some(isNaN)) {
|
|
throw new TypeError(
|
|
"nsliders, ncircles, nobjects are required (must be numbers) "
|
|
);
|
|
}
|
|
if (nobjects < nsliders + ncircles) {
|
|
throw new TypeError(
|
|
"nobjects must be >= nsliders + ncircles"
|
|
);
|
|
}
|
|
|
|
base_ar = params.base_ar;
|
|
if (isUndefined(base_ar)) base_ar = 5;
|
|
base_od = params.base_od;
|
|
if (isUndefined(base_od)) base_od = 5;
|
|
}
|
|
|
|
if (stars) {
|
|
mods = stars.mods;
|
|
aim_stars = stars.aim;
|
|
speed_stars = stars.speed;
|
|
} else {
|
|
mods = params.mods || modbits.nomod;
|
|
aim_stars = params.aim_stars;
|
|
speed_stars = params.speed_stars;
|
|
}
|
|
|
|
if ([aim_stars, speed_stars].some(isNaN)) {
|
|
throw new TypeError("aim and speed stars required (must be numbers)");
|
|
}
|
|
|
|
var nmiss = params.nmiss || 0;
|
|
var n50 = params.n50 || 0;
|
|
var n100 = params.n100 || 0;
|
|
|
|
var n300 = params.n300;
|
|
if (n300 === undefined) {
|
|
n300 = nobjects - n100 - n50 - nmiss;
|
|
}
|
|
|
|
var combo = params.combo;
|
|
if (combo === undefined) {
|
|
combo = max_combo - nmiss;
|
|
}
|
|
|
|
var score_version = params.score_version || 1;
|
|
|
|
// common values used in all pp calculations
|
|
|
|
var nobjects_over_2k = nobjects / 2000.0;
|
|
var length_bonus = 0.95 + 0.4 * Math.min(1.0, nobjects_over_2k);
|
|
|
|
if (nobjects > 2000) {
|
|
length_bonus += Math.log10(nobjects_over_2k) * 0.5;
|
|
}
|
|
|
|
var combo_break = Math.pow(combo, 0.8) / Math.pow(max_combo, 0.8);
|
|
var mapstats = (
|
|
new std_beatmap_stats({ar: base_ar, od: base_od}).with_mods(mods)
|
|
);
|
|
|
|
this.computed_accuracy = new std_accuracy({
|
|
percent: params.acc_percent,
|
|
nobjects: nobjects,
|
|
n300: n300, n100: n100, n50: n50, nmiss: nmiss
|
|
});
|
|
|
|
n300 = this.computed_accuracy.n300;
|
|
n100 = this.computed_accuracy.n100;
|
|
n50 = this.computed_accuracy.n50;
|
|
|
|
var doubletap_penalty = Math.pow(0.98, n50 < nobjects / 500.0 ? 0 : n50 - nobjects / 500.0);
|
|
|
|
var accuracy = this.computed_accuracy.value();
|
|
|
|
// high/low ar bonus
|
|
|
|
var ar_bonus = 0.0;
|
|
if (mapstats.ar > 10.33) {
|
|
ar_bonus += 0.4 * (mapstats.ar - 10.33);
|
|
} else if (mapstats.ar < 8.0) {
|
|
ar_bonus += 0.01 * (8.0 - mapstats.ar);
|
|
}
|
|
|
|
ar_bonus = 1.0 + Math.min(ar_bonus, ar_bonus * (nobjects / 1000.0));
|
|
|
|
// aim pp
|
|
|
|
var aim = this._base(aim_stars);
|
|
aim *= length_bonus;
|
|
|
|
if (nmiss > 0) {
|
|
aim *= 0.97 * Math.pow(1 - Math.pow(nmiss / nobjects, 0.775), nmiss);
|
|
}
|
|
|
|
aim *= combo_break;
|
|
aim *= ar_bonus;
|
|
|
|
var hd_bonus = 1.0;
|
|
if (mods & modbits.hd) {
|
|
hd_bonus *= 1.0 + 0.04 * (12.0 - mapstats.ar);
|
|
}
|
|
|
|
aim *= hd_bonus;
|
|
|
|
if (mods & modbits.fl) {
|
|
var fl_bonus = 1.0 + 0.35 * Math.min(1.0, nobjects / 200.0);
|
|
if (nobjects > 200) {
|
|
fl_bonus += 0.3 * Math.min(1.0, (nobjects - 200) / 300.0);
|
|
}
|
|
if (nobjects > 500) {
|
|
fl_bonus += (nobjects - 500) / 1200.0;
|
|
}
|
|
aim *= fl_bonus;
|
|
}
|
|
|
|
var acc_bonus = 0.5 + accuracy / 2.0;
|
|
var od_squared = Math.pow(mapstats.od, 2);
|
|
var od_bonus = 0.98 + od_squared / 2500.0;
|
|
|
|
aim *= acc_bonus;
|
|
aim *= od_bonus;
|
|
|
|
this.aim = aim;
|
|
|
|
// speed pp
|
|
|
|
var speed = this._base(speed_stars);
|
|
speed *= length_bonus;
|
|
|
|
if (nmiss > 0) {
|
|
speed *= 0.97 * Math.pow(1 - Math.pow(nmiss / nobjects, 0.775), Math.pow(nmiss, 0.875));
|
|
}
|
|
|
|
speed *= doubletap_penalty;
|
|
speed *= combo_break;
|
|
if (mapstats.ar > 10.33) {
|
|
speed *= ar_bonus;
|
|
}
|
|
speed *= hd_bonus;
|
|
|
|
speed *= (0.95 + od_squared / 750) * Math.pow(accuracy, (14.5 - Math.max(mapstats.od, 8)) / 2);
|
|
|
|
this.speed = speed;
|
|
|
|
// accuracy pp
|
|
//
|
|
// scorev1 ignores sliders and spinners since they are free
|
|
// 300s
|
|
|
|
var real_acc = accuracy;
|
|
|
|
switch (score_version) {
|
|
case 1:
|
|
var nspinners = nobjects - nsliders - ncircles;
|
|
real_acc = new std_accuracy({
|
|
n300: Math.max(0, n300 - nsliders - nspinners),
|
|
n100: n100,
|
|
n50: n50,
|
|
nmiss: nmiss
|
|
}).value();
|
|
real_acc = Math.max(0.0, real_acc);
|
|
break;
|
|
case 2:
|
|
ncircles = nobjects;
|
|
break;
|
|
default:
|
|
throw new {
|
|
name: "NotImplementedError",
|
|
message: "unsupported scorev" + score_version
|
|
};
|
|
}
|
|
|
|
var acc = (
|
|
Math.pow(1.52163, mapstats.od) *
|
|
Math.pow(real_acc, 24.0) * 2.83
|
|
);
|
|
|
|
acc *= Math.min(1.15, Math.pow(ncircles / 1000.0, 0.3));
|
|
if (mods & modbits.hd) acc *= 1.08;
|
|
if (mods & modbits.fl) acc *= 1.02;
|
|
|
|
this.acc = acc;
|
|
|
|
// total pp
|
|
|
|
var final_multiplier = 1.12;
|
|
if (mods & modbits.nf) final_multiplier *= Math.max(0.9, 1.0 - 0.02 * nmiss);
|
|
if (mods & modbits.so) final_multiplier *= 1.0 - Math.pow(nspinners / nobjects, 0.85);
|
|
|
|
this.total = Math.pow(
|
|
Math.pow(aim, 1.1) + Math.pow(speed, 1.1) +
|
|
Math.pow(acc, 1.1),
|
|
1.0 / 1.1
|
|
) * final_multiplier;
|
|
|
|
return this;
|
|
};
|
|
|
|
std_ppv2.prototype.toString = function() {
|
|
return (
|
|
this.total.toFixed(2) + " pp (" + this.aim.toFixed(2)
|
|
+ " aim, " + this.speed.toFixed(2) + " speed, "
|
|
+ this.acc.toFixed(2) + " acc)"
|
|
);
|
|
};
|
|
|
|
// _(internal)_ base pp value for stars
|
|
std_ppv2.prototype._base = function(stars) {
|
|
return (
|
|
Math.pow(5.0 * Math.max(1.0, stars / 0.0675) - 4.0, 3.0) / 100000.0
|
|
);
|
|
};
|
|
|
|
// generic pp calc function that figures out what calculator to use
|
|
// based on the params' mode and passes through params and
|
|
// return value for calc()
|
|
|
|
function ppv2(params) {
|
|
var mode;
|
|
if (params.map) {
|
|
mode = params.map.mode;
|
|
} else {
|
|
mode = params.mode || modes.std;
|
|
}
|
|
switch (mode) {
|
|
case modes.std:
|
|
return new std_ppv2().calc(params);
|
|
}
|
|
throw {
|
|
name: "NotImplementedError",
|
|
message: "this gamemode is not yet supported"
|
|
};
|
|
}
|
|
|
|
// exports
|
|
// ----------------------------------------------------------------
|
|
|
|
osu.timing = timing;
|
|
osu.objtypes = objtypes;
|
|
osu.circle = circle;
|
|
osu.slider = slider;
|
|
osu.hitobject = hitobject;
|
|
osu.modes = modes;
|
|
osu.beatmap = beatmap;
|
|
osu.parser = parser;
|
|
osu.modbits = modbits;
|
|
osu.std_beatmap_stats = std_beatmap_stats;
|
|
osu.std_diff_hitobject = std_diff_hitobject;
|
|
osu.std_diff = std_diff;
|
|
osu.diff = diff;
|
|
osu.std_accuracy = std_accuracy;
|
|
osu.std_ppv2 = std_ppv2;
|
|
osu.ppv2 = ppv2;
|
|
|
|
})();
|