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`
- TS 文件:初始基线 92当前 `tsugu` 源码 125
- TS 文件:初始基线 92当前 `tsugu` 源码 126
- 函数节点481其中稳定函数 410匿名/内联回调 71
- 源码 JSDoc稳定函数 410/410 已覆盖
- 变量声明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`,验证谱面预览重构后图片输出非空且视觉结构正常。
- 已新增 `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`,验证卡牌详情图在规格收口后仍能正常输出。
- 已新增 `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 和时间/档线规则

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