From c726cad3201decfcb8608cb1a3ae8f8000611442 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 6 Jun 2026 23:09:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20BangDream=20?= =?UTF-8?q?=E8=B0=B1=E9=9D=A2=E9=A2=84=E8=A7=88=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 4 +- .../render-blocks/song-chart-preview-spec.ts | 471 ++++++++++++++++++ .../tsugu/render-blocks/song-chart-preview.ts | 440 +--------------- .../tsugu/song-chart-preview-spec.spec.ts | 105 ++++ 4 files changed, 596 insertions(+), 424 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec.ts create mode 100644 test/qqbot/plugins/bangDream/tsugu/song-chart-preview-spec.spec.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index cbb8a6f..10c05ae 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -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 和时间/档线规则 diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec.ts new file mode 100644 index 0000000..168da87 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec.ts @@ -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 & { + 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 = { + 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 = { + 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, + }; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts index fd5287b..38749af 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts @@ -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 & { - 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 = { - 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 { - 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([ diff --git a/test/qqbot/plugins/bangDream/tsugu/song-chart-preview-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/song-chart-preview-spec.spec.ts new file mode 100644 index 0000000..f7ff224 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/song-chart-preview-spec.spec.ts @@ -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); + }); +});