UNPKG

kshoot-tools

Version:

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

330 lines (329 loc) 13.4 kB
import * as path from 'node:path'; import * as url from 'node:url'; import { createCanvas, registerFont } from 'canvas'; import * as kshoot from 'kshoot'; var LayerInd; (function (LayerInd) { LayerInd[LayerInd["FXLong"] = 0] = "FXLong"; LayerInd[LayerInd["BTLong"] = 1] = "BTLong"; LayerInd[LayerInd["FXShort"] = 2] = "FXShort"; LayerInd[LayerInd["Laser"] = 3] = "Laser"; LayerInd[LayerInd["BTShort"] = 4] = "BTShort"; LayerInd[LayerInd["Measure"] = 5] = "Measure"; LayerInd[LayerInd["Lane"] = 6] = "Lane"; LayerInd[LayerInd["MAX"] = 7] = "MAX"; })(LayerInd || (LayerInd = {})); export class Column { width = 131; height = 769; chart; timing; range = [0n, 4n * kshoot.PULSES_PER_WHOLE]; layers = []; constructor(renderer, range) { this.chart = renderer.chart; this.timing = renderer.timing; if (range) { this.range = range; } this.height = Math.ceil(Number(this.range[1] - this.range[0]) / 5) + 1; for (let i = 0; i < LayerInd.MAX; ++i) { const canvas = createCanvas(this.width, this.height); this.layers.push([canvas, canvas.getContext('2d')]); } this.drawLane(); } reset(new_range) { const resize_canvas = new_range[1] - new_range[0] !== this.range[1] - this.range[0]; this.range = new_range; if (resize_canvas) { this.height = Math.ceil(Number(this.range[1] - this.range[0]) / 5) + 1; for (let i = 0; i < LayerInd.MAX; ++i) { const canvas = this.layers[i][0]; canvas.height = this.height; } this.drawLane(); } } drawLane() { const ctx = this.layers[LayerInd.Lane][1]; ctx.clearRect(0, 0, this.width, this.height); ctx.beginPath(); ctx.lineWidth = 1; ctx.strokeStyle = "rgba(214, 214, 214, 0.5)"; for (let i = 0; i <= 4; ++i) { ctx.moveTo(45.5 + i * 10, 0); ctx.lineTo(45.5 + i * 10, this.height); } ctx.stroke(); } drawMeasures(column_index) { const ctx = this.layers[LayerInd.Measure][1]; ctx.clearRect(0, 0, this.width, this.height); ctx.beginPath(); ctx.lineWidth = 1; ctx.strokeStyle = "rgb(56, 56, 56)"; const measure_lines = []; for (const [pulse, measure] of this.timing.measures(this.range)) { measure_lines.push([pulse, measure.idx]); if (pulse + measure.length === this.range[1]) measure_lines.push([pulse + measure.length, measure.idx + 1n]); for (let beat_pulse = measure.beat_length; beat_pulse < measure.length; beat_pulse += measure.beat_length) { const y = this.height - Number(pulse + beat_pulse - this.range[0]) / 5 - 0.5; ctx.moveTo(45.5, y); ctx.lineTo(84.5, y); } } ctx.stroke(); ctx.beginPath(); ctx.strokeStyle = "rgb(255, 255, 0)"; ctx.fillStyle = 'rgb(255, 255, 255)'; ctx.font = "bold 10px monospace"; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; for (const [pulse, idx] of measure_lines) { const y = this.height - Number(pulse - this.range[0]) / 5 - 0.5; ctx.moveTo(45.5, y); ctx.lineTo(84.5, y); ctx.fillText(`${idx + 1n}`, 42, y); } ctx.stroke(); ctx.fillStyle = 'rgb(0, 255, 0)'; ctx.textAlign = 'left'; const start_bpm_pulse = this.timing.bpm_by_pulse.nextLowerKey(this.range[0]) ?? 0n; for (const [pulse, bpm_info] of this.timing.bpm_by_pulse.entries(start_bpm_pulse)) { const y = this.height - Number((column_index === 0 && pulse < this.range[0] ? this.range[0] : pulse) - this.range[0]) / 5 - 0.5; const bpm = bpm_info.bpm; ctx.fillText(`${bpm === Math.floor(bpm) ? bpm.toString() : bpm.toFixed(2)}`, 88, y); } } drawButtonNote(pulse, button) { const layer_ind = button.lane < 4 ? (button.length === 0n ? LayerInd.BTShort : LayerInd.BTLong) : (button.length === 0n ? LayerInd.FXShort : LayerInd.FXLong); const ctx = this.layers[layer_ind][1]; const lane = button.lane; const x = 45 + 10 * (lane >= 4 ? (lane - 4) * 2 : lane); const y = this.height - Number(pulse - this.range[0]) / 5 - 1; ctx.save(); ctx.translate(x, y); ctx.beginPath(); if (button.length > 0) { const height = Number(button.length) / 5; if (lane < 4) { ctx.fillStyle = "rgb(201, 216, 237)"; ctx.fillRect(1, -height, 9, height); } else { ctx.fillStyle = "rgb(197, 107, 23)"; ctx.fillRect(1, -height, 19, height); } } else { if (lane < 4) { ctx.lineWidth = 1; ctx.strokeStyle = "#808080"; ctx.fillStyle = "#FFFFFF"; ctx.fillRect(1, -3, 9, 3); ctx.strokeRect(1.5, -2.5, 8, 2); } else { ctx.lineWidth = 1; ctx.strokeStyle = "rgb(136, 98, 0)"; ctx.fillStyle = "rgb(244, 129, 28)"; ctx.fillRect(1, -5, 19, 5); ctx.strokeRect(1.5, -4.5, 18, 4); } } ctx.fill(); ctx.stroke(); ctx.restore(); } drawButtonNotes() { for (const layer_ind of [LayerInd.FXLong, LayerInd.BTLong, LayerInd.FXShort, LayerInd.BTShort]) { this.layers[layer_ind][1].clearRect(0, 0, this.width, this.height); } for (const [pulse, notes] of this.chart.buttonNotes(this.range)) { for (const note of notes) { this.drawButtonNote(pulse, note); } } } drawLaserNote(pulse, laser) { const LASER_WIDTH = 9; const SLAM_HEIGHT = 6; const END_HEIGHT = 4; const ctx = this.layers[LayerInd.Laser][1]; const y = this.height - Number(pulse - this.range[0]) / 5 - 1; const color = ["rgba(0, 143, 180, 0.8)", "rgba(240, 8, 175, 0.8)"][laser.lane]; const height = Number(laser.length) / 5; const is_slam = laser.v[0] !== laser.v[1]; const is_head = pulse === laser.section_pulse; const getPos = (v) => { v = v * laser.width - laser.width / 2; return (v + 0.5) * 50; }; const pos_v0 = getPos(laser.v[0]); const pos_v1 = getPos(laser.v[1]); const pos_ve = getPos(laser.ve); ctx.save(); ctx.translate(36, y); if (is_head) { ctx.save(); ctx.translate(pos_v0, 0); ctx.beginPath(); ctx.fillStyle = color; ctx.fillRect(0, 0, LASER_WIDTH, 5); ctx.fill(); ctx.fillStyle = "rgba(255, 255, 255, 1.0)"; ctx.fillRect(1, 0, LASER_WIDTH - 2, 5); ctx.fill(); ctx.restore(); } // Coordinates for main slant const sx = pos_v1; let sy = 0; // Start drawing the laser ctx.beginPath(); ctx.fillStyle = color; // Draw slam head if (is_slam) { sy -= SLAM_HEIGHT; if (laser.v[0] < laser.v[1]) { ctx.moveTo(sx + LASER_WIDTH, sy); ctx.lineTo(pos_v1 + LASER_WIDTH, 0); ctx.lineTo(pos_v0, 0); ctx.lineTo(pos_v0, sy); } else { ctx.moveTo(sx + LASER_WIDTH, sy); ctx.lineTo(pos_v0 + LASER_WIDTH, sy); ctx.lineTo(pos_v0 + LASER_WIDTH, 0); ctx.lineTo(pos_v1, 0); } } ctx.lineTo(sx, sy); const [ex, ey] = [pos_ve, height <= 0 ? sy - END_HEIGHT : -height]; // Draw laser body ((sx, sy) - (ex, ey)) if (sx === ex || laser.curve[0] === laser.curve[1]) { // Straight laser ctx.lineTo(ex, ey); ctx.lineTo(ex + LASER_WIDTH, ey); ctx.lineTo(sx + LASER_WIDTH, sy); } else { // Curved laser // Quadratic bezier control point const [qx, qy] = [sx + (ex - sx) * laser.curve[0], sy + (ey - sy) * laser.curve[1]]; // Convert it to the cubic bezier control point const [c1x, c1y] = [sx + (2 / 3) * (qx - sx), sy + (2 / 3) * (qy - sy)]; const [c2x, c2y] = [ex + (2 / 3) * (qx - ex), ey + (2 / 3) * (qy - ey)]; ctx.bezierCurveTo(c1x, c1y, c2x, c2y, ex, ey); ctx.lineTo(ex + LASER_WIDTH, ey); ctx.bezierCurveTo(c2x + LASER_WIDTH, c2y, c1x + LASER_WIDTH, c1y, sx + LASER_WIDTH, sy); } ctx.closePath(); ctx.fill(); ctx.restore(); } drawLaserNotes() { const ctx = this.layers[LayerInd.Laser][1]; ctx.clearRect(0, 0, this.width, this.height); ctx.globalCompositeOperation = 'screen'; for (const [pulse, lasers] of this.chart.laserNotes(this.range)) { for (const laser of lasers) { this.drawLaserNote(pulse, laser); } } } render(column_index, target, x, y) { this.drawMeasures(column_index); this.drawButtonNotes(); this.drawLaserNotes(); for (let i = 0; i < LayerInd.MAX; ++i) { switch (i) { case LayerInd.Measure: case LayerInd.Laser: target.globalCompositeOperation = 'screen'; break; default: target.globalCompositeOperation = 'source-over'; } target.drawImage(this.layers[i][0], x, y); } } } export class Renderer { chart; timing; constructor(args) { this.chart = args.chart; this.timing = args.timing ?? this.chart.getTiming(); } chooseStart(columns, pulses_per_column) { const visible_pulses = pulses_per_column * BigInt(columns); const [chart_begin, chart_end] = [this.chart.getFirstNotePulse(), this.chart.getLastNotePulse()]; // Show the entire chart if possible const chart_begin_measure = this.timing.getMeasureInfoByPulse(chart_begin); if (chart_end <= visible_pulses) return 0n; if (chart_end <= chart_begin_measure.pulse + visible_pulses) return chart_begin_measure.pulse; const stat = kshoot.tools.stat.getButtonOnlyStat(this.chart, this.timing); const [highlight_begin, highlight_end] = stat.peak_note_density_range; // Put the highlight in a later part of the visible chart let init_candidate = (highlight_begin + highlight_end) / 2n - visible_pulses / 2n; if (visible_pulses > highlight_end - highlight_begin) init_candidate -= (visible_pulses - (highlight_end - highlight_begin)) / 3n; let candidate = this.timing.getMeasureInfoByPulse(init_candidate >= 0n ? init_candidate : 0n); if (candidate.pulse <= chart_begin && chart_end <= candidate.pulse + visible_pulses) { return candidate.pulse; } while (candidate.pulse > 0n && candidate.pulse + visible_pulses > chart_end) { const prev_candidate = this.timing.getMeasureInfoByIdx(candidate.idx - 1n); if (prev_candidate.pulse + visible_pulses <= chart_end) break; candidate = prev_candidate; } while (candidate.pulse < chart_begin) { const next_candidate = this.timing.getMeasureInfoByIdx(candidate.idx + 1n); if (chart_begin < next_candidate.pulse) break; candidate = next_candidate; } return candidate.pulse; } renderButton(ctx, length) { if (length === 0) { ctx.beginPath(); ctx.fillRect(0, -3, 9, 3); ctx.strokeRect(0, -3, 9, 3); ctx.fill(); ctx.stroke(); return; } } async render(params) { const max_columns = params.max_columns ?? 9; const pulses_per_column = params.pulses_per_column ?? kshoot.PULSES_PER_WHOLE * 4n; if (params.start == null) { params.start = this.chooseStart(max_columns, pulses_per_column); } const canvas = createCanvas(max_columns * 131, Math.ceil(Number(pulses_per_column) / 5) + 20); const ctx = canvas.getContext('2d'); ctx.fillStyle = 'black'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fill(); const column = new Column(this); const offset = params.start ?? 0n; for (let i = 0; i < max_columns; ++i) { const tick_begin = offset + BigInt(i) * pulses_per_column; const tick_end = tick_begin + pulses_per_column; column.reset([tick_begin, tick_end]); column.render(i, ctx, i * column.width, 12); } 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' });