refactor: 收口 BangDream 详情区块规格

This commit is contained in:
sunlei 2026-06-06 23:33:29 +08:00
parent 319577cffa
commit 08627646e5
4 changed files with 166 additions and 48 deletions

View File

@ -14,7 +14,7 @@
## 当前事实 ## 当前事实
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu` - Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
- TS 文件:初始基线 92当前 `tsugu` 源码 125 - TS 文件:初始基线 92当前 `tsugu` 源码 126
- 函数节点481其中稳定函数 410匿名/内联回调 71 - 函数节点481其中稳定函数 410匿名/内联回调 71
- 源码 JSDoc稳定函数 410/410 已覆盖 - 源码 JSDoc稳定函数 410/410 已覆盖
- 变量声明1896 - 变量声明1896
@ -354,6 +354,8 @@ export interface TsuguHook {
- 已新增 `song-chart-preview-spec.spec.ts`,覆盖 BPM 变速时间、十六分单点、双押、滑条 bar/tick/end、布局列数和音符分类本地生成 `song-chart-preview-spec-136-expert.jpg`,验证谱面预览重构后图片输出非空且视觉结构正常。 - 已新增 `song-chart-preview-spec.spec.ts`,覆盖 BPM 变速时间、十六分单点、双押、滑条 bar/tick/end、布局列数和音符分类本地生成 `song-chart-preview-spec-136-expert.jpg`,验证谱面预览重构后图片输出非空且视觉结构正常。
- 已新增 `render-blocks/card-art-spec.ts`,收口卡牌图标/插画边框 URL 生成、icon/illustration 画布尺寸、属性/乐队/星级/突破/技能等级坐标;`card-art.ts` 改为消费 spec 和 URL factory下载与绘制顺序保持不变。 - 已新增 `render-blocks/card-art-spec.ts`,收口卡牌图标/插画边框 URL 生成、icon/illustration 画布尺寸、属性/乐队/星级/突破/技能等级坐标;`card-art.ts` 改为消费 spec 和 URL factory下载与绘制顺序保持不变。
- 已新增 `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数据查询和绘制顺序保持不变。
- 已新增 `detail-block-spec.spec.ts`,覆盖歌曲详情尺寸、角色/玩家详情尺寸和 meta 相对百分比舍入;本地生成 `detail-spec-song-136.jpg`、`detail-spec-event-50.jpg`、`detail-spec-character-1.jpg`,验证详情区块规格收口后查曲/查活动/查角色图片输出正常。
### Phase 6策略 policy 和时间/档线规则 ### Phase 6策略 policy 和时间/档线规则

View File

@ -0,0 +1,62 @@
export const BANGDREAM_DETAIL_BLOCK_SPEC = {
characterHalf: {
footerHeight: 100,
height: 800,
idTextSize: 30,
illustrationInsetY: 2,
illustrationPaddingHeight: 20,
nameTextSize: 40,
opaqueOpacity: 1,
overlayOpacity: 0.25,
radius: 20,
strokeWidth: 4,
width: 250,
},
metaList: {
maxRows: 50,
percentRoundScale: 100,
percentScale: 100,
},
playerDetail: {
dataBlockOpacity: 1,
dataBlockY: 900,
degreeGapWidth: 20,
illustrationHeight: 1000,
illustrationWidth: 1000,
infoTextSize: 35,
nameTextSize: 75,
spacerHeight: 25,
},
songDetail: {
detailSeparator: {
endX: 360,
height: 20,
width: 365,
},
detailTextSize: 30,
difficultyGap: 10,
difficultyHeight: 60,
difficultyX: 435,
horizontalGapWidth: 35,
jacketMaxWidth: 400,
metaSeparatorHeight: 10,
rightBottomGapHeight: 60,
textMaxWidth: 365,
titleTextSize: 40,
},
} as const;
/**
* meta
*
* @param meta - meta
* @param maxMeta - meta
*/
export function getRelativeMetaPercent(meta: number, maxMeta: number): number {
const { percentRoundScale, percentScale } =
BANGDREAM_DETAIL_BLOCK_SPEC.metaList;
return (
Math.round((meta / maxMeta) * percentScale * percentRoundScale) /
percentRoundScale
);
}

View File

@ -49,18 +49,22 @@ import { Canvas, Image } from 'skia-canvas';
import { Band } from '@/qqbot/plugins/bangDream/tsugu/models/band'; import { Band } from '@/qqbot/plugins/bangDream/tsugu/models/band';
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme'; import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
import { createHorizontalSeparatorSpec } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/layout-spec'; import { createHorizontalSeparatorSpec } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/layout-spec';
import {
BANGDREAM_DETAIL_BLOCK_SPEC,
getRelativeMetaPercent,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-spec';
const songDetailSeparator = drawDottedLine( const songDetailSeparator = drawDottedLine(
createHorizontalSeparatorSpec({ createHorizontalSeparatorSpec({
width: 365, width: BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.detailSeparator.width,
height: 20, height: BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.detailSeparator.height,
endX: 360, endX: BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.detailSeparator.endX,
}), }),
); );
const songMetaSeparator = drawDottedLine( const songMetaSeparator = drawDottedLine(
createHorizontalSeparatorSpec({ createHorizontalSeparatorSpec({
height: 10, height: BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.metaSeparatorHeight,
}), }),
); );
@ -174,18 +178,19 @@ export async function drawSongDataBlock(
text?: string, text?: string,
displayedServerList: Server[] = globalDefaultServer, displayedServerList: Server[] = globalDefaultServer,
) { ) {
const spec = BANGDREAM_DETAIL_BLOCK_SPEC.songDetail;
const server = getServerByPriority(song.publishedAt, displayedServerList); const server = getServerByPriority(song.publishedAt, displayedServerList);
const songJacketCanvas = resizeImage({ const songJacketCanvas = resizeImage({
image: await song.getSongJacketImage(), image: await song.getSongJacketImage(),
widthMax: 400, widthMax: spec.jacketMaxWidth,
}); });
const songName = song.musicTitle[server]; const songName = song.musicTitle[server];
const bandName = new Band(song.bandId).bandName[server]; const bandName = new Band(song.bandId).bandName[server];
const songTipsName = song.getTagName(); const songTipsName = song.getTagName();
const songNameImage = drawText({ const songNameImage = drawText({
text: songName, text: songName,
textSize: 40, textSize: spec.titleTextSize,
maxWidth: 365, maxWidth: spec.textMaxWidth,
}); });
let songDetail = `${bandName}\n${songTipsName}\nID:${song.songId}`; let songDetail = `${bandName}\n${songTipsName}\nID:${song.songId}`;
if (text != undefined) { if (text != undefined) {
@ -193,23 +198,31 @@ export async function drawSongDataBlock(
} }
const songDetailImage = drawText({ const songDetailImage = drawText({
text: songDetail, text: songDetail,
textSize: 30, textSize: spec.detailTextSize,
maxWidth: 365, maxWidth: spec.textMaxWidth,
}); });
const difficultyImage = drawDifficultyList(song, 60, 10); const difficultyImage = drawDifficultyList(
song,
spec.difficultyHeight,
spec.difficultyGap,
);
const rightCanvas = stackImage([ const rightCanvas = stackImage([
songNameImage, songNameImage,
songDetailSeparator, songDetailSeparator,
songDetailImage, songDetailImage,
new Canvas(1, 60), new Canvas(1, spec.rightBottomGapHeight),
]); ]);
const canvas = stackImageHorizontal([ const canvas = stackImageHorizontal([
songJacketCanvas, songJacketCanvas,
new Canvas(35, 1), new Canvas(spec.horizontalGapWidth, 1),
rightCanvas, rightCanvas,
]); ]);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.drawImage(difficultyImage, 435, canvas.height - difficultyImage.height); ctx.drawImage(
difficultyImage,
spec.difficultyX,
canvas.height - difficultyImage.height,
);
return drawDataBlock({ list: [canvas] }); return drawDataBlock({ list: [canvas] });
} }
@ -250,9 +263,10 @@ export async function drawSongMetaListDataBlock(
const tempSongMetaRanking = songMetaRanking[server].data; const tempSongMetaRanking = songMetaRanking[server].data;
for (let j = 0; j < tempSongMetaRanking.length; j++) { for (let j = 0; j < tempSongMetaRanking.length; j++) {
if (tempSongMetaRanking[j].difficulty == difficultyId) { if (tempSongMetaRanking[j].difficulty == difficultyId) {
let percent = const percent = getRelativeMetaPercent(
(tempSongMetaRanking[j].meta / metaRanking[server].maxMeta) * 100; tempSongMetaRanking[j].meta,
percent = Math.round(percent * 100) / 100; metaRanking[server].maxMeta,
);
text += `${serverNameFullList[server]}: ${percent}% #${tempSongMetaRanking[j].rank + 1} `; text += `${serverNameFullList[server]}: ${percent}% #${tempSongMetaRanking[j].rank + 1} `;
} }
} }
@ -280,14 +294,14 @@ export async function drawMetaListDataBlock(
const metaRanking = getMetaRanking(withFever, server); const metaRanking = getMetaRanking(withFever, server);
const maxMeta = metaRanking[0].meta; const maxMeta = metaRanking[0].meta;
const list: Array<Image | Canvas> = []; const list: Array<Image | Canvas> = [];
const max = 50; const maxRows = BANGDREAM_DETAIL_BLOCK_SPEC.metaList.maxRows;
for (let i = 0; i < max; i++) { for (let i = 0; i < maxRows; i++) {
if (i >= metaRanking.length) { if (i >= metaRanking.length) {
break; break;
} }
const song = new Song(metaRanking[i].songId); const song = new Song(metaRanking[i].songId);
const difficultyId = metaRanking[i].difficulty; const difficultyId = metaRanking[i].difficulty;
const percent = (metaRanking[i].meta / maxMeta) * 100; const percent = getRelativeMetaPercent(metaRanking[i].meta, maxMeta);
list.push( list.push(
await drawSongInList( await drawSongInList(
song, song,
@ -312,8 +326,9 @@ export async function drawCharacterHalfBlock(
character: Character, character: Character,
displayedServerList: Server[] = globalDefaultServer, displayedServerList: Server[] = globalDefaultServer,
): Promise<Canvas> { ): Promise<Canvas> {
const width = 250; const spec = BANGDREAM_DETAIL_BLOCK_SPEC.characterHalf;
const height = 800; const width = spec.width;
const height = spec.height;
const canvas = new Canvas(width, height); const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
await character.initFull(false); await character.initFull(false);
@ -324,32 +339,32 @@ export async function drawCharacterHalfBlock(
drawRoundedRect({ drawRoundedRect({
width, width,
height, height,
radius: 20, radius: spec.radius,
color, color,
opacity: 0.25, opacity: spec.overlayOpacity,
}), }),
0, 0,
0, 0,
); );
const characterIllustration = resizeImage({ const characterIllustration = resizeImage({
image: await character.getIllustration(), image: await character.getIllustration(),
heightMax: height - 20, heightMax: height - spec.illustrationPaddingHeight,
}); });
ctx.drawImage( ctx.drawImage(
characterIllustration, characterIllustration,
width / 2 - characterIllustration.width / 2, width / 2 - characterIllustration.width / 2,
2, spec.illustrationInsetY,
); );
ctx.drawImage( ctx.drawImage(
drawRoundedRect({ drawRoundedRect({
width, width,
height, height,
radius: 20, radius: spec.radius,
opacity: 1, opacity: spec.opaqueOpacity,
color: color + '00', color: color + '00',
strokeColor: color, strokeColor: color,
strokeWidth: 4, strokeWidth: spec.strokeWidth,
}), }),
0, 0,
0, 0,
@ -357,13 +372,13 @@ export async function drawCharacterHalfBlock(
ctx.drawImage( ctx.drawImage(
drawRoundedRect({ drawRoundedRect({
width, width,
height: 100, height: spec.footerHeight,
radius: 20, radius: spec.radius,
opacity: 1, opacity: spec.opaqueOpacity,
color, color,
}), }),
0, 0,
height - 100, height - spec.footerHeight,
); );
const list: Canvas[] = []; const list: Canvas[] = [];
@ -373,19 +388,19 @@ export async function drawCharacterHalfBlock(
); );
const nameTextImage = drawText({ const nameTextImage = drawText({
text: character.characterName[server], text: character.characterName[server],
textSize: 40, textSize: spec.nameTextSize,
color: BANGDREAM_RENDER_THEME.color.surface, color: BANGDREAM_RENDER_THEME.color.surface,
maxWidth: width, maxWidth: width,
}); });
list.push(drawImageListCenter([nameTextImage], width)); list.push(drawImageListCenter([nameTextImage], width));
const idTextImage = drawText({ const idTextImage = drawText({
text: `ID: ${character.characterId}`, text: `ID: ${character.characterId}`,
textSize: 30, textSize: spec.idTextSize,
color: BANGDREAM_RENDER_THEME.color.surface, color: BANGDREAM_RENDER_THEME.color.surface,
maxWidth: width, maxWidth: width,
}); });
list.push(drawImageListCenter([idTextImage], width)); list.push(drawImageListCenter([idTextImage], width));
ctx.drawImage(stackImage(list), 0, height - 100); ctx.drawImage(stackImage(list), 0, height - spec.footerHeight);
return canvas; return canvas;
} }
@ -398,20 +413,21 @@ export async function drawCharacterHalfBlock(
export async function drawPlayerDetailBlockWithIllustration( export async function drawPlayerDetailBlockWithIllustration(
player: Player, player: Player,
): Promise<Canvas> { ): Promise<Canvas> {
const spec = BANGDREAM_DETAIL_BLOCK_SPEC.playerDetail;
const list: Array<Canvas | Image> = []; const list: Array<Canvas | Image> = [];
const playerText = drawText({ const playerText = drawText({
text: player.profile.userName, text: player.profile.userName,
maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth, maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth,
textSize: 75, textSize: spec.nameTextSize,
}); });
list.push(drawImageListCenter([playerText])); list.push(drawImageListCenter([playerText]));
const levelText = drawText({ const levelText = drawText({
text: `等级 ${player.profile.rank}`, text: `等级 ${player.profile.rank}`,
maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth, maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth,
textSize: 35, textSize: spec.infoTextSize,
}); });
list.push(drawImageListCenter([levelText])); list.push(drawImageListCenter([levelText]));
list.push(new Canvas(1, 25)); list.push(new Canvas(1, spec.spacerHeight));
const degreeImageList: Array<Canvas | Image> = []; const degreeImageList: Array<Canvas | Image> = [];
const userProfileDegreeMap = player.profile.userProfileDegreeMap.entries; const userProfileDegreeMap = player.profile.userProfileDegreeMap.entries;
@ -420,19 +436,19 @@ export async function drawPlayerDetailBlockWithIllustration(
degreeImageList.push( degreeImageList.push(
await drawDegree(new Degree(tempDegree.degreeId), player.server), await drawDegree(new Degree(tempDegree.degreeId), player.server),
); );
degreeImageList.push(new Canvas(20, 1)); degreeImageList.push(new Canvas(spec.degreeGapWidth, 1));
} }
degreeImageList.pop(); degreeImageList.pop();
list.push(drawImageListCenter(degreeImageList)); list.push(drawImageListCenter(degreeImageList));
list.push(new Canvas(1, 25)); list.push(new Canvas(1, spec.spacerHeight));
const introductionText = drawText({ const introductionText = drawText({
text: player.profile.introduction, text: player.profile.introduction,
maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth, maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth,
textSize: 35, textSize: spec.infoTextSize,
}); });
list.push(drawImageListCenter([introductionText])); list.push(drawImageListCenter([introductionText]));
list.push(new Canvas(1, 25)); list.push(new Canvas(1, spec.spacerHeight));
const userId = player.profile.publishUserIdFlg const userId = player.profile.publishUserIdFlg
? player.profile.userId.toString() ? player.profile.userId.toString()
@ -440,10 +456,10 @@ export async function drawPlayerDetailBlockWithIllustration(
const idText = drawTextWithImages({ const idText = drawTextWithImages({
content: [await getIcon(player.server), userId], content: [await getIcon(player.server), userId],
maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth, maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth,
textSize: 35, textSize: spec.infoTextSize,
}); });
list.push(drawImageListCenter([idText])); list.push(drawImageListCenter([idText]));
const dataBlock = drawDataBlock({ list, opacity: 1 }); const dataBlock = drawDataBlock({ list, opacity: spec.dataBlockOpacity });
const userIllustrationData = player.profile.userIllustration; const userIllustrationData = player.profile.userIllustration;
const illustrationCard = new Card(userIllustrationData.cardId); const illustrationCard = new Card(userIllustrationData.cardId);
@ -451,10 +467,19 @@ export async function drawPlayerDetailBlockWithIllustration(
userIllustrationData.trainingStatus, userIllustrationData.trainingStatus,
); );
const titleImage = drawTitle('查询', '玩家信息'); const titleImage = drawTitle('查询', '玩家信息');
const canvas = new Canvas(1000, 900 + dataBlock.height); const canvas = new Canvas(
spec.illustrationWidth,
spec.dataBlockY + dataBlock.height,
);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.drawImage(illustrationImage, 0, 0, 1000, 1000); ctx.drawImage(
illustrationImage,
0,
0,
spec.illustrationWidth,
spec.illustrationHeight,
);
ctx.drawImage(titleImage, 0, 0); ctx.drawImage(titleImage, 0, 0);
ctx.drawImage(dataBlock, 0, 900); ctx.drawImage(dataBlock, 0, spec.dataBlockY);
return canvas; return canvas;
} }

View File

@ -0,0 +1,29 @@
import {
BANGDREAM_DETAIL_BLOCK_SPEC,
getRelativeMetaPercent,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-spec';
describe('BangDream detail block spec', () => {
it('keeps song detail dimensions stable', () => {
expect(BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.jacketMaxWidth).toBe(400);
expect(BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.textMaxWidth).toBe(365);
expect(BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.difficultyX).toBe(435);
expect(BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.detailSeparator.endX).toBe(
360,
);
});
it('keeps character and player detail dimensions stable', () => {
expect(BANGDREAM_DETAIL_BLOCK_SPEC.characterHalf.width).toBe(250);
expect(BANGDREAM_DETAIL_BLOCK_SPEC.characterHalf.height).toBe(800);
expect(BANGDREAM_DETAIL_BLOCK_SPEC.playerDetail.illustrationWidth).toBe(
1000,
);
expect(BANGDREAM_DETAIL_BLOCK_SPEC.playerDetail.dataBlockY).toBe(900);
});
it('rounds relative meta percent with the historical precision', () => {
expect(getRelativeMetaPercent(333, 400)).toBe(83.25);
expect(getRelativeMetaPercent(1, 3)).toBe(33.33);
});
});