kshoot-tools
Version:
A collection of tools related to KSH and KSON chart files of K-Shoot Mania
182 lines (181 loc) • 7.48 kB
JavaScript
import * as path from 'node:path';
import * as url from 'node:url';
import { registerFont, createCanvas } from 'canvas';
import * as kshoot from 'kshoot';
export const RadarStat = Object.freeze({
rawFromChart(chart, timing) {
const stat = kshoot.tools.stat.getStat(chart, timing);
const duration = chart.getDuration() / 1000;
const bc_jacks = [1, 2].map((lane) => stat.by_button_lane[lane].jacks).reduce((x, y) => x + y);
const adlr_jacks = [0, 3, 4, 5].map((lane) => stat.by_button_lane[lane].jacks).reduce((x, y) => x + y);
// TODO: find more accurate formulas for TRICKY and other values
return {
notes: (stat.chips + stat.holds) / duration,
peak: stat.peak_note_density,
tsumami: (stat.slant_laser_chains + stat.slams) / duration,
one_hand: stat.one_hand_notes / duration,
hand_trip: stat.wrong_side_notes / duration,
tricky: (bc_jacks + 2 * adlr_jacks + 4000 * stat.bpm_inverse_differences) / duration,
};
},
fromRaw(radar_stat) {
radar_stat.notes = (16.663 * radar_stat.notes + 38.798) / 1.5;
radar_stat.peak = (2.1644 * radar_stat.peak + 42.113) / 1.5;
radar_stat.tsumami = (20 * radar_stat.tsumami + 18) / 1.5;
radar_stat.one_hand = (64.817 * radar_stat.one_hand) / 1.5 + 10;
radar_stat.hand_trip = (168.25 * radar_stat.hand_trip) / 1.5 + 10;
radar_stat.tricky = (177.18 * radar_stat.tricky) / 1.5 + 10;
return radar_stat;
},
fromChart(chart, timing) {
return RadarStat.clamp(RadarStat.fromRaw(RadarStat.rawFromChart(chart, timing)));
},
clamp(radar_stat) {
for (const str_k in radar_stat) {
const k = str_k;
if (radar_stat[k] < 10)
radar_stat[k] = 10;
if (radar_stat[k] >= 1000)
radar_stat[k] = 1000;
}
return radar_stat;
},
createEmpty() {
return RadarStat.clamp(RadarStat.fromRaw({
notes: 0, peak: 0, tsumami: 0, one_hand: 0, hand_trip: 0, tricky: 0,
}));
},
toString(radar_stat, no_round = false) {
const arr = [];
for (const k of ['notes', 'peak', 'tsumami', 'tricky', 'hand_trip', 'one_hand']) {
const value = radar_stat[k];
arr.push(`${k}: ${no_round ? value.toFixed(3) : Math.round(value).toString()}`);
}
return `{${arr.join(', ')}}`;
},
});
const HEXAGON_DIRS = Object.freeze([
[0, -1],
[Math.sqrt(3) / 2, -1 / 2],
[Math.sqrt(3) / 2, +1 / 2],
[0, +1],
[-Math.sqrt(3) / 2, +1 / 2],
[-Math.sqrt(3) / 2, -1 / 2],
]);
export class Radar {
shape = { width: 500, height: 450, size: 150 };
stat;
difficulty;
level;
constructor(args) {
this.stat = ('stat' in args) ? args.stat : ('chart' in args) ? RadarStat.fromChart(args.chart, args.timing) : RadarStat.createEmpty();
this.difficulty = ('difficulty' in args) ? args.difficulty : ('chart' in args) ? args.chart.difficulty_id.toUpperCase() : (void 0);
this.level = ('level' in args) ? `${args.level}` : ('chart' in args) ? args.chart.meta.level.toString() : (void 0);
}
renderBackground(ctx) {
ctx.clearRect(0, 0, this.shape.width, this.shape.height);
ctx.save();
ctx.translate(this.shape.width / 2, this.shape.height / 2);
ctx.beginPath();
ctx.strokeStyle = "rgb(255, 255, 255)";
ctx.lineWidth = 5;
ctx.lineCap = "round";
ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
for (let i = 0; i < 6; ++i) {
const [dx, dy] = HEXAGON_DIRS[i];
(i === 0 ? ctx.moveTo : ctx.lineTo).call(ctx, this.shape.size * dx, this.shape.size * dy);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = "rgb(255, 255, 255)";
ctx.lineWidth = 1.5;
const half_tick_len = this.shape.size * 0.02;
for (const [dx, dy] of HEXAGON_DIRS) {
ctx.moveTo(0, 0);
ctx.lineTo(this.shape.size * dx, this.shape.size * dy);
for (let i = 1; i <= 3; ++i) {
const [ox, oy] = [i * this.shape.size / 4 * dx, i * this.shape.size / 4 * dy];
const [px, py] = [dy, -dx];
ctx.moveTo(ox - half_tick_len * px, oy - half_tick_len * py);
ctx.lineTo(ox + half_tick_len * px, oy + half_tick_len * py);
}
}
ctx.stroke();
if (this.difficulty) {
ctx.font = "72px ConcertOne";
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
ctx.fillText(this.difficulty, 0, 0);
}
if (this.level) {
ctx.font = "96px ConcertOne";
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
ctx.fillText(this.level.padStart(2, '0'), 0, -20);
}
ctx.restore();
}
toString() {
return RadarStat.toString(this.stat);
}
renderRadar(ctx) {
ctx.save();
ctx.translate(this.shape.width / 2, this.shape.height / 2);
const stats = [
this.stat.notes, this.stat.peak, this.stat.tsumami, this.stat.tricky, this.stat.hand_trip, this.stat.one_hand,
];
const gradient = ctx.createRadialGradient(0, 0, this.shape.size * 0.8, 0, 0, this.shape.size * 1.25);
gradient.addColorStop(0, "rgba(38, 121, 255, 0.7)");
gradient.addColorStop(0.75, "rgba(128, 64, 128, 0.7)");
gradient.addColorStop(1, "rgba(255, 0, 0, 0.7)");
ctx.beginPath();
ctx.fillStyle = gradient;
for (let i = 0; i < 6; ++i) {
const [dx, dy] = HEXAGON_DIRS[i];
const len = stats[i] / 100 * this.shape.size;
(i === 0 ? ctx.moveTo : ctx.lineTo).call(ctx, len * dx, len * dy);
}
ctx.fill();
ctx.restore();
}
renderForeground(ctx) {
const labels = [
["NOTES", "cyan"],
["PEAK", "red"],
["TSUMAMI", "violet"],
["TRICKY", "yellow"],
["HAND\nTRIP", "#BF46EB"],
[" ONE\nHAND", "lime"],
];
ctx.save();
ctx.translate(this.shape.width / 2, this.shape.height / 2);
ctx.font = "30px ConcertOne";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeStyle = 'black';
ctx.lineWidth = 3;
for (let i = 0; i < 6; ++i) {
const [dx, dy] = HEXAGON_DIRS[i];
const [tx, ty] = [1.35 * this.shape.size * dx, 1.35 * this.shape.size * dy];
ctx.fillStyle = labels[i][1];
ctx.strokeText(labels[i][0], tx, ty);
ctx.fillText(labels[i][0], tx, ty);
}
ctx.restore();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async render(params) {
const canvas = createCanvas(this.shape.width, this.shape.height);
const ctx = canvas.getContext('2d');
this.renderBackground(ctx);
this.renderRadar(ctx);
this.renderForeground(ctx);
return canvas.toBuffer();
}
}
const RES_DIR = url.fileURLToPath(new URL("../res/", import.meta.url));
registerFont(path.join(RES_DIR, "Concert_One/ConcertOne-Regular.ttf"), { family: "ConcertOne", style: 'regular' });