UNPKG

kshoot-tools

Version:

A collection of tools related to KSH and KSON chart files of K-Shoot Mania

207 lines (206 loc) 8.42 kB
import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import AdmZip from 'adm-zip'; import * as kshoot from 'kshoot'; export * as kshoot from 'kshoot'; import { Radar } from "./radar.js"; export * as radar from "./radar.js"; import { Renderer } from "./render.js"; export * as render from "./render.js"; export * as npy from "./npy.js"; export { OffsetComputer, CrossCorrelation, computeOffset, computeCrossCorrelation } from "./offset-computer.js"; export class KShootChartContext { context; file_path = ""; file_name = ""; chart; timing; constructor(context) { this.context = context; } set(source_path, chart_data) { this.file_path = source_path; this.file_name = path.basename(source_path); const chart = kshoot.parse((typeof chart_data === 'string') ? chart_data : chart_data.toString('utf-8')); const timing = chart.getTiming(); return Object.assign(this, { chart, timing }); } async load(file_path) { return this.set(file_path, await fs.readFile(file_path)); } async resolve(res_path) { return this.context.resolve(this.file_path, res_path); } toString() { if (!this.chart) return `[Invalid chart from "${this.file_name}"]`; return `[Chart "${this.chart.meta.title.trim()}" (${this.chart.difficulty_id} ${this.chart.meta.level}) from "${this.file_name}"]`; } getStatDescription() { const stat = kshoot.tools.stat.getStat(this.chart); return ` ${this.toString()} - Duration: ${(this.chart.getDuration() / 1000).toFixed(3)} s - BPM: ${this.chart.meta.disp_bpm} (median: ${this.chart.getMedianBPM()}) - notes: ${stat.buttons} (${stat.chips} chips + ${stat.holds} holds) - chains: ${stat.button_chains} (${stat.chips} chips + ${stat.hold_chains} holds) - peak density: ${stat.peak_note_density} (measure ${this.timing.getMeasureInfoByPulse(stat.peak_note_density_range[0]).idx + 1n}) - lasers: ${stat.slant_laser_chains + stat.slams} (${stat.slant_laser_chains} slants + ${stat.slams} slams) - one hand: ${stat.one_hand_notes} - hand trip: ${stat.wrong_side_notes} - jacks: ${stat.jacks} (BC: ${[1, 2].map((lane) => stat.by_button_lane[lane].jacks).reduce((x, y) => x + y)}, ADLR: ${[0, 3, 4, 5].map((lane) => stat.by_button_lane[lane].jacks).reduce((x, y) => x + y)}) - sofulan: ${stat.bpm_differences.toFixed(1)} (${stat.bpm_changes} BPM changes) `.split('\n').map((line) => line.trim()).join('\n').trim(); } } export class KShootTools { dir_path = ""; zip_archive = null; charts = []; reset() { this.dir_path = ""; this.zip_archive = null; this.charts = []; } async load(params) { switch (params.type) { case 'path': { const stat = await fs.stat(params.file_or_dir_path); if (stat.isFile()) await this.loadFiles({ type: 'files', file_paths: [params.file_or_dir_path] }); else if (stat.isDirectory()) await this.loadDirectory({ type: 'directory', dir_path: params.file_or_dir_path }); break; } case 'files': await this.loadFiles(params); break; case 'archive': await this.loadArchiveFile(params); break; case 'set': this.setData(params); break; case 'directory': await this.loadDirectory(params); break; } } async resolve(chart_path, res_path) { if (this.zip_archive) { res_path = path.posix.join(path.posix.dirname(chart_path), res_path); const entry = this.zip_archive.getEntry(res_path); if (!entry) return null; return entry.getData(); } res_path = path.join(path.dirname(chart_path), res_path); // Check whether the resource is located outside of the chart folder. const relative_res_path = path.relative(this.dir_path, res_path); if (!relative_res_path || relative_res_path.startsWith('..') || path.isAbsolute(relative_res_path)) return null; return await fs.readFile(res_path); } sort() { this.charts.sort((ctx_a, ctx_b) => { if (ctx_a.chart.meta.difficulty !== ctx_b.chart.meta.difficulty) { return ctx_a.chart.meta.difficulty < ctx_b.chart.meta.difficulty ? -1 : +1; } return ctx_a.chart.meta.level - ctx_b.chart.meta.level; }); } async loadFiles(params) { this.reset(); if (params.file_paths.length === 0) return; let first_path = true; for (const file_path of params.file_paths) { const file_dir_path = path.dirname(file_path); if (first_path) { this.dir_path = file_dir_path; first_path = false; } else if (this.dir_path !== file_dir_path) { throw new Error(`Multiple chart files in different directories are not supported!`); } } await Promise.all(params.file_paths.map(async (file_path) => { const chart = new KShootChartContext(this); this.charts.push(await chart.load(file_path)); })); this.sort(); } async loadArchiveFile(params) { this.reset(); let zip_archive; if (params.data) zip_archive = new AdmZip(params.data, { readEntries: true }); else if (params.file_path) zip_archive = new AdmZip(params.file_path, { readEntries: true }); else return; this.zip_archive = zip_archive; const entries = zip_archive.getEntries(); for (const entry of entries) { if (entry.isDirectory) continue; if (entry.entryName.endsWith('.ksh') || entry.entryName.endsWith('.kson')) { const chart = new KShootChartContext(this); this.charts.push(chart.set(entry.entryName, entry.getData())); } } this.sort(); } setData(params) { this.reset(); for (const source_path in params.data) { const chart = new KShootChartContext(this); this.charts.push(chart.set(source_path, params.data[source_path])); } } async loadDirectory(params) { this.reset(); const file_paths = []; for (const file_path of await fs.readdir(params.dir_path)) { const ext = path.extname(file_path).toLowerCase(); if (ext === '.ksh' || ext === '.kson') { file_paths.push(path.join(params.dir_path, file_path)); } } await this.loadFiles({ type: 'files', file_paths }); } getSummary() { const lines = [ `${this.charts.length} chart${this.charts.length === 1 ? '' : 's'} from "${this.zip_archive ? 'a zip archive' : this.dir_path}"` ]; for (const chart_ctx of this.charts) lines.push(`- ${chart_ctx.toString()}`); return lines.join('\n'); } getStatDescriptions() { const stats = []; for (const chart_ctx of this.charts) { stats.push(chart_ctx.getStatDescription()); } return stats; } getRadars() { return this.charts.map((chart_ctx) => new Radar(chart_ctx)); } getRenderers() { return this.charts.map((chart_ctx) => new Renderer(chart_ctx)); } async save(out_dir, data, pathMapOrExt) { const pathMap = (typeof pathMapOrExt === 'string') ? (chart_ctx) => `${chart_ctx.file_name}.${pathMapOrExt}` : pathMapOrExt; await Promise.all(data.map(async (data, ind) => { if (data == null || !pathMap) return; const dir_path = out_dir || this.dir_path || process.cwd(); const file_path = path.join(dir_path, pathMap(this.charts[ind])); if (typeof data === 'string') await fs.writeFile(file_path, data, 'utf-8'); else await fs.writeFile(file_path, data); })); } }