refactor: 收口 BangDream 列表框架规格

This commit is contained in:
sunlei 2026-06-06 23:48:53 +08:00
parent 08627646e5
commit ef1140bd22
4 changed files with 379 additions and 95 deletions

View File

@ -14,7 +14,7 @@
## 当前事实 ## 当前事实
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu` - Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
- TS 文件:初始基线 92当前 `tsugu` 源码 126 - TS 文件:初始基线 92当前 `tsugu` 源码 127
- 函数节点481其中稳定函数 410匿名/内联回调 71 - 函数节点481其中稳定函数 410匿名/内联回调 71
- 源码 JSDoc稳定函数 410/410 已覆盖 - 源码 JSDoc稳定函数 410/410 已覆盖
- 变量声明1896 - 变量声明1896
@ -356,6 +356,8 @@ export interface TsuguHook {
- 已新增 `card-art-spec.spec.ts`,覆盖 rarity=1 属性边框、其他稀有度边框和关键尺寸;本地生成 `card-art-spec-card-472.jpg`,验证卡牌详情图在规格收口后仍能正常输出。 - 已新增 `card-art-spec.spec.ts`,覆盖 rarity=1 属性边框、其他稀有度边框和关键尺寸;本地生成 `card-art-spec-card-472.jpg`,验证卡牌详情图在规格收口后仍能正常输出。
- 已新增 `render-blocks/detail-block-spec.ts`,收口歌曲详情、歌曲 meta、角色半身块和玩家详情头图的尺寸、间距、字号与相对分数舍入规则`detail-blocks.ts` 改为消费 spec数据查询和绘制顺序保持不变。 - 已新增 `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`,验证详情区块规格收口后查曲/查活动/查角色图片输出正常。 - 已新增 `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 和时间/档线规则 ### Phase 6策略 policy 和时间/档线规则

View File

@ -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<T extends ImageLike> {
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<T extends ImageLike>(
imageList: T[],
maxWidth: number = BANGDREAM_RENDER_THEME.layout.contentWidth,
): ListFrameImageRow<T>[] {
const lineList: ListFrameImageRow<T>[] = [];
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<ListFrameImageRow<ImageLike>>,
) {
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,
};
}

View File

@ -17,6 +17,18 @@ import {
createHorizontalSeparatorSpec, createHorizontalSeparatorSpec,
createVerticalSeparatorSpec, createVerticalSeparatorSpec,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/layout-spec'; } 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()); export const line: Canvas = drawDottedLine(createHorizontalSeparatorSpec());
@ -43,16 +55,16 @@ export function drawList({
key, key,
text, text,
content, content,
textSize = 40, textSize = BANGDREAM_LIST_FRAME_SPEC.text.defaultSize,
lineHeight = textSize * 1.5, lineHeight = getListFrameLineHeight(textSize),
spacing = textSize / 3, spacing = getListFrameSpacing(textSize),
color = BANGDREAM_RENDER_THEME.color.primaryText, color = BANGDREAM_RENDER_THEME.color.primaryText,
maxWidth = BANGDREAM_RENDER_THEME.layout.contentWidth, maxWidth = BANGDREAM_RENDER_THEME.layout.contentWidth,
}: ListOptions): Canvas { }: ListOptions): Canvas {
const xmax = maxWidth - 40; const xmax = getListFrameTextMaxWidth(maxWidth);
const keyImage = drawRoundedRectWithText({ const keyImage = drawRoundedRectWithText({
text: key, text: key,
textSize: 30, textSize: BANGDREAM_LIST_FRAME_SPEC.text.labelSize,
}); });
let textImage: Canvas; let textImage: Canvas;
@ -68,24 +80,30 @@ export function drawList({
color, color,
}); });
} else { } else {
textImage = new Canvas(0, 0); textImage = new Canvas(
BANGDREAM_LIST_FRAME_SPEC.list.emptyTextWidth,
BANGDREAM_LIST_FRAME_SPEC.list.emptyTextHeight,
);
} }
if (key == undefined) { if (key == undefined) {
return stackImageHorizontal([ return stackImageHorizontal([
new Canvas(BANGDREAM_RENDER_THEME.layout.listIndent, 1), new Canvas(
BANGDREAM_RENDER_THEME.layout.listIndent,
BANGDREAM_LIST_FRAME_SPEC.list.noKeySpacerHeight,
),
textImage, textImage,
]); ]);
} }
const ymax = textImage.height + keyImage.height + 10; const layout = createKeyedListFrameLayout({
const canvas = new Canvas(maxWidth, ymax); keyHeight: keyImage.height,
maxWidth,
textHeight: textImage.height,
});
const canvas = new Canvas(layout.width, layout.height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.drawImage(keyImage, 0, 0); ctx.drawImage(keyImage, layout.keyX, layout.keyY);
if (textImage.height != 0) { if (textImage.height != 0) {
ctx.drawImage( ctx.drawImage(textImage, layout.textX, layout.textY);
textImage,
BANGDREAM_RENDER_THEME.layout.listIndent,
keyImage.height + 10,
);
} }
return canvas; return canvas;
} }
@ -105,13 +123,12 @@ interface tipsOptions {
export function drawTipsInList({ export function drawTipsInList({
text, text,
content, content,
textSize = 30, textSize = BANGDREAM_LIST_FRAME_SPEC.tips.defaultTextSize,
lineHeight = textSize * 1.5, lineHeight = getListFrameLineHeight(textSize),
spacing = textSize / 3, spacing = getListFrameSpacing(textSize),
}: tipsOptions) { }: tipsOptions) {
const xmax = const layout = createTipsInListLayout(0);
BANGDREAM_RENDER_THEME.layout.contentWidth - const xmax = layout.textMaxWidth;
BANGDREAM_RENDER_THEME.layout.listIndent * 2;
let textImage: Canvas; let textImage: Canvas;
if (typeof text == 'string') { if (typeof text == 'string') {
textImage = drawText({ text, textSize, maxWidth: xmax, lineHeight }); textImage = drawText({ text, textSize, maxWidth: xmax, lineHeight });
@ -124,21 +141,22 @@ export function drawTipsInList({
spacing, spacing,
}); });
} else { } else {
textImage = new Canvas(1, 1); textImage = new Canvas(
} BANGDREAM_LIST_FRAME_SPEC.tips.emptyTextWidth,
const canvas = new Canvas( BANGDREAM_LIST_FRAME_SPEC.tips.emptyTextHeight,
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'); const ctx = canvas.getContext('2d');
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.subtlePanel; ctx.fillStyle = BANGDREAM_RENDER_THEME.color.subtlePanel;
ctx.fillRect( ctx.fillRect(
0, textLayout.backgroundX,
10, textLayout.backgroundY,
BANGDREAM_RENDER_THEME.layout.contentWidth, textLayout.backgroundWidth,
textImage.height, textLayout.backgroundHeight,
); );
ctx.drawImage(textImage, BANGDREAM_RENDER_THEME.layout.listIndent, 10); ctx.drawImage(textImage, textLayout.textX, textLayout.textY);
return canvas; return canvas;
} }
@ -224,15 +242,16 @@ export function drawListMerge(imageList: Array<Canvas | Image>): Canvas {
} }
} }
const canvas = new Canvas( const canvas = new Canvas(
BANGDREAM_RENDER_THEME.layout.contentWidth, BANGDREAM_LIST_FRAME_SPEC.merge.defaultWidth,
maxHeight, maxHeight,
); );
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
let x = 0; let x = 0;
const columnWidth = getMergedListColumnWidth(imageList.length);
for (let i = 0; i < imageList.length; i++) { for (let i = 0; i < imageList.length; i++) {
const element = imageList[i]; const element = imageList[i];
ctx.drawImage(element, x, 0); ctx.drawImage(element, x, 0);
x += BANGDREAM_RENDER_THEME.layout.contentWidth / imageList.length; x += columnWidth;
} }
return canvas; return canvas;
} }
@ -249,60 +268,15 @@ export function drawImageListCenter(
imageList: Array<Canvas | Image>, imageList: Array<Canvas | Image>,
maxWidth: number = BANGDREAM_RENDER_THEME.layout.contentWidth, maxWidth: number = BANGDREAM_RENDER_THEME.layout.contentWidth,
): Canvas { ): Canvas {
interface imageLine {
imageList: Array<Canvas | Image>;
width: number;
height: number;
}
const lineList: Array<imageLine> = [];
let tempWidth = 0;
let tempHeight = 0;
let tempImageList: Array<Canvas | Image> = [];
//换行函数
/**
* new线条
*/
function newLine() {
lineList.push({
imageList: tempImageList,
width: tempWidth,
height: tempHeight,
});
tempWidth = 0;
tempHeight = 0;
tempImageList = [];
}
if (imageList.length == 0) { 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 const lineList = createCenteredImageRows(imageList, maxWidth);
for (let i = 0; i < imageList.length; i++) { const height = getCenteredImageRowsHeight(lineList);
const element = imageList[i]; const canvas = new Canvas(maxWidth, height);
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 ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
//画每一行 //画每一行
const middleWidth = maxWidth / 2; const middleWidth = maxWidth / 2;
@ -328,24 +302,26 @@ export function drawImageListCenter(
* @returns * @returns
*/ */
export function drawListWithLine(textImageList: Array<Canvas | Image>): Canvas { export function drawListWithLine(textImageList: Array<Canvas | Image>): Canvas {
const x = 10;
let y = 10;
let height = 0; let height = 0;
for (let i = 0; i < textImageList.length; i++) { for (let i = 0; i < textImageList.length; i++) {
const element = textImageList[i]; const element = textImageList[i];
height += element.height; height += element.height;
} }
const canvas = new Canvas( const layout = createListWithLineLayout(height);
BANGDREAM_RENDER_THEME.layout.contentWidth, const canvas = new Canvas(layout.canvasWidth, layout.canvasHeight);
height + 10,
);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.separator; ctx.fillStyle = BANGDREAM_RENDER_THEME.color.separator;
const lineSpec = createVerticalSeparatorSpec(height + 20); const lineSpec = createVerticalSeparatorSpec(layout.lineHeight);
ctx.fillRect(lineSpec.startX, 10, 5, height + 20); ctx.fillRect(
lineSpec.startX,
layout.lineY,
layout.lineWidth,
layout.lineHeight,
);
let y = layout.contentY;
for (let i = 0; i < textImageList.length; i++) { for (let i = 0; i < textImageList.length; i++) {
const element = textImageList[i]; const element = textImageList[i];
ctx.drawImage(element, x, y); ctx.drawImage(element, layout.contentX, y);
y += element.height; y += element.height;
} }
return canvas; return canvas;

View File

@ -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,
});
});
});