From ef1140bd222cd2889a1e9da1b1528bc8be87ca68 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 6 Jun 2026 23:48:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20BangDream=20?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=A1=86=E6=9E=B6=E8=A7=84=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 4 +- .../tsugu/render-blocks/list-frame-spec.ts | 213 ++++++++++++++++++ .../tsugu/render-blocks/list-frame.ts | 164 ++++++-------- .../bangDream/tsugu/list-frame-spec.spec.ts | 93 ++++++++ 4 files changed, 379 insertions(+), 95 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame-spec.ts create mode 100644 test/qqbot/plugins/bangDream/tsugu/list-frame-spec.spec.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 84478b3..758610d 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;当前 `tsugu` 源码 126 +- TS 文件:初始基线 92;当前 `tsugu` 源码 127 - 函数节点:481,其中稳定函数 410,匿名/内联回调 71 - 源码 JSDoc:稳定函数 410/410 已覆盖 - 变量声明:1896 @@ -356,6 +356,8 @@ export interface TsuguHook { - 已新增 `card-art-spec.spec.ts`,覆盖 rarity=1 属性边框、其他稀有度边框和关键尺寸;本地生成 `card-art-spec-card-472.jpg`,验证卡牌详情图在规格收口后仍能正常输出。 - 已新增 `render-blocks/detail-block-spec.ts`,收口歌曲详情、歌曲 meta、角色半身块和玩家详情头图的尺寸、间距、字号与相对分数舍入规则;`detail-blocks.ts` 改为消费 spec,数据查询和绘制顺序保持不变。 - 已新增 `detail-block-spec.spec.ts`,覆盖歌曲详情尺寸、角色/玩家详情尺寸和 meta 相对百分比舍入;本地生成 `detail-spec-song-136.jpg`、`detail-spec-event-50.jpg`、`detail-spec-character-1.jpg`,验证详情区块规格收口后查曲/查活动/查角色图片输出正常。 +- 已新增 `render-blocks/list-frame-spec.ts`,收口通用列表行、tips、横向合并列、居中图片列表和左侧竖线的字号、间距、兜底尺寸与布局计算;`list-frame.ts` 改为消费 spec,服务器分组、换行和绘制顺序保持不变。 +- 已新增 `list-frame-spec.spec.ts`,覆盖列表正文宽度、标签/tips 偏移、合并列宽、居中图片换行和左侧竖线尺寸;本地生成 `list-frame-spec-song-136.jpg`、`list-frame-spec-event-50.jpg`、`list-frame-spec-character-1.jpg`,验证列表框架规格收口后查曲/查活动/查角色图片输出正常。 ### Phase 6:策略 policy 和时间/档线规则 diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame-spec.ts new file mode 100644 index 0000000..5fbd80f --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame-spec.ts @@ -0,0 +1,213 @@ +import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme'; + +interface ImageLike { + height: number; + width: number; +} + +interface KeyedListLayoutOptions { + keyHeight: number; + maxWidth?: number; + textHeight: number; +} + +export interface ListFrameImageRow { + height: number; + imageList: T[]; + width: number; +} + +export const BANGDREAM_LIST_FRAME_SPEC = { + imageList: { + emptyHeight: 10, + emptyWidth: 1, + }, + list: { + emptyTextHeight: 0, + emptyTextWidth: 0, + keyGapHeight: 10, + noKeySpacerHeight: 1, + textHorizontalInset: 40, + }, + merge: { + defaultWidth: BANGDREAM_RENDER_THEME.layout.contentWidth, + }, + text: { + defaultSize: 40, + labelSize: 30, + lineHeightRatio: 1.5, + spacingRatio: 1 / 3, + }, + tips: { + backgroundOffsetY: 10, + defaultTextSize: 30, + emptyTextHeight: 1, + emptyTextWidth: 1, + }, + withLine: { + canvasExtraHeight: 10, + contentX: 10, + contentY: 10, + lineExtraHeight: 20, + lineWidth: 5, + }, +} as const; + +/** + * 计算列表文本默认行高。 + * + * @param textSize - 文本字号。 + */ +export function getListFrameLineHeight(textSize: number) { + return textSize * BANGDREAM_LIST_FRAME_SPEC.text.lineHeightRatio; +} + +/** + * 计算列表文本和图片之间的默认间距。 + * + * @param textSize - 文本字号。 + */ +export function getListFrameSpacing(textSize: number) { + return textSize * BANGDREAM_LIST_FRAME_SPEC.text.spacingRatio; +} + +/** + * 计算列表正文最大宽度。 + * + * @param maxWidth - 列表总宽度。 + */ +export function getListFrameTextMaxWidth( + maxWidth: number = BANGDREAM_RENDER_THEME.layout.contentWidth, +) { + return maxWidth - BANGDREAM_LIST_FRAME_SPEC.list.textHorizontalInset; +} + +/** + * 计算带字段标签的列表行布局。 + * + * @param options - 标签高度、正文高度和总宽度。 + */ +export function createKeyedListFrameLayout({ + keyHeight, + maxWidth = BANGDREAM_RENDER_THEME.layout.contentWidth, + textHeight, +}: KeyedListLayoutOptions) { + const textY = keyHeight + BANGDREAM_LIST_FRAME_SPEC.list.keyGapHeight; + return { + height: textHeight + textY, + keyX: 0, + keyY: 0, + textX: BANGDREAM_RENDER_THEME.layout.listIndent, + textY, + width: maxWidth, + }; +} + +/** + * 计算 tips 行布局。 + * + * @param textHeight - tips 文本高度。 + */ +export function createTipsInListLayout(textHeight: number) { + return { + backgroundHeight: textHeight, + backgroundWidth: BANGDREAM_RENDER_THEME.layout.contentWidth, + backgroundX: 0, + backgroundY: BANGDREAM_LIST_FRAME_SPEC.tips.backgroundOffsetY, + height: textHeight + BANGDREAM_LIST_FRAME_SPEC.tips.backgroundOffsetY, + textMaxWidth: + BANGDREAM_RENDER_THEME.layout.contentWidth - + BANGDREAM_RENDER_THEME.layout.listIndent * 2, + textX: BANGDREAM_RENDER_THEME.layout.listIndent, + textY: BANGDREAM_LIST_FRAME_SPEC.tips.backgroundOffsetY, + width: BANGDREAM_RENDER_THEME.layout.contentWidth, + }; +} + +/** + * 计算横向合并列表的列宽。 + * + * @param itemCount - 合并项数量。 + */ +export function getMergedListColumnWidth(itemCount: number) { + if (itemCount <= 0) return 0; + return BANGDREAM_LIST_FRAME_SPEC.merge.defaultWidth / itemCount; +} + +/** + * 计算居中图片列表的换行结果。 + * + * @param imageList - 图片或画布尺寸列表。 + * @param maxWidth - 每行最大宽度。 + */ +export function createCenteredImageRows( + imageList: T[], + maxWidth: number = BANGDREAM_RENDER_THEME.layout.contentWidth, +): ListFrameImageRow[] { + const lineList: ListFrameImageRow[] = []; + let tempWidth = 0; + let tempHeight = 0; + let tempImageList: T[] = []; + + const newLine = () => { + lineList.push({ + height: tempHeight, + imageList: tempImageList, + width: tempWidth, + }); + tempWidth = 0; + tempHeight = 0; + tempImageList = []; + }; + + for (const element of imageList) { + if (element.width > maxWidth) { + newLine(); + tempImageList.push(element); + continue; + } + if (tempWidth + element.width > maxWidth) { + newLine(); + } + tempWidth += element.width; + if (element.height > tempHeight) { + tempHeight = element.height; + } + tempImageList.push(element); + } + if (tempImageList.length > 0) { + newLine(); + } + + return lineList; +} + +/** + * 计算居中图片列表总高度。 + * + * @param lineList - 居中图片行。 + */ +export function getCenteredImageRowsHeight( + lineList: Array>, +) { + return lineList.reduce((total, element) => total + element.height, 0); +} + +/** + * 计算左侧竖线列表布局。 + * + * @param contentHeight - 内容总高度。 + */ +export function createListWithLineLayout(contentHeight: number) { + return { + canvasHeight: + contentHeight + BANGDREAM_LIST_FRAME_SPEC.withLine.canvasExtraHeight, + canvasWidth: BANGDREAM_RENDER_THEME.layout.contentWidth, + contentX: BANGDREAM_LIST_FRAME_SPEC.withLine.contentX, + contentY: BANGDREAM_LIST_FRAME_SPEC.withLine.contentY, + lineHeight: + contentHeight + BANGDREAM_LIST_FRAME_SPEC.withLine.lineExtraHeight, + lineWidth: BANGDREAM_LIST_FRAME_SPEC.withLine.lineWidth, + lineY: BANGDREAM_LIST_FRAME_SPEC.withLine.contentY, + }; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame.ts index 17c7196..a832a58 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame.ts @@ -17,6 +17,18 @@ import { createHorizontalSeparatorSpec, createVerticalSeparatorSpec, } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/layout-spec'; +import { + BANGDREAM_LIST_FRAME_SPEC, + createCenteredImageRows, + createKeyedListFrameLayout, + createListWithLineLayout, + createTipsInListLayout, + getCenteredImageRowsHeight, + getListFrameLineHeight, + getListFrameSpacing, + getListFrameTextMaxWidth, + getMergedListColumnWidth, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame-spec'; //表格用默认虚线 export const line: Canvas = drawDottedLine(createHorizontalSeparatorSpec()); @@ -43,16 +55,16 @@ export function drawList({ key, text, content, - textSize = 40, - lineHeight = textSize * 1.5, - spacing = textSize / 3, + textSize = BANGDREAM_LIST_FRAME_SPEC.text.defaultSize, + lineHeight = getListFrameLineHeight(textSize), + spacing = getListFrameSpacing(textSize), color = BANGDREAM_RENDER_THEME.color.primaryText, maxWidth = BANGDREAM_RENDER_THEME.layout.contentWidth, }: ListOptions): Canvas { - const xmax = maxWidth - 40; + const xmax = getListFrameTextMaxWidth(maxWidth); const keyImage = drawRoundedRectWithText({ text: key, - textSize: 30, + textSize: BANGDREAM_LIST_FRAME_SPEC.text.labelSize, }); let textImage: Canvas; @@ -68,24 +80,30 @@ export function drawList({ color, }); } else { - textImage = new Canvas(0, 0); + textImage = new Canvas( + BANGDREAM_LIST_FRAME_SPEC.list.emptyTextWidth, + BANGDREAM_LIST_FRAME_SPEC.list.emptyTextHeight, + ); } if (key == undefined) { return stackImageHorizontal([ - new Canvas(BANGDREAM_RENDER_THEME.layout.listIndent, 1), + new Canvas( + BANGDREAM_RENDER_THEME.layout.listIndent, + BANGDREAM_LIST_FRAME_SPEC.list.noKeySpacerHeight, + ), textImage, ]); } - const ymax = textImage.height + keyImage.height + 10; - const canvas = new Canvas(maxWidth, ymax); + const layout = createKeyedListFrameLayout({ + keyHeight: keyImage.height, + maxWidth, + textHeight: textImage.height, + }); + const canvas = new Canvas(layout.width, layout.height); const ctx = canvas.getContext('2d'); - ctx.drawImage(keyImage, 0, 0); + ctx.drawImage(keyImage, layout.keyX, layout.keyY); if (textImage.height != 0) { - ctx.drawImage( - textImage, - BANGDREAM_RENDER_THEME.layout.listIndent, - keyImage.height + 10, - ); + ctx.drawImage(textImage, layout.textX, layout.textY); } return canvas; } @@ -105,13 +123,12 @@ interface tipsOptions { export function drawTipsInList({ text, content, - textSize = 30, - lineHeight = textSize * 1.5, - spacing = textSize / 3, + textSize = BANGDREAM_LIST_FRAME_SPEC.tips.defaultTextSize, + lineHeight = getListFrameLineHeight(textSize), + spacing = getListFrameSpacing(textSize), }: tipsOptions) { - const xmax = - BANGDREAM_RENDER_THEME.layout.contentWidth - - BANGDREAM_RENDER_THEME.layout.listIndent * 2; + const layout = createTipsInListLayout(0); + const xmax = layout.textMaxWidth; let textImage: Canvas; if (typeof text == 'string') { textImage = drawText({ text, textSize, maxWidth: xmax, lineHeight }); @@ -124,21 +141,22 @@ export function drawTipsInList({ spacing, }); } else { - textImage = new Canvas(1, 1); + textImage = new Canvas( + BANGDREAM_LIST_FRAME_SPEC.tips.emptyTextWidth, + BANGDREAM_LIST_FRAME_SPEC.tips.emptyTextHeight, + ); } - const canvas = new Canvas( - BANGDREAM_RENDER_THEME.layout.contentWidth, - textImage.height + 10, - ); + const textLayout = createTipsInListLayout(textImage.height); + const canvas = new Canvas(textLayout.width, textLayout.height); const ctx = canvas.getContext('2d'); ctx.fillStyle = BANGDREAM_RENDER_THEME.color.subtlePanel; ctx.fillRect( - 0, - 10, - BANGDREAM_RENDER_THEME.layout.contentWidth, - textImage.height, + textLayout.backgroundX, + textLayout.backgroundY, + textLayout.backgroundWidth, + textLayout.backgroundHeight, ); - ctx.drawImage(textImage, BANGDREAM_RENDER_THEME.layout.listIndent, 10); + ctx.drawImage(textImage, textLayout.textX, textLayout.textY); return canvas; } @@ -224,15 +242,16 @@ export function drawListMerge(imageList: Array): Canvas { } } const canvas = new Canvas( - BANGDREAM_RENDER_THEME.layout.contentWidth, + BANGDREAM_LIST_FRAME_SPEC.merge.defaultWidth, maxHeight, ); const ctx = canvas.getContext('2d'); let x = 0; + const columnWidth = getMergedListColumnWidth(imageList.length); for (let i = 0; i < imageList.length; i++) { const element = imageList[i]; ctx.drawImage(element, x, 0); - x += BANGDREAM_RENDER_THEME.layout.contentWidth / imageList.length; + x += columnWidth; } return canvas; } @@ -249,60 +268,15 @@ export function drawImageListCenter( imageList: Array, maxWidth: number = BANGDREAM_RENDER_THEME.layout.contentWidth, ): Canvas { - interface imageLine { - imageList: Array; - width: number; - height: number; - } - const lineList: Array = []; - let tempWidth = 0; - let tempHeight = 0; - let tempImageList: Array = []; - //换行函数 - /** - * 在图片布局层中处理new线条。 - */ - function newLine() { - lineList.push({ - imageList: tempImageList, - width: tempWidth, - height: tempHeight, - }); - tempWidth = 0; - tempHeight = 0; - tempImageList = []; - } if (imageList.length == 0) { - return new Canvas(1, 10); + return new Canvas( + BANGDREAM_LIST_FRAME_SPEC.imageList.emptyWidth, + BANGDREAM_LIST_FRAME_SPEC.imageList.emptyHeight, + ); } - //遍历imageList,计算每一行的宽度,高度,imageList - for (let i = 0; i < imageList.length; i++) { - const element = imageList[i]; - if (element.width > maxWidth) { - newLine(); - tempImageList.push(element); - continue; - } - if (tempWidth + element.width > maxWidth) { - newLine(); - } - tempWidth += element.width; - if (element.height > tempHeight) { - tempHeight = element.height; - } - tempImageList.push(element); - } - if (tempImageList.length > 0) { - //最后一行 - newLine(); - } - //计算总高度,生成canvas - let Height = 0; - for (let i = 0; i < lineList.length; i++) { - const element = lineList[i]; - Height += element.height; - } - const canvas = new Canvas(maxWidth, Height); + const lineList = createCenteredImageRows(imageList, maxWidth); + const height = getCenteredImageRowsHeight(lineList); + const canvas = new Canvas(maxWidth, height); const ctx = canvas.getContext('2d'); //画每一行 const middleWidth = maxWidth / 2; @@ -328,24 +302,26 @@ export function drawImageListCenter( * @returns 渲染或资源结果。 */ export function drawListWithLine(textImageList: Array): Canvas { - const x = 10; - let y = 10; let height = 0; for (let i = 0; i < textImageList.length; i++) { const element = textImageList[i]; height += element.height; } - const canvas = new Canvas( - BANGDREAM_RENDER_THEME.layout.contentWidth, - height + 10, - ); + const layout = createListWithLineLayout(height); + const canvas = new Canvas(layout.canvasWidth, layout.canvasHeight); const ctx = canvas.getContext('2d'); ctx.fillStyle = BANGDREAM_RENDER_THEME.color.separator; - const lineSpec = createVerticalSeparatorSpec(height + 20); - ctx.fillRect(lineSpec.startX, 10, 5, height + 20); + const lineSpec = createVerticalSeparatorSpec(layout.lineHeight); + ctx.fillRect( + lineSpec.startX, + layout.lineY, + layout.lineWidth, + layout.lineHeight, + ); + let y = layout.contentY; for (let i = 0; i < textImageList.length; i++) { const element = textImageList[i]; - ctx.drawImage(element, x, y); + ctx.drawImage(element, layout.contentX, y); y += element.height; } return canvas; diff --git a/test/qqbot/plugins/bangDream/tsugu/list-frame-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/list-frame-spec.spec.ts new file mode 100644 index 0000000..d0c9756 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/list-frame-spec.spec.ts @@ -0,0 +1,93 @@ +import { + BANGDREAM_LIST_FRAME_SPEC, + createCenteredImageRows, + createKeyedListFrameLayout, + createListWithLineLayout, + createTipsInListLayout, + getCenteredImageRowsHeight, + getListFrameLineHeight, + getListFrameSpacing, + getListFrameTextMaxWidth, + getMergedListColumnWidth, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame-spec'; + +describe('BangDream list frame spec', () => { + it('keeps text sizing and content width stable', () => { + expect(BANGDREAM_LIST_FRAME_SPEC.text.defaultSize).toBe(40); + expect(BANGDREAM_LIST_FRAME_SPEC.text.labelSize).toBe(30); + expect(getListFrameLineHeight(40)).toBe(60); + expect(getListFrameSpacing(30)).toBe(10); + expect(getListFrameTextMaxWidth(800)).toBe(760); + }); + + it('creates keyed list and tips layouts with historical offsets', () => { + expect( + createKeyedListFrameLayout({ + keyHeight: 30, + maxWidth: 800, + textHeight: 120, + }), + ).toEqual({ + height: 160, + keyX: 0, + keyY: 0, + textX: 20, + textY: 40, + width: 800, + }); + + expect(createTipsInListLayout(90)).toEqual({ + backgroundHeight: 90, + backgroundWidth: 800, + backgroundX: 0, + backgroundY: 10, + height: 100, + textMaxWidth: 760, + textX: 20, + textY: 10, + width: 800, + }); + }); + + it('keeps merge column width and centered image rows deterministic', () => { + expect(getMergedListColumnWidth(2)).toBe(400); + + const rows = createCenteredImageRows( + [ + { height: 20, width: 300 }, + { height: 30, width: 500 }, + { height: 40, width: 200 }, + ], + 800, + ); + + expect(rows).toEqual([ + { + height: 30, + imageList: [ + { height: 20, width: 300 }, + { height: 30, width: 500 }, + ], + width: 800, + }, + { + height: 40, + imageList: [{ height: 40, width: 200 }], + width: 200, + }, + ]); + expect(getCenteredImageRowsHeight(rows)).toBe(70); + }); + + it('keeps left-line list dimensions stable', () => { + expect(createListWithLineLayout(120)).toEqual({ + canvasHeight: 130, + canvasWidth: 800, + contentX: 10, + contentY: 10, + lineHeight: 140, + lineWidth: 5, + lineY: 10, + }); + }); +});