kt-template-online-api/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts

612 lines
18 KiB
TypeScript

import { Canvas, Image, loadImage } from 'skia-canvas';
import type { CanvasRenderingContext2D } from 'skia-canvas';
import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils';
import {
BangDreamLocalAssetKey,
getBangDreamAssetPath,
} from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest';
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
import {
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS,
BANGDREAM_SONG_CHART_PREVIEW_SPEC,
BestdoriNote,
createSongChartPreviewModel,
isSongChartCountLineNoteType,
PreviewLayout,
PreviewNote,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec';
interface BestdoriPreviewPayload {
id: number;
title?: string;
artist?: string;
author?: string;
diff: string;
level: number;
cover: string | Buffer;
}
const NOTE_IMAGE_KEYS = [
'Single',
'SingleOff',
'Flick',
'FlickTop',
'Skill',
'Long',
'Tick',
'Sim',
'LeftArrow',
'LeftArrowEnd',
'RightArrow',
'RightArrowEnd',
] as const;
const NOTE_IMAGE_ASSET_KEYS: Record<
(typeof NOTE_IMAGE_KEYS)[number],
BangDreamLocalAssetKey
> = {
Flick: 'songChartNoteFlick',
FlickTop: 'songChartNoteFlickTop',
LeftArrow: 'songChartNoteLeftArrow',
LeftArrowEnd: 'songChartNoteLeftArrowEnd',
Long: 'songChartNoteLong',
RightArrow: 'songChartNoteRightArrow',
RightArrowEnd: 'songChartNoteRightArrowEnd',
Sim: 'songChartNoteSim',
Single: 'songChartNoteSingle',
SingleOff: 'songChartNoteSingleOff',
Skill: 'songChartNoteSkill',
Tick: 'songChartNoteTick',
};
/**
* 在图片布局层中加载谱面预览所需音符贴图。
*
* @returns 异步处理结果。
*/
async function loadNoteImages(): Promise<Record<string, Image>> {
const entries = await Promise.all(
NOTE_IMAGE_KEYS.map(async (key) => [
key,
await loadImageFromPath(
getBangDreamAssetPath(NOTE_IMAGE_ASSET_KEYS[key]),
),
]),
);
return Object.fromEntries(entries);
}
/**
* 在图片布局层中加载谱面预览封面图。
*
* @param cover - cover参数。
* @returns 异步处理结果。
*/
async function loadCoverImage(cover: string | Buffer): Promise<Image> {
try {
return await loadImage(cover);
} catch {
return await loadImageFromPath(getBangDreamAssetPath('songChartJacket'));
}
}
/**
* 在图片布局层中设置Adaptive文本Baseline。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param fontSize - fontSize参数。
* @param y - 纵向绘制坐标。
*/
function setAdaptiveTextBaseline(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
fontSize: number,
y: number,
): void {
if (y <= fontSize / 2) {
ctx.textBaseline = 'top';
} else if (y >= layout.height - fontSize / 2) {
ctx.textBaseline = 'bottom';
} else {
ctx.textBaseline = 'middle';
}
}
/**
* 在图片布局层中获取时间Position。
*
* @param layout - 布局参数。
* @param time - 谱面时间点。
*/
function getTimePosition(layout: PreviewLayout, time: number) {
const drawCol = Math.floor(time / layout.secondsPerCol);
const x =
layout.infoAreaWidth +
drawCol * layout.originalWidth +
layout.blockDistance;
const y = layout.height - ((time * layout.heightPerSecond) % layout.height);
return { drawCol, x, y };
}
/**
* 在图片布局层中绘制基础Info。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param payload - 渲染或请求负载。
* @param coverImg - coverImg参数。
*/
function drawBaseInfo(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
payload: BestdoriPreviewPayload,
coverImg: Image,
): void {
const { id, diff, level } = payload;
ctx.save();
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartBackground;
ctx.fillRect(0, 0, layout.width, layout.height);
ctx.restore();
ctx.drawImage(
coverImg,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset,
layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset,
layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset,
);
ctx.save();
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartPanel;
ctx.fillRect(
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.xOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.yOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.width,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.height,
);
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
ctx.font = `${BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.fontSize}px "${BANGDREAM_RENDER_THEME.font.chart}"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
`${id}`,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.textXOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.textYOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.maxWidth,
);
ctx.restore();
const coverWidth =
layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset;
ctx.save();
ctx.fillStyle =
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS[diff] ??
BANGDREAM_RENDER_THEME.color.chartDifficultyFallback;
ctx.fillRect(
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
coverWidth +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.xFromCoverRight,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
coverWidth +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.yFromCoverBottom,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.width,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.height,
);
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
ctx.font = `${BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.fontSize}px "${BANGDREAM_RENDER_THEME.font.chart}"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
`${diff} ${level}`,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
coverWidth +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.textXFromCoverRight,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
coverWidth +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel
.textYOffsetFromCoverBottom,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.maxWidth,
);
ctx.restore();
}
/**
* 在图片布局层中绘制Tracks。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
*/
function drawTracks(ctx: CanvasRenderingContext2D, layout: PreviewLayout): void {
for (let i = 0; i < layout.colCount; i++) {
ctx.save();
const x =
layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance;
const w = layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount;
const grd = ctx.createLinearGradient(
x,
0,
x + layout.splitLineWidth * 2,
0,
);
BANGDREAM_SONG_CHART_PREVIEW_SPEC.trackGradientStops.forEach(
({ color, offset }) => {
grd.addColorStop(offset, color);
},
);
ctx.fillStyle = grd;
ctx.fillRect(
x - layout.splitLineWidth * 2,
0,
layout.splitLineWidth * 2,
layout.height,
);
ctx.fillRect(x + w, 0, layout.splitLineWidth * 2, layout.height);
for (let j = 1; j <= 6; j++) {
const splitLineX = x + layout.laneWidth * j - layout.splitLineWidth / 2;
ctx.fillRect(splitLineX, 0, layout.splitLineWidth, layout.height);
}
ctx.restore();
}
}
/**
* 在图片布局层中绘制Beat线条列表。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param notes - 谱面音符列表。
*/
function drawBeatLines(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
notes: PreviewNote[],
): void {
const bpmList = notes
.filter((item) => item.type === 'BPM')
.sort((a, b) => (a.time as number) - (b.time as number));
ctx.save();
for (let index = 0; index < bpmList.length; index++) {
const bpmNote = bpmList[index];
let beat = 0;
const previousTime = bpmList[index - 1]
? (bpmList[index - 1].time as number)
: 0;
const nextTime = bpmList[index + 1]
? (bpmList[index + 1].time as number)
: layout.chartLength;
do {
ctx.save();
ctx.strokeStyle =
beat % 1 === 0 ? 'rgba(17, 72, 74, 0.75)' : 'rgba(17, 72, 74, 0.4)';
if (beat % 1 !== 0) {
ctx.setLineDash([5, 5]);
}
const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm);
const { x, y } = getTimePosition(layout, currentTime);
const w = BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + w, y);
ctx.stroke();
beat += 0.5;
ctx.restore();
} while (
(bpmNote.time as number) + beat * (60 / bpmNote.bpm) < nextTime &&
(bpmNote.time as number) + beat * (60 / bpmNote.bpm) >= previousTime
);
}
ctx.restore();
}
/**
* 在图片布局层中绘制时间轴。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
*/
function drawTimeline(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
): void {
ctx.save();
ctx.font = `18px "${BANGDREAM_RENDER_THEME.font.chart}"`;
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
ctx.textAlign = 'right';
for (let i = 0; i <= layout.chartLength; i += 5) {
const { x, y } = getTimePosition(layout, i);
setAdaptiveTextBaseline(ctx, layout, 18, y);
ctx.fillText(`${Math.floor(i / 60)}:${i % 60}`, x - 8, y);
}
ctx.restore();
}
/**
* 在图片布局层中绘制CountAndBPM线条列表。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param notes - 谱面音符列表。
*/
function drawCountAndBpmLines(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
notes: PreviewNote[],
): void {
let count = 0;
const w = BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
for (const note of notes) {
const time = note.time as number;
const { x, y } = getTimePosition(layout, time);
if (isSongChartCountLineNoteType(note.type)) {
count++;
if (count % 50 !== 0) {
continue;
}
ctx.font = `18px "${BANGDREAM_RENDER_THEME.font.chart}"`;
ctx.fillStyle = 'rgba(128, 128, 128, 0.5)';
ctx.textAlign = 'left';
ctx.fillRect(x, y - 1, w, 2);
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
setAdaptiveTextBaseline(ctx, layout, 18, y);
ctx.fillText(`${count}`, x + w + 8, y);
continue;
}
if (note.type === 'BPM') {
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartBpm;
ctx.fillRect(x, y - 1, w, 2);
ctx.font = `18px "${BANGDREAM_RENDER_THEME.font.chart}"`;
ctx.textAlign = 'left';
setAdaptiveTextBaseline(ctx, layout, 18, y);
ctx.fillText(`${note.bpm}`, x + w + 8, y);
}
}
}
/**
* 在图片布局层中绘制Tap音符。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param noteImages - 音符图片列表参数。
* @param note - 谱面音符。
*/
function drawTapNote(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
noteImages: Record<string, Image>,
note: PreviewNote,
): void {
const { drawCol } = getTimePosition(layout, note.time as number);
const img = noteImages[note.type];
const w = layout.laneWidth;
const h = (layout.laneWidth * img.height) / img.width;
const x =
layout.infoAreaWidth +
drawCol * layout.originalWidth +
layout.blockDistance +
(note.lane as number) * layout.laneWidth;
const y =
layout.height -
(((note.time as number) * layout.heightPerSecond) % layout.height) -
h / 2;
ctx.drawImage(img, x, y, w, h);
if (note.type === 'Flick') {
ctx.drawImage(
noteImages.FlickTop,
x + layout.laneWidth * 0.2,
y - h,
layout.laneWidth * 0.6,
(layout.laneWidth * 0.6 * noteImages.FlickTop.height) /
noteImages.FlickTop.width,
);
}
}
/**
* 在图片布局层中绘制Directional音符。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param noteImages - 音符图片列表参数。
* @param note - 谱面音符。
*/
function drawDirectionalNote(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
noteImages: Record<string, Image>,
note: PreviewNote,
): void {
const { drawCol } = getTimePosition(layout, note.time as number);
const arrowImg =
noteImages[note.direction === 'Left' ? 'LeftArrow' : 'RightArrow'];
const direction = note.direction === 'Left' ? -1 : 1;
const noteWidth = note.width ?? 0;
for (let i = 0; i < noteWidth; i++) {
const w = layout.laneWidth;
const h = (layout.laneWidth * arrowImg.height) / arrowImg.width;
const x =
layout.infoAreaWidth +
drawCol * layout.originalWidth +
layout.blockDistance +
((note.lane as number) + i * direction) * layout.laneWidth;
const y =
layout.height -
(((note.time as number) * layout.heightPerSecond) % layout.height) -
h / 2;
ctx.drawImage(arrowImg, x, y, w, h);
if (i + 1 === noteWidth) {
const endImg =
noteImages[
note.direction === 'Left' ? 'LeftArrowEnd' : 'RightArrowEnd'
];
const arrowEndX =
direction === 1 ? x + layout.laneWidth : x - layout.laneWidth * 0.4;
ctx.drawImage(
endImg,
arrowEndX,
y,
layout.laneWidth * 0.4,
(layout.laneWidth * 0.4 * endImg.height) / endImg.width,
);
}
}
}
/**
* 在图片布局层中绘制Sim音符。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param noteImages - 音符图片列表参数。
* @param note - 谱面音符。
*/
function drawSimNote(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
noteImages: Record<string, Image>,
note: PreviewNote,
): void {
const { drawCol } = getTimePosition(layout, note.time as number);
const lane = note.lane as number[];
lane.sort((a, b) => a - b);
const simW = layout.laneWidth * (lane[1] - lane[0] - 1);
const simH = BANGDREAM_SONG_CHART_PREVIEW_SPEC.simLineHeight;
const simStartX =
layout.infoAreaWidth +
drawCol * layout.originalWidth +
layout.blockDistance +
(lane[0] + 1) * layout.laneWidth;
const simY =
layout.height -
(((note.time as number) * layout.heightPerSecond) % layout.height) -
simH / 2;
ctx.drawImage(noteImages.Sim, simStartX, simY, simW, simH);
}
/**
* 在图片布局层中绘制Bar音符。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param note - 谱面音符。
*/
function drawBarNote(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
note: PreviewNote,
): void {
const time = note.time as number[];
const lane = note.lane as number[];
const startCol = Math.floor(time[0] / layout.secondsPerCol);
const endCol = Math.floor(time[1] / layout.secondsPerCol);
for (let i = startCol; i <= endCol; i++) {
const x1 =
layout.infoAreaWidth +
i * layout.originalWidth +
layout.blockDistance +
lane[0] * layout.laneWidth;
const x2 =
layout.infoAreaWidth +
i * layout.originalWidth +
layout.blockDistance +
lane[1] * layout.laneWidth;
const y1 = layout.height * (i + 1) - time[0] * layout.heightPerSecond;
const y2 = layout.height * (i + 1) - time[1] * layout.heightPerSecond;
const w = layout.laneWidth * 0.8;
ctx.beginPath();
ctx.moveTo(x1 + (layout.laneWidth - w) / 2, y1);
ctx.lineTo(x1 + (layout.laneWidth - w) / 2 + w, y1);
ctx.lineTo(x2 + (layout.laneWidth - w) / 2 + w, y2);
ctx.lineTo(x2 + (layout.laneWidth - w) / 2, y2);
ctx.closePath();
const grd = ctx.createLinearGradient(x1, y1, x2, y2);
grd.addColorStop(0, 'rgba(16, 143, 19, 0.5)');
grd.addColorStop(0.5, 'rgba(33, 177, 39, 0.5)');
grd.addColorStop(1, 'rgba(16, 143, 19, 0.5)');
ctx.fillStyle = grd;
ctx.fill();
}
}
/**
* 在图片布局层中绘制音符列表。
*
* @param ctx - 画布绘图上下文。
* @param layout - 布局参数。
* @param noteImages - 音符图片列表参数。
* @param notes - 谱面音符列表。
*/
function drawNotes(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
noteImages: Record<string, Image>,
notes: PreviewNote[],
): void {
for (const note of notes) {
switch (note.type) {
case 'Single':
case 'SingleOff':
case 'Skill':
case 'Flick':
case 'Long':
case 'Tick':
drawTapNote(ctx, layout, noteImages, note);
break;
case 'Directional':
drawDirectionalNote(ctx, layout, noteImages, note);
break;
case 'Sim':
drawSimNote(ctx, layout, noteImages, note);
break;
case 'Bar':
drawBarNote(ctx, layout, note);
break;
default:
break;
}
}
}
/**
* 在图片布局层中绘制 Bestdori 谱面预览图。
*
* @param payload - 渲染或请求负载。
* @param chart - 谱面音符数据。
* @returns 异步处理结果。
*/
export async function drawBestdoriPreview(
payload: BestdoriPreviewPayload,
chart: BestdoriNote[],
): Promise<Canvas> {
const { layout, notes } = createSongChartPreviewModel(chart);
const canvas = new Canvas(layout.width, layout.height);
const ctx = canvas.getContext('2d');
const [noteImages, coverImg] = await Promise.all([
loadNoteImages(),
loadCoverImage(payload.cover),
]);
drawBaseInfo(ctx, layout, payload, coverImg);
drawTracks(ctx, layout);
drawBeatLines(ctx, layout, notes);
drawTimeline(ctx, layout);
drawCountAndBpmLines(ctx, layout, notes);
drawNotes(ctx, layout, noteImages, notes);
return canvas;
}