diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 284f222..84478b3 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` 源码 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 和时间/档线规则 diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-spec.ts new file mode 100644 index 0000000..913f0d4 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-spec.ts @@ -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 + ); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/detail-blocks.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/detail-blocks.ts index 7fc28b6..6c44154 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/detail-blocks.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/detail-blocks.ts @@ -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 = []; - 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 { - 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 { + const spec = BANGDREAM_DETAIL_BLOCK_SPEC.playerDetail; const list: Array = []; 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 = []; 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; } diff --git a/test/qqbot/plugins/bangDream/tsugu/detail-block-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/detail-block-spec.spec.ts new file mode 100644 index 0000000..d3af7fd --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/detail-block-spec.spec.ts @@ -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); + }); +});