osu-pp-calculator
Version:
calculates performance points for given beatmap
211 lines (168 loc) • 6.39 kB
text/typescript
import Vec2 from './lib/vec2';
import Beatmap, {HitObject, HitObjectType} from './beatmap';
enum DifficultyType {
SPEED = 0,
AIM = 1
};
// how much strains decay per interval (if the previous interval's peak
// strains after applying decay are still higher than the current one's,
// they will be used as the peak strains).
const DECAY_BASE = [0.3, 0.15];
// almost the normalized circle diameter (104px)
const DIAMETER_APPROX = 90;
// arbitrary tresholds to determine when a stream is spaced enough that is
// becomes hard to alternate.
const STREAM_INTERVAL = 110;
const SINGLETAP_INTERVAL = 125;
// used to keep speed and aim balanced between eachother
const WEIGHT_SCALING = [1400, 26.25];
// non-normalized diameter where the circlesize buff starts
const CS_BUFF_TRESHOLD = 30;
// diffcalc hit object
class DiffCalcHitObject {
// strains start at 1
public strains: number[] = [1.0, 1.0];
// start/end positions normalized on radius
public normStart: Vec2;
public normEnd: Vec2;
constructor(public hitObject: HitObject, radius: any) {
//this.hitObject = baseObject;
// strains start at 1
this.strains = [1, 1];
// positions are normalized on circle radius so that we can calc as
// if everything was the same circlesize
let scalingFactor = 52.0 / radius;
// cs buff (based on osuElements, pretty accurate but not 100% sure)
//
// some high cs data I've collected:
//
// cs5.85 on RoR:
// 1.822916667% aim stars increase
// 2.752293578% speed stars increase
// 4.799627961% pp increase
//
// cd6.5 on defenders
// 18.143683959% pp increase
// 4.62962963% aim stars increase
// 9.039548023% speed stars increase
if (radius < CS_BUFF_TRESHOLD) {
scalingFactor *=
1 + (CS_BUFF_TRESHOLD - radius) * 0.02;
}
this.normStart = new Vec2(this.hitObject.position);
this.normStart.multiply(scalingFactor);
// ignoring slider lengths doesn't seem to affect star rating too
// much and speeds up the calculation exponentially
// actually, I believe this is how diff calc works now and slider
// lengths were dropped since osu!tp
this.normEnd = this.normStart.clone();
}
calculateStrains (prev: DiffCalcHitObject) {
this.calculateStrain(prev, DifficultyType.SPEED);
this.calculateStrain(prev, DifficultyType.AIM);
}
calculateStrain (prev: DiffCalcHitObject, diffType: DifficultyType) {
let res: number = 0;
let timeElapsed: number = this.hitObject.startTime - prev.hitObject.startTime;
let decay: number = Math.pow(DECAY_BASE[diffType], timeElapsed / 1000.0);
let scaling: number = WEIGHT_SCALING[diffType];
if (this.hitObject.type == HitObjectType.Circle ||
this.hitObject.type == HitObjectType.Slider)
res = this.spacingWeight(this.normStart.distance(prev.normEnd), diffType) * scaling;
res /= Math.max(timeElapsed, 50);
this.strains[diffType] = prev.strains[diffType] * decay + res;
}
spacingWeight (distance, diffType: DifficultyType) {
if (diffType == DifficultyType.AIM)
return Math.pow(distance, 0.99);
if (distance > SINGLETAP_INTERVAL) {
return 2.5;
}
if (distance > STREAM_INTERVAL) {
return 1.6 + 0.9 *
(distance - STREAM_INTERVAL) /
(SINGLETAP_INTERVAL - STREAM_INTERVAL);
}
if (distance > DIAMETER_APPROX) {
return 1.2 + 0.4 * (distance - DIAMETER_APPROX) /
(STREAM_INTERVAL - DIAMETER_APPROX);
}
if (distance > DIAMETER_APPROX / 2.0) {
return 0.95 + 0.25 *
(distance - DIAMETER_APPROX / 2.0) /
(DIAMETER_APPROX / 2.0);
}
return 0.95;
}
};
const STAR_SCALING_FACTOR = 0.0675;
const EXTREME_SCALING_FACTOR = 0.5;
const PLAYFIELD_WIDTH = 512; // in osu!pixels
// strains are calculated by analyzing the map in chunks and then taking the
// peak strains in each chunk.
// this is the length of a strain interval in milliseconds.
const STRAIN_STEP = 400;
// max strains are weighted from highest to lowest, and this is how much the
// weight decays.
const DECAY_WEIGHT = 0.9;
const calculateDifficulty = (objects: DiffCalcHitObject[], type: DifficultyType): number => {
let highestStrains: number[] = [];
let intervalEnd: number = STRAIN_STEP;
let maxStrain: number = 0.0;
let prev: DiffCalcHitObject;
for (let i = 0; i < objects.length; i++) {
let obj: DiffCalcHitObject = objects[i];
// make previous peak strain decay until the current object
while (obj.hitObject.startTime > intervalEnd) {
highestStrains.push(maxStrain);
if (!prev) {
maxStrain = 0.0;
} else {
let decay = Math.pow(DECAY_BASE[type],
(intervalEnd - prev.hitObject.startTime) / 1000.0);
maxStrain = prev.strains[type] * decay;
}
intervalEnd += STRAIN_STEP;
}
// calculate max strain for this interval
maxStrain = Math.max(maxStrain, obj.strains[type]);
prev = obj;
}
highestStrains.push(maxStrain);
// sort strains from greatest to lowest
highestStrains.sort((a, b) => b - a);
return highestStrains.reduce((prev, curr, idx) => prev + curr * Math.pow(DECAY_WEIGHT, idx), 0);
}
export interface BeatmapDifficulty {
aim: number;
speed: number;
stars: number;
}
namespace DifficultyCalculator {
export function calculate(beatmap: Beatmap): BeatmapDifficulty {
if (beatmap.mode != 0)
throw new Error("This gamemode is not supported");
const circleRadius: number = (PLAYFIELD_WIDTH / 16) * (1 - 0.7 *
(beatmap.circleSize - 5) / 5);
let objects: DiffCalcHitObject[] = [];
for (let i = 0; i < beatmap.hitObjects.length; i++) {
objects[i] = new DiffCalcHitObject(beatmap.hitObjects[i], circleRadius);
}
let prev: DiffCalcHitObject = objects[0];
for (let i = 1; i < objects.length; i++) {
let o = objects[i];
o.calculateStrains(prev);
prev = o;
}
let aim: number = calculateDifficulty(objects, DifficultyType.AIM);
let speed: number = calculateDifficulty(objects, DifficultyType.SPEED);
aim = Math.sqrt(aim) * STAR_SCALING_FACTOR;
speed = Math.sqrt(speed) * STAR_SCALING_FACTOR;
let stars = aim + speed +
Math.abs(speed - aim) * EXTREME_SCALING_FACTOR;
return {
aim, speed, stars
};
}
}
export default DifficultyCalculator;