refactor: 收口 BangDream 谱面预览策略
This commit is contained in:
parent
46f6a2465c
commit
c726cad320
@ -14,7 +14,7 @@
|
||||
## 当前事实
|
||||
|
||||
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
|
||||
- TS 文件:92
|
||||
- TS 文件:初始基线 92;当前 `tsugu` 源码 124
|
||||
- 函数节点:481,其中稳定函数 410,匿名/内联回调 71
|
||||
- 源码 JSDoc:稳定函数 410/410 已覆盖
|
||||
- 变量声明:1896
|
||||
@ -350,6 +350,8 @@ export interface TsuguHook {
|
||||
- 卡牌详情页已接入 `DetailBlockBuilder`,卡牌标题、插画、基础字段、缩略图和演出缩略图不再手写数组与分割线;`/查卡 472` 本地 smoke 输出非空长图。
|
||||
- `scripts/bangdream-render-smoke.ps1` 已固化图片 smoke 完成判定:先删除旧目标图,轮询 stdout 成功 JSON 和新图片文件;如果 Tsugu 后台 handle 导致 Node 未自然退出,则在图片落盘后清理本次进程并返回成功,避免已成功出图仍卡到超时。
|
||||
- 线上 `/qqbot/command/test` smoke 已固化为先按 `operationKey` 查询启用命令、传 `commandId`,并保留完整命令文本;避免默认 `preview` selfId 未绑定命令时误报“未匹配到命令”。
|
||||
- 已新增 `render-blocks/song-chart-preview-spec.ts`,把谱面 BPM 时间计算、双押识别、滑条拆分、音符排序、展示/计数音符分类、难度颜色和布局规格从 `song-chart-preview.ts` 拆成可测纯策略;绘制文件改为消费 `createSongChartPreviewModel`,只保留资源加载和 canvas 绘制。
|
||||
- 已新增 `song-chart-preview-spec.spec.ts`,覆盖 BPM 变速时间、十六分单点、双押、滑条 bar/tick/end、布局列数和音符分类;本地生成 `song-chart-preview-spec-136-expert.jpg`,验证谱面预览重构后图片输出非空且视觉结构正常。
|
||||
|
||||
### Phase 6:策略 policy 和时间/档线规则
|
||||
|
||||
|
||||
@ -0,0 +1,471 @@
|
||||
export interface BestdoriConnection {
|
||||
beat: number;
|
||||
lane: number;
|
||||
time?: number;
|
||||
skill?: boolean;
|
||||
flick?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface BestdoriNote {
|
||||
type: string;
|
||||
beat: number;
|
||||
lane?: number;
|
||||
time?: number | number[];
|
||||
bpm?: number;
|
||||
connections?: BestdoriConnection[];
|
||||
skill?: boolean;
|
||||
flick?: boolean;
|
||||
direction?: 'Left' | 'Right';
|
||||
width?: number;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type PreviewNote = Omit<BestdoriNote, 'time' | 'lane'> & {
|
||||
type: string;
|
||||
time: number | number[];
|
||||
lane: number | number[];
|
||||
};
|
||||
|
||||
export interface PreviewLayout {
|
||||
infoAreaWidth: number;
|
||||
laneWidth: number;
|
||||
splitLineWidth: number;
|
||||
blockDistance: number;
|
||||
heightPerSecond: number;
|
||||
originalWidth: number;
|
||||
chartLength: number;
|
||||
secondsPerCol: number;
|
||||
width: number;
|
||||
height: number;
|
||||
colCount: number;
|
||||
}
|
||||
|
||||
export const BANGDREAM_SONG_CHART_PREVIEW_SPEC = {
|
||||
aspectRatioLimit: 16 / 9,
|
||||
blockDistance: 72,
|
||||
heightPerSecond: 216,
|
||||
infoAreaWidth: 240,
|
||||
laneCount: 7,
|
||||
laneWidth: 32,
|
||||
minHeight: 500,
|
||||
noteEndPaddingSeconds: 0.25,
|
||||
splitLineWidth: 2,
|
||||
} as const;
|
||||
|
||||
export const BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES = [
|
||||
'Single',
|
||||
'SingleOff',
|
||||
'Skill',
|
||||
'Flick',
|
||||
'Directional',
|
||||
'Long',
|
||||
] as const;
|
||||
|
||||
export const BANGDREAM_SONG_CHART_COUNT_LINE_NOTE_TYPES = [
|
||||
'Single',
|
||||
'SingleOff',
|
||||
'Flick',
|
||||
'Long',
|
||||
'Skill',
|
||||
'Tick',
|
||||
'Directional',
|
||||
] as const;
|
||||
|
||||
export const BANGDREAM_SONG_CHART_DIFFICULTY_COLORS: Record<string, string> = {
|
||||
easy: 'rgb(87, 192, 201)',
|
||||
expert: 'rgb(199, 96, 96)',
|
||||
hard: 'rgb(239, 161, 25)',
|
||||
normal: 'rgb(138, 201, 87)',
|
||||
special: 'rgb(195, 96, 199)',
|
||||
};
|
||||
|
||||
const PREVIEW_NOTE_TYPE_SORT: Record<string, number> = {
|
||||
Bar: -2,
|
||||
Sim: -1,
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断谱面音符是否参与谱面长度计算。
|
||||
*
|
||||
* @param type - 预览音符类型。
|
||||
*/
|
||||
export function isSongChartDisplayNoteType(type: string): boolean {
|
||||
return BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES.includes(type as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断谱面音符是否参与计数线绘制。
|
||||
*
|
||||
* @param type - 预览音符类型。
|
||||
*/
|
||||
export function isSongChartCountLineNoteType(type: string): boolean {
|
||||
return BANGDREAM_SONG_CHART_COUNT_LINE_NOTE_TYPES.includes(type as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中按节拍排序 BPM 时间点并写入累计时间。
|
||||
*
|
||||
* @param timepoints - BPM 时间点列表。
|
||||
* @returns 处理后的列表。
|
||||
*/
|
||||
function sortTimepoints(timepoints: BestdoriNote[]): BestdoriNote[] {
|
||||
timepoints.sort((a, b) => a.beat - b.beat);
|
||||
for (let i = 0; i < timepoints.length; i++) {
|
||||
const current = timepoints[i];
|
||||
if (i === 0) {
|
||||
current.time = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = timepoints[i - 1];
|
||||
current.time =
|
||||
(previous.time as number) +
|
||||
(current.beat - previous.beat) * (60 / previous.bpm);
|
||||
}
|
||||
return timepoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中用二分查找定位节拍所在的 BPM 时间点。
|
||||
*
|
||||
* @param timepoints - BPM 时间点列表。
|
||||
* @param beat - 谱面节拍位置。
|
||||
* @returns 处理结果。
|
||||
*/
|
||||
function findTimepointAtBeat(
|
||||
timepoints: BestdoriNote[],
|
||||
beat: number,
|
||||
): BestdoriNote {
|
||||
let left = 0;
|
||||
let right = timepoints.length - 1;
|
||||
let result = timepoints[0];
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (timepoints[mid].beat > beat) {
|
||||
right = mid - 1;
|
||||
continue;
|
||||
}
|
||||
result = timepoints[mid];
|
||||
left = mid + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中根据 BPM 时间点计算谱面音符时间。
|
||||
*
|
||||
* @param timepoints - BPM 时间点列表。
|
||||
* @param beat - 谱面节拍位置。
|
||||
* @returns 计算后的数值。
|
||||
*/
|
||||
function getNoteTime(timepoints: BestdoriNote[], beat: number): number {
|
||||
const timepoint = findTimepointAtBeat(timepoints, beat);
|
||||
return (
|
||||
(timepoint.time as number) + (60 / timepoint.bpm) * (beat - timepoint.beat)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中为谱面音符写入实际时间。
|
||||
*
|
||||
* @param chart - 谱面音符数据。
|
||||
* @returns 处理后的 BPM 时间点列表。
|
||||
*/
|
||||
export function assignSongChartTimes(chart: BestdoriNote[]): BestdoriNote[] {
|
||||
const timepoints = sortTimepoints(
|
||||
chart.filter((note) => note.type === 'BPM'),
|
||||
);
|
||||
|
||||
for (const note of chart) {
|
||||
if (note.type === 'Long' || note.type === 'Slide') {
|
||||
for (const connection of note.connections ?? []) {
|
||||
connection.time = getNoteTime(timepoints, connection.beat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (note.type !== 'BPM') {
|
||||
note.time = getNoteTime(timepoints, note.beat);
|
||||
}
|
||||
}
|
||||
|
||||
return timepoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中为同拍音符补充双押标记。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @param beat - 谱面节拍位置。
|
||||
* @param time - 谱面时间点。
|
||||
* @param lane - 轨道位置。
|
||||
*/
|
||||
function addSimNote(
|
||||
notes: PreviewNote[],
|
||||
beat: number,
|
||||
time: number,
|
||||
lane: number,
|
||||
): void {
|
||||
for (const note of notes) {
|
||||
if (note.beat === beat && note.lane === lane) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
['Single', 'Flick', 'Skill', 'Long', 'Directional'].includes(note.type) &&
|
||||
note.beat === beat
|
||||
) {
|
||||
notes.push({
|
||||
beat,
|
||||
lane: [note.lane as number, lane].sort((a, b) => a - b),
|
||||
time,
|
||||
type: 'Sim',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中识别单点音符的展示类型。
|
||||
*
|
||||
* @param note - 谱面音符。
|
||||
* @returns 预览音符类型。
|
||||
*/
|
||||
function getSingleNoteType(note: BestdoriNote): string {
|
||||
if (note.flick) {
|
||||
return 'Flick';
|
||||
}
|
||||
if (note.skill) {
|
||||
return 'Skill';
|
||||
}
|
||||
if (note.beat % 0.5 !== 0) {
|
||||
return 'SingleOff';
|
||||
}
|
||||
return 'Single';
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中把滑条连接点拆成可绘制音符。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @param note - 谱面音符。
|
||||
*/
|
||||
function pushSlideNotes(notes: PreviewNote[], note: BestdoriNote): void {
|
||||
const barTime: number[] = [];
|
||||
const lane: number[] = [];
|
||||
const connections = note.connections ?? [];
|
||||
|
||||
for (let i = 0; i < connections.length; i++) {
|
||||
const tick = connections[i];
|
||||
const time = tick.time;
|
||||
const firstTick = i === 0;
|
||||
const lastTick = i === connections.length - 1;
|
||||
|
||||
barTime.push(time);
|
||||
lane.push(tick.lane);
|
||||
|
||||
if (!firstTick) {
|
||||
notes.push({
|
||||
beat: tick.beat,
|
||||
lane: [lane[0], lane[1]],
|
||||
time: [barTime[0], barTime[1]],
|
||||
type: 'Bar',
|
||||
});
|
||||
}
|
||||
|
||||
if (firstTick || lastTick) {
|
||||
notes.push({
|
||||
...tick,
|
||||
lane: tick.lane,
|
||||
time,
|
||||
type: firstTick
|
||||
? tick.skill
|
||||
? 'Skill'
|
||||
: 'Long'
|
||||
: tick.flick
|
||||
? 'Flick'
|
||||
: tick.skill
|
||||
? 'Skill'
|
||||
: 'Long',
|
||||
});
|
||||
addSimNote(notes, tick.beat, time, tick.lane);
|
||||
continue;
|
||||
}
|
||||
|
||||
lane.shift();
|
||||
barTime.shift();
|
||||
if (!tick.hidden) {
|
||||
notes.push({ ...tick, lane: tick.lane, time, type: 'Tick' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中把可游玩音符转换为预览音符。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @param note - 谱面音符。
|
||||
*/
|
||||
function pushPlayableNote(notes: PreviewNote[], note: BestdoriNote): void {
|
||||
if (note.type === 'Single') {
|
||||
const typedNote = {
|
||||
...note,
|
||||
lane: note.lane,
|
||||
time: note.time as number,
|
||||
type: getSingleNoteType(note),
|
||||
} as PreviewNote;
|
||||
notes.push(typedNote);
|
||||
addSimNote(notes, note.beat, typedNote.time as number, note.lane);
|
||||
return;
|
||||
}
|
||||
|
||||
if (note.type === 'Directional') {
|
||||
notes.push({
|
||||
...note,
|
||||
lane: note.lane,
|
||||
time: note.time as number,
|
||||
} as PreviewNote);
|
||||
addSimNote(notes, note.beat, note.time as number, note.lane);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中获取排序轨道。
|
||||
*
|
||||
* @param note - 谱面音符。
|
||||
* @returns 计算后的数值。
|
||||
*/
|
||||
function getSortLane(note: PreviewNote): number {
|
||||
return Array.isArray(note.lane) ? note.lane[0] : note.lane;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中按时间和轨道排序预览音符。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @returns 处理后的列表。
|
||||
*/
|
||||
function sortPreviewNotes(notes: PreviewNote[]): PreviewNote[] {
|
||||
notes.sort((a, b) => {
|
||||
const typeSortResult =
|
||||
(PREVIEW_NOTE_TYPE_SORT[a.type] || 0) -
|
||||
(PREVIEW_NOTE_TYPE_SORT[b.type] || 0);
|
||||
if (typeSortResult !== 0) {
|
||||
return typeSortResult;
|
||||
}
|
||||
if (a.time !== b.time) {
|
||||
return (a.time as number) - (b.time as number);
|
||||
}
|
||||
return getSortLane(a) - getSortLane(b);
|
||||
});
|
||||
return notes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中把 Bestdori 谱面转换为预览音符列表。
|
||||
*
|
||||
* @param chart - 谱面音符数据。
|
||||
* @returns 处理后的列表。
|
||||
*/
|
||||
export function createSongChartPreviewNotes(
|
||||
chart: BestdoriNote[],
|
||||
): PreviewNote[] {
|
||||
const notes: PreviewNote[] = [];
|
||||
|
||||
for (const note of chart) {
|
||||
if (note.type === 'Slide' || note.type === 'Long') {
|
||||
pushSlideNotes(notes, note);
|
||||
continue;
|
||||
}
|
||||
if (note.type === 'BPM') {
|
||||
notes.push(note as PreviewNote);
|
||||
continue;
|
||||
}
|
||||
pushPlayableNote(notes, note);
|
||||
}
|
||||
|
||||
return sortPreviewNotes(notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中根据谱面长度创建预览布局参数。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @returns 处理结果。
|
||||
*/
|
||||
export function createSongChartPreviewLayout(
|
||||
notes: PreviewNote[],
|
||||
): PreviewLayout {
|
||||
const {
|
||||
aspectRatioLimit,
|
||||
blockDistance,
|
||||
heightPerSecond,
|
||||
infoAreaWidth,
|
||||
laneCount,
|
||||
laneWidth,
|
||||
minHeight,
|
||||
noteEndPaddingSeconds,
|
||||
splitLineWidth,
|
||||
} = BANGDREAM_SONG_CHART_PREVIEW_SPEC;
|
||||
const displayNotes = notes.filter((note) =>
|
||||
isSongChartDisplayNoteType(note.type),
|
||||
);
|
||||
const chartLength = Math.ceil(
|
||||
(displayNotes[displayNotes.length - 1].time as number) +
|
||||
noteEndPaddingSeconds,
|
||||
);
|
||||
const originalWidth = blockDistance * 2 + laneWidth * laneCount;
|
||||
const originalHeight = heightPerSecond * chartLength;
|
||||
let width = infoAreaWidth + originalWidth;
|
||||
let height = originalHeight;
|
||||
let colCount = 1;
|
||||
|
||||
while (width / height < aspectRatioLimit) {
|
||||
if (width / height > 4 / 3) {
|
||||
break;
|
||||
}
|
||||
if (Math.ceil(originalHeight / (colCount + 1)) < minHeight) {
|
||||
break;
|
||||
}
|
||||
colCount++;
|
||||
|
||||
const newWidth = infoAreaWidth + originalWidth * colCount;
|
||||
const newHeight = originalHeight / colCount;
|
||||
if (newHeight < minHeight) {
|
||||
break;
|
||||
}
|
||||
|
||||
width = newWidth;
|
||||
height = newHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
blockDistance,
|
||||
chartLength,
|
||||
colCount,
|
||||
height,
|
||||
heightPerSecond,
|
||||
infoAreaWidth,
|
||||
laneWidth,
|
||||
originalWidth,
|
||||
secondsPerCol: chartLength / colCount,
|
||||
splitLineWidth,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建谱面预览渲染模型。
|
||||
*
|
||||
* @param chart - Bestdori 谱面数据。
|
||||
* @returns 预览音符和布局。
|
||||
*/
|
||||
export function createSongChartPreviewModel(chart: BestdoriNote[]) {
|
||||
assignSongChartTimes(chart);
|
||||
const notes = createSongChartPreviewNotes(chart);
|
||||
return {
|
||||
layout: createSongChartPreviewLayout(notes),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
@ -5,6 +5,15 @@ import {
|
||||
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;
|
||||
@ -16,49 +25,6 @@ interface BestdoriPreviewPayload {
|
||||
cover: string | Buffer;
|
||||
}
|
||||
|
||||
interface BestdoriConnection {
|
||||
beat: number;
|
||||
lane: number;
|
||||
time?: number;
|
||||
skill?: boolean;
|
||||
flick?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface BestdoriNote {
|
||||
type: string;
|
||||
beat: number;
|
||||
lane?: number;
|
||||
time?: number | number[];
|
||||
bpm?: number;
|
||||
connections?: BestdoriConnection[];
|
||||
skill?: boolean;
|
||||
flick?: boolean;
|
||||
direction?: 'Left' | 'Right';
|
||||
width?: number;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
type PreviewNote = Omit<BestdoriNote, 'time' | 'lane'> & {
|
||||
type: string;
|
||||
time: number | number[];
|
||||
lane: number | number[];
|
||||
};
|
||||
|
||||
interface PreviewLayout {
|
||||
infoAreaWidth: number;
|
||||
laneWidth: number;
|
||||
splitLineWidth: number;
|
||||
blockDistance: number;
|
||||
heightPerSecond: number;
|
||||
originalWidth: number;
|
||||
chartLength: number;
|
||||
secondsPerCol: number;
|
||||
width: number;
|
||||
height: number;
|
||||
colCount: number;
|
||||
}
|
||||
|
||||
const OFFSET = 8;
|
||||
const NOTE_IMAGE_KEYS = [
|
||||
'Single',
|
||||
@ -91,378 +57,6 @@ const NOTE_IMAGE_ASSET_KEYS: Record<
|
||||
Skill: 'songChartNoteSkill',
|
||||
Tick: 'songChartNoteTick',
|
||||
};
|
||||
const DISPLAY_NOTE_TYPES = [
|
||||
'Single',
|
||||
'SingleOff',
|
||||
'Skill',
|
||||
'Flick',
|
||||
'Directional',
|
||||
'Long',
|
||||
];
|
||||
const COUNT_LINE_NOTE_TYPES = [
|
||||
'Single',
|
||||
'SingleOff',
|
||||
'Flick',
|
||||
'Long',
|
||||
'Skill',
|
||||
'Tick',
|
||||
'Directional',
|
||||
];
|
||||
const DIFFICULTY_COLOR_LIST: Record<string, string> = {
|
||||
easy: 'rgb(87, 192, 201)',
|
||||
normal: 'rgb(138, 201, 87)',
|
||||
hard: 'rgb(239, 161, 25)',
|
||||
expert: 'rgb(199, 96, 96)',
|
||||
special: 'rgb(195, 96, 199)',
|
||||
};
|
||||
|
||||
/**
|
||||
* 在图片布局层中按节拍排序 BPM 时间点并写入累计时间。
|
||||
*
|
||||
* @param timepoints - BPM 时间点列表。
|
||||
* @returns 处理后的列表。
|
||||
*/
|
||||
function sortTimepoints(timepoints: BestdoriNote[]): BestdoriNote[] {
|
||||
timepoints.sort((a, b) => a.beat - b.beat);
|
||||
for (let i = 0; i < timepoints.length; i++) {
|
||||
const current = timepoints[i];
|
||||
if (i === 0) {
|
||||
current.time = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = timepoints[i - 1];
|
||||
current.time =
|
||||
(previous.time as number) +
|
||||
(current.beat - previous.beat) * (60 / previous.bpm);
|
||||
}
|
||||
return timepoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中用二分查找定位节拍所在的 BPM 时间点。
|
||||
*
|
||||
* @param timepoints - BPM 时间点列表。
|
||||
* @param beat - 谱面节拍位置。
|
||||
* @returns 处理结果。
|
||||
*/
|
||||
function findTimepointAtBeat(
|
||||
timepoints: BestdoriNote[],
|
||||
beat: number,
|
||||
): BestdoriNote {
|
||||
let left = 0;
|
||||
let right = timepoints.length - 1;
|
||||
let result = timepoints[0];
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (timepoints[mid].beat > beat) {
|
||||
right = mid - 1;
|
||||
continue;
|
||||
}
|
||||
result = timepoints[mid];
|
||||
left = mid + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中根据 BPM 时间点计算谱面音符时间。
|
||||
*
|
||||
* @param timepoints - BPM 时间点列表。
|
||||
* @param beat - 谱面节拍位置。
|
||||
* @returns 计算后的数值。
|
||||
*/
|
||||
function getNoteTime(timepoints: BestdoriNote[], beat: number): number {
|
||||
const timepoint = findTimepointAtBeat(timepoints, beat);
|
||||
return (
|
||||
(timepoint.time as number) + (60 / timepoint.bpm) * (beat - timepoint.beat)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中为谱面音符写入实际时间。
|
||||
*
|
||||
* @param chart - 谱面音符数据。
|
||||
* @returns 处理后的列表。
|
||||
*/
|
||||
function assignChartTimes(chart: BestdoriNote[]): BestdoriNote[] {
|
||||
const timepoints = sortTimepoints(
|
||||
chart.filter((note) => note.type === 'BPM'),
|
||||
);
|
||||
|
||||
for (const note of chart) {
|
||||
if (note.type === 'Long' || note.type === 'Slide') {
|
||||
for (const connection of note.connections ?? []) {
|
||||
connection.time = getNoteTime(timepoints, connection.beat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (note.type !== 'BPM') {
|
||||
note.time = getNoteTime(timepoints, note.beat);
|
||||
}
|
||||
}
|
||||
|
||||
return timepoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中为同拍音符补充双押标记。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @param beat - 谱面节拍位置。
|
||||
* @param time - 谱面时间点。
|
||||
* @param lane - 轨道位置。
|
||||
*/
|
||||
function addSimNote(
|
||||
notes: PreviewNote[],
|
||||
beat: number,
|
||||
time: number,
|
||||
lane: number,
|
||||
): void {
|
||||
for (const note of notes) {
|
||||
if (note.beat === beat && note.lane === lane) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
['Single', 'Flick', 'Skill', 'Long', 'Directional'].includes(note.type) &&
|
||||
note.beat === beat
|
||||
) {
|
||||
notes.push({
|
||||
type: 'Sim',
|
||||
beat,
|
||||
time,
|
||||
lane: [note.lane as number, lane].sort((a, b) => a - b),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中识别单点音符的展示类型。
|
||||
*
|
||||
* @param note - 谱面音符。
|
||||
* @returns 格式化后的文本。
|
||||
*/
|
||||
function getSingleNoteType(note: BestdoriNote): string {
|
||||
if (note.flick) {
|
||||
return 'Flick';
|
||||
}
|
||||
if (note.skill) {
|
||||
return 'Skill';
|
||||
}
|
||||
if (note.beat % 0.5 !== 0) {
|
||||
return 'SingleOff';
|
||||
}
|
||||
return 'Single';
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中把滑条连接点拆成可绘制音符。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @param note - 谱面音符。
|
||||
*/
|
||||
function pushSlideNotes(notes: PreviewNote[], note: BestdoriNote): void {
|
||||
const barTime: number[] = [];
|
||||
const lane: number[] = [];
|
||||
const connections = note.connections ?? [];
|
||||
|
||||
for (let i = 0; i < connections.length; i++) {
|
||||
const tick = connections[i];
|
||||
const time = tick.time;
|
||||
const firstTick = i === 0;
|
||||
const lastTick = i === connections.length - 1;
|
||||
|
||||
barTime.push(time);
|
||||
lane.push(tick.lane);
|
||||
|
||||
if (!firstTick) {
|
||||
notes.push({
|
||||
type: 'Bar',
|
||||
beat: tick.beat,
|
||||
time: [barTime[0], barTime[1]],
|
||||
lane: [lane[0], lane[1]],
|
||||
});
|
||||
}
|
||||
|
||||
if (firstTick || lastTick) {
|
||||
notes.push({
|
||||
...tick,
|
||||
type: firstTick
|
||||
? tick.skill
|
||||
? 'Skill'
|
||||
: 'Long'
|
||||
: tick.flick
|
||||
? 'Flick'
|
||||
: tick.skill
|
||||
? 'Skill'
|
||||
: 'Long',
|
||||
time,
|
||||
lane: tick.lane,
|
||||
});
|
||||
addSimNote(notes, tick.beat, time, tick.lane);
|
||||
continue;
|
||||
}
|
||||
|
||||
lane.shift();
|
||||
barTime.shift();
|
||||
if (!tick.hidden) {
|
||||
notes.push({ ...tick, type: 'Tick', time, lane: tick.lane });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中把可游玩音符转换为预览音符。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @param note - 谱面音符。
|
||||
*/
|
||||
function pushPlayableNote(notes: PreviewNote[], note: BestdoriNote): void {
|
||||
if (note.type === 'Single') {
|
||||
const typedNote = {
|
||||
...note,
|
||||
type: getSingleNoteType(note),
|
||||
time: note.time as number,
|
||||
lane: note.lane,
|
||||
} as PreviewNote;
|
||||
notes.push(typedNote);
|
||||
addSimNote(notes, note.beat, typedNote.time as number, note.lane);
|
||||
return;
|
||||
}
|
||||
|
||||
if (note.type === 'Directional') {
|
||||
notes.push({
|
||||
...note,
|
||||
time: note.time as number,
|
||||
lane: note.lane,
|
||||
} as PreviewNote);
|
||||
addSimNote(notes, note.beat, note.time as number, note.lane);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中获取Sort轨道。
|
||||
*
|
||||
* @param note - 谱面音符。
|
||||
* @returns 计算后的数值。
|
||||
*/
|
||||
function getSortLane(note: PreviewNote): number {
|
||||
return Array.isArray(note.lane) ? note.lane[0] : note.lane;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中按时间和轨道排序预览音符。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @returns 处理后的列表。
|
||||
*/
|
||||
function sortPreviewNotes(notes: PreviewNote[]): PreviewNote[] {
|
||||
/**
|
||||
* 在图片布局层中处理类型Sort。
|
||||
*
|
||||
* @param type - 数据类型或匹配类型。
|
||||
* @returns 计算后的数值。
|
||||
*/
|
||||
const typeSort = (type: string): number => ({ Bar: -2, Sim: -1 })[type] || 0;
|
||||
notes.sort((a, b) => {
|
||||
const typeSortResult = typeSort(a.type) - typeSort(b.type);
|
||||
if (typeSortResult !== 0) {
|
||||
return typeSortResult;
|
||||
}
|
||||
if (a.time !== b.time) {
|
||||
return (a.time as number) - (b.time as number);
|
||||
}
|
||||
return getSortLane(a) - getSortLane(b);
|
||||
});
|
||||
return notes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中把 Bestdori 谱面转换为预览音符列表。
|
||||
*
|
||||
* @param chart - 谱面音符数据。
|
||||
* @returns 处理后的列表。
|
||||
*/
|
||||
function createPreviewNotes(chart: BestdoriNote[]): PreviewNote[] {
|
||||
const notes: PreviewNote[] = [];
|
||||
|
||||
for (const note of chart) {
|
||||
if (note.type === 'Slide' || note.type === 'Long') {
|
||||
pushSlideNotes(notes, note);
|
||||
continue;
|
||||
}
|
||||
if (note.type === 'BPM') {
|
||||
notes.push(note as PreviewNote);
|
||||
continue;
|
||||
}
|
||||
pushPlayableNote(notes, note);
|
||||
}
|
||||
|
||||
return sortPreviewNotes(notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中根据谱面长度创建预览布局参数。
|
||||
*
|
||||
* @param notes - 谱面音符列表。
|
||||
* @returns 处理结果。
|
||||
*/
|
||||
function createPreviewLayout(notes: PreviewNote[]): PreviewLayout {
|
||||
const infoAreaWidth = 240;
|
||||
const laneWidth = 32;
|
||||
const splitLineWidth = 2;
|
||||
const blockDistance = 72;
|
||||
const heightPerSecond = 216;
|
||||
const displayNotes = notes.filter((note) =>
|
||||
DISPLAY_NOTE_TYPES.includes(note.type),
|
||||
);
|
||||
const chartLength = Math.ceil(
|
||||
(displayNotes[displayNotes.length - 1].time as number) + 0.25,
|
||||
);
|
||||
const minHeight = 500;
|
||||
const originalWidth = blockDistance * 2 + laneWidth * 7;
|
||||
const originalHeight = heightPerSecond * chartLength;
|
||||
let width = infoAreaWidth + originalWidth;
|
||||
let height = originalHeight;
|
||||
let colCount = 1;
|
||||
|
||||
while (width / height < 16 / 9) {
|
||||
if (width / height > 4 / 3) {
|
||||
break;
|
||||
}
|
||||
if (Math.ceil(originalHeight / (colCount + 1)) < minHeight) {
|
||||
break;
|
||||
}
|
||||
colCount++;
|
||||
|
||||
const newWidth = infoAreaWidth + originalWidth * colCount;
|
||||
const newHeight = originalHeight / colCount;
|
||||
if (newHeight < minHeight) {
|
||||
break;
|
||||
}
|
||||
|
||||
width = newWidth;
|
||||
height = newHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
infoAreaWidth,
|
||||
laneWidth,
|
||||
splitLineWidth,
|
||||
blockDistance,
|
||||
heightPerSecond,
|
||||
originalWidth,
|
||||
chartLength,
|
||||
secondsPerCol: chartLength / colCount,
|
||||
width,
|
||||
height,
|
||||
colCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片布局层中加载谱面预览所需音符贴图。
|
||||
*
|
||||
@ -574,7 +168,7 @@ function drawBaseInfo(
|
||||
const coverWidth = layout.infoAreaWidth - 16;
|
||||
ctx.save();
|
||||
ctx.fillStyle =
|
||||
DIFFICULTY_COLOR_LIST[diff] ??
|
||||
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS[diff] ??
|
||||
BANGDREAM_RENDER_THEME.color.chartDifficultyFallback;
|
||||
ctx.fillRect(8 + coverWidth - 116, 8 + coverWidth - 12, 128, 24);
|
||||
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
|
||||
@ -596,7 +190,8 @@ function drawTracks(ctx: any, layout: PreviewLayout): void {
|
||||
ctx.save();
|
||||
const x =
|
||||
layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance;
|
||||
const w = layout.laneWidth * 7;
|
||||
const w =
|
||||
layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount;
|
||||
const grd = ctx.createLinearGradient(
|
||||
x,
|
||||
0,
|
||||
@ -658,7 +253,8 @@ function drawBeatLines(
|
||||
}
|
||||
const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm);
|
||||
const { x, y } = getTimePosition(layout, currentTime);
|
||||
const w = 7 * layout.laneWidth;
|
||||
const w =
|
||||
BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
@ -706,13 +302,13 @@ function drawCountAndBpmLines(
|
||||
notes: PreviewNote[],
|
||||
): void {
|
||||
let count = 0;
|
||||
const w = 7 * layout.laneWidth;
|
||||
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 (COUNT_LINE_NOTE_TYPES.includes(note.type)) {
|
||||
if (isSongChartCountLineNoteType(note.type)) {
|
||||
count++;
|
||||
if (count % 50 !== 0) {
|
||||
continue;
|
||||
@ -955,9 +551,7 @@ export async function drawBestdoriPreview(
|
||||
payload: BestdoriPreviewPayload,
|
||||
chart: BestdoriNote[],
|
||||
): Promise<Canvas> {
|
||||
assignChartTimes(chart);
|
||||
const notes = createPreviewNotes(chart);
|
||||
const layout = createPreviewLayout(notes);
|
||||
const { layout, notes } = createSongChartPreviewModel(chart);
|
||||
const canvas = new Canvas(layout.width, layout.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const [noteImages, coverImg] = await Promise.all([
|
||||
|
||||
@ -0,0 +1,105 @@
|
||||
import {
|
||||
assignSongChartTimes,
|
||||
BestdoriNote,
|
||||
createSongChartPreviewLayout,
|
||||
createSongChartPreviewModel,
|
||||
createSongChartPreviewNotes,
|
||||
isSongChartCountLineNoteType,
|
||||
isSongChartDisplayNoteType,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec';
|
||||
|
||||
describe('BangDream song chart preview spec', () => {
|
||||
it('assigns note times from BPM changes', () => {
|
||||
const chart: BestdoriNote[] = [
|
||||
{ beat: 0, bpm: 120, type: 'BPM' },
|
||||
{ beat: 4, bpm: 240, type: 'BPM' },
|
||||
{ beat: 5, lane: 1, type: 'Single' },
|
||||
];
|
||||
|
||||
assignSongChartTimes(chart);
|
||||
|
||||
expect(chart[0].time).toBe(0);
|
||||
expect(chart[1].time).toBe(2);
|
||||
expect(chart[2].time).toBe(2.25);
|
||||
});
|
||||
|
||||
it('converts playable notes into preview notes', () => {
|
||||
const chart: BestdoriNote[] = [
|
||||
{ beat: 0, bpm: 120, type: 'BPM' },
|
||||
{ beat: 1.25, lane: 1, type: 'Single' },
|
||||
{ beat: 2, flick: true, lane: 2, type: 'Single' },
|
||||
{ beat: 2, lane: 5, skill: true, type: 'Single' },
|
||||
];
|
||||
|
||||
assignSongChartTimes(chart);
|
||||
const notes = createSongChartPreviewNotes(chart);
|
||||
|
||||
expect(notes.map((note) => note.type)).toEqual([
|
||||
'Sim',
|
||||
'BPM',
|
||||
'SingleOff',
|
||||
'Flick',
|
||||
'Skill',
|
||||
]);
|
||||
expect(notes.find((note) => note.type === 'Sim')?.lane).toEqual([2, 5]);
|
||||
});
|
||||
|
||||
it('converts slide connections into bars, ticks and endpoints', () => {
|
||||
const chart: BestdoriNote[] = [
|
||||
{ beat: 0, bpm: 120, type: 'BPM' },
|
||||
{
|
||||
beat: 1,
|
||||
connections: [
|
||||
{ beat: 1, lane: 1 },
|
||||
{ beat: 2, lane: 3 },
|
||||
{ beat: 3, flick: true, lane: 4 },
|
||||
],
|
||||
type: 'Slide',
|
||||
},
|
||||
];
|
||||
|
||||
assignSongChartTimes(chart);
|
||||
const notes = createSongChartPreviewNotes(chart);
|
||||
|
||||
expect(notes.map((note) => note.type)).toEqual([
|
||||
'Bar',
|
||||
'Bar',
|
||||
'BPM',
|
||||
'Long',
|
||||
'Tick',
|
||||
'Flick',
|
||||
]);
|
||||
expect(notes.filter((note) => note.type === 'Bar')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates layout from display note duration', () => {
|
||||
const layout = createSongChartPreviewLayout([
|
||||
{ beat: 0, lane: 0, time: 0, type: 'BPM' },
|
||||
{ beat: 1, lane: 1, time: 1, type: 'Single' },
|
||||
{ beat: 120, lane: 2, time: 60, type: 'Single' },
|
||||
]);
|
||||
|
||||
expect(layout.chartLength).toBe(61);
|
||||
expect(layout.colCount).toBeGreaterThan(1);
|
||||
expect(layout.secondsPerCol).toBe(layout.chartLength / layout.colCount);
|
||||
});
|
||||
|
||||
it('classifies display and count-line note types', () => {
|
||||
expect(isSongChartDisplayNoteType('Long')).toBe(true);
|
||||
expect(isSongChartDisplayNoteType('BPM')).toBe(false);
|
||||
expect(isSongChartCountLineNoteType('Tick')).toBe(true);
|
||||
expect(isSongChartCountLineNoteType('Sim')).toBe(false);
|
||||
});
|
||||
|
||||
it('creates preview model in one call', () => {
|
||||
const chart: BestdoriNote[] = [
|
||||
{ beat: 0, bpm: 120, type: 'BPM' },
|
||||
{ beat: 1, lane: 1, type: 'Single' },
|
||||
];
|
||||
|
||||
const model = createSongChartPreviewModel(chart);
|
||||
|
||||
expect(model.notes.some((note) => note.type === 'Single')).toBe(true);
|
||||
expect(model.layout.width).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user