diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index c898f69..c6f0fb6 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -390,6 +390,7 @@ export interface TsuguHook { - 已新增 `render-blocks/list-song-spec.ts`,收口歌曲列表单行封面、文本、难度块位置、列表内容宽度、分割线高度和外层行高计算;`list-song.ts` 改为消费 spec,歌曲列表绘制顺序和输出结构保持不变;`list-song-spec.spec.ts` 覆盖单行尺寸、难度块垂直居中和歌曲组列表尺寸,本地 `/查曲 136` 图片 smoke 输出非空。 - 已新增 `render-blocks/list-difficulty-spec.ts`,收口难度列表默认高度/间距、徽章兜底色、圆形布局和等级文字比例/居中计算;`list-difficulty.ts` 改为消费 spec,难度列表绘制顺序和输出结构保持不变;`list-difficulty-spec.spec.ts` 覆盖列表宽度、条目偏移、徽章颜色、圆形布局和文字居中,本地 `/查曲 136` 图片 smoke 输出非空。 - 已新增 `render-blocks/list-difficulty-detail-spec.ts`,收口玩家难度详情列表的徽章宽度/字号/圆角、正文宽度/行高、项画布高度和通用列表行高/间距;`list-difficulty-detail.ts` 改为消费 spec,玩家详情里的已通关/Full Combo/All Perfect 难度详情绘制顺序和输出结构保持不变;`list-difficulty-detail-spec.spec.ts` 覆盖徽章参数、正文绘制参数、项布局和列表框架参数,本地 `/查玩家 26591455 jp` 图片 smoke 输出非空。 +- 已新增 `render-blocks/list-band-detail-spec.ts`,收口玩家详情乐队等级、舞台挑战达成情况和乐队编成等级列表的 Logo 缩放、正文宽度/行高、项画布、通用列表框架和 Rank 等级图片布局;`list-band-detail.ts` 改为消费 spec,玩家详情里的乐队详情绘制顺序、资源 repository 和输出结构保持不变;`list-band-detail-spec.spec.ts` 覆盖 Logo/正文参数、项布局、列表框架、Rank 画布/图片位置和等级图片 Rank ID 封顶,本地 `/查玩家 26591455 jp` 图片 smoke 输出非空。 - smoke/Jenkins/远程调试卡点继续按已固化规则处理:同一卡点第二次尝试前必须改变可验证变量;Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home;线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录;PowerShell 双引号 here-string 中不要写 JS `${...}` 模板字面量,避免被 PowerShell 提前插值;smoke 没有真实图片落盘时必须失败。 ### Phase 6:策略 policy 和时间/档线规则 diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail-spec.ts new file mode 100644 index 0000000..71b7568 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail-spec.ts @@ -0,0 +1,131 @@ +interface ImageLike { + height: number; + width: number; +} + +export const BANGDREAM_BAND_DETAIL_LIST_SPEC = { + deckRank: { + height: 100, + levelHeight: 50, + levelOffsetX: 2, + levelY: 45, + maxLevelSpriteRankId: 4, + width: 150, + }, + item: { + height: 100, + logoWidth: 110, + textLineHeight: 40, + textOffsetY: 50, + width: 152, + }, + list: { + spacing: 0, + }, +} as const; + +/** + * 计算乐队详情 Logo 缩放参数。 + */ +export function createBandDetailLogoSpec() { + return { + widthMax: BANGDREAM_BAND_DETAIL_LIST_SPEC.item.logoWidth, + }; +} + +/** + * 计算乐队详情正文绘制参数。 + */ +export function createBandDetailTextSpec() { + const item = BANGDREAM_BAND_DETAIL_LIST_SPEC.item; + return { + lineHeight: item.textLineHeight, + maxWidth: item.width, + }; +} + +/** + * 计算乐队详情项画布和内容位置。 + * + * @param contentImage - 正文图片尺寸。 + */ +export function createBandDetailItemLayout(contentImage: ImageLike) { + const item = BANGDREAM_BAND_DETAIL_LIST_SPEC.item; + return { + canvasHeight: item.height, + canvasWidth: item.width, + logoX: (item.width - item.logoWidth) / 2, + logoY: 0, + textX: item.width / 2 - contentImage.width / 2, + textY: item.textOffsetY, + }; +} + +/** + * 计算乐队详情列表传给通用列表框架的尺寸。 + * + * @param firstItem - 第一个乐队详情项。 + */ +export function createBandDetailListFrameSpec(firstItem?: ImageLike) { + return { + lineHeight: firstItem?.height, + spacing: BANGDREAM_BAND_DETAIL_LIST_SPEC.list.spacing, + textSize: firstItem?.height, + }; +} + +/** + * 计算乐队编成等级画布尺寸。 + */ +export function createDeckRankCanvasSpec() { + const deckRank = BANGDREAM_BAND_DETAIL_LIST_SPEC.deckRank; + return { + height: deckRank.height, + width: deckRank.width, + }; +} + +/** + * 计算乐队编成等级图片位置。 + * + * @param rankImage - Rank 主图片尺寸。 + * @param levelImage - Rank 等级图片尺寸。 + */ +export function createDeckRankImageLayout( + rankImage: ImageLike, + levelImage?: ImageLike, +) { + const deckRank = BANGDREAM_BAND_DETAIL_LIST_SPEC.deckRank; + return { + rankX: (deckRank.width - rankImage.width) / 2, + rankY: 0, + ...(levelImage + ? { + levelX: + (deckRank.width + rankImage.width) / 2 + + deckRank.levelOffsetX - + levelImage.width, + levelY: deckRank.levelY, + } + : {}), + }; +} + +/** + * 计算乐队编成等级的等级图片缩放参数。 + */ +export function createDeckRankLevelImageSpec() { + return { + heightMax: BANGDREAM_BAND_DETAIL_LIST_SPEC.deckRank.levelHeight, + }; +} + +/** + * 计算可用于等级图片文件名的 Rank ID。 + * + * @param rankId - 原始 Rank ID。 + */ +export function normalizeDeckRankLevelSpriteRankId(rankId: number) { + const maxRankId = BANGDREAM_BAND_DETAIL_LIST_SPEC.deckRank.maxLevelSpriteRankId; + return rankId > maxRankId ? maxRankId : rankId; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail.ts index 843d7ea..1cd5333 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail.ts @@ -11,6 +11,16 @@ import { BANGDREAM_STAGE_CHALLENGE_BAND_ID, } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; import { deckRankResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/deck-rank-resource-repository'; +import { + createBandDetailItemLayout, + createBandDetailListFrameSpec, + createBandDetailLogoSpec, + createBandDetailTextSpec, + createDeckRankCanvasSpec, + createDeckRankImageLayout, + createDeckRankLevelImageSpec, + normalizeDeckRankLevelSpriteRankId, +} from './list-band-detail-spec'; interface drawBandDetailsInListOptions { [bandId: number]: Array; @@ -30,33 +40,30 @@ async function drawBandDetailsInList( for (const i in BandDetailsInListOptions) { const tempBand = new Band(parseInt(i)); const content = BandDetailsInListOptions[i]; - const maxWidth = 152; - const logoWidth = 110; const tempBandIcon = resizeImage({ image: await tempBand.getLogo(), - widthMax: logoWidth, + ...createBandDetailLogoSpec(), }); - const canvas = new Canvas(maxWidth, 100); - const ctx = canvas.getContext('2d'); - ctx.drawImage(tempBandIcon, (maxWidth - logoWidth) / 2, 0); + const textSpec = createBandDetailTextSpec(); const tempBandRankText = drawTextWithImages({ content, - maxWidth: maxWidth, - lineHeight: 40, + maxWidth: textSpec.maxWidth, + lineHeight: textSpec.lineHeight, }); - ctx.drawImage( - tempBandRankText, - maxWidth / 2 - tempBandRankText.width / 2, - 50, - ); + const layout = createBandDetailItemLayout(tempBandRankText); + const canvas = new Canvas(layout.canvasWidth, layout.canvasHeight); + const ctx = canvas.getContext('2d'); + ctx.drawImage(tempBandIcon, layout.logoX, layout.logoY); + ctx.drawImage(tempBandRankText, layout.textX, layout.textY); bandAndContentList.push(canvas); } + const frameSpec = createBandDetailListFrameSpec(bandAndContentList?.[0]); const bandAndContentListImage = drawList({ key, content: bandAndContentList, - spacing: 0, - lineHeight: bandAndContentList?.[0].height, - textSize: bandAndContentList?.[0].height, + spacing: frameSpec.spacing, + lineHeight: frameSpec.lineHeight, + textSize: frameSpec.textSize, }); return bandAndContentListImage; } @@ -145,27 +152,24 @@ export async function drawPlayerDeckTotalRatingInList( const rankName = userDeckTotalRatingMap[i].rank; let rankId = BANGDREAM_DECK_TOTAL_RATING_ID[rankName]; const rankImage = await loadRankImage(`rank_${rankId}`); - const widthMax = 150, - heightMax = 100; - const canvas = new Canvas(widthMax, heightMax); + const canvasSpec = createDeckRankCanvasSpec(); + const canvas = new Canvas(canvasSpec.width, canvasSpec.height); const ctx = canvas.getContext('2d'); - ctx.drawImage(rankImage, (widthMax - rankImage.width) / 2, 0); + const rankLayout = createDeckRankImageLayout(rankImage); + ctx.drawImage(rankImage, rankLayout.rankX, rankLayout.rankY); if (userDeckTotalRatingMap[i].level != 0) { - //ss与s字体相同 - if (rankId > 4) { - rankId = 4; - } + rankId = normalizeDeckRankLevelSpriteRankId(rankId); const rankLevelImage = resizeImage({ image: await loadRankImage( `rank_${rankId}_${userDeckTotalRatingMap[i].level}`, ), - heightMax: 50, + ...createDeckRankLevelImageSpec(), }); - ctx.drawImage( + const levelLayout = createDeckRankImageLayout( + rankImage, rankLevelImage, - (widthMax + rankImage.width) / 2 + 2 - rankLevelImage.width, - 45, ); + ctx.drawImage(rankLevelImage, levelLayout.levelX, levelLayout.levelY); } BandDetails[i] = [canvas]; } else { diff --git a/test/qqbot/plugins/bangDream/tsugu/list-band-detail-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/list-band-detail-spec.spec.ts new file mode 100644 index 0000000..ad05134 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/list-band-detail-spec.spec.ts @@ -0,0 +1,76 @@ +import { + BANGDREAM_BAND_DETAIL_LIST_SPEC, + createBandDetailItemLayout, + createBandDetailListFrameSpec, + createBandDetailLogoSpec, + createBandDetailTextSpec, + createDeckRankCanvasSpec, + createDeckRankImageLayout, + createDeckRankLevelImageSpec, + normalizeDeckRankLevelSpriteRankId, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-band-detail-spec'; + +describe('BangDream band detail list spec', () => { + it('keeps band detail item constants stable', () => { + expect(BANGDREAM_BAND_DETAIL_LIST_SPEC.item).toEqual({ + height: 100, + logoWidth: 110, + textLineHeight: 40, + textOffsetY: 50, + width: 152, + }); + expect(BANGDREAM_BAND_DETAIL_LIST_SPEC.list.spacing).toBe(0); + }); + + it('creates logo and text drawing specs', () => { + expect(createBandDetailLogoSpec()).toEqual({ widthMax: 110 }); + expect(createBandDetailTextSpec()).toEqual({ + lineHeight: 40, + maxWidth: 152, + }); + }); + + it('keeps band detail item layout stable', () => { + expect(createBandDetailItemLayout({ height: 36, width: 48 })).toEqual({ + canvasHeight: 100, + canvasWidth: 152, + logoX: 21, + logoY: 0, + textX: 52, + textY: 50, + }); + }); + + it('keeps list frame values derived from the first item', () => { + expect(createBandDetailListFrameSpec({ height: 100, width: 152 })).toEqual({ + lineHeight: 100, + spacing: 0, + textSize: 100, + }); + }); + + it('keeps deck rank canvas and image layout stable', () => { + expect(createDeckRankCanvasSpec()).toEqual({ height: 100, width: 150 }); + expect(createDeckRankLevelImageSpec()).toEqual({ heightMax: 50 }); + expect(createDeckRankImageLayout({ height: 32, width: 84 })).toEqual({ + rankX: 33, + rankY: 0, + }); + expect( + createDeckRankImageLayout( + { height: 32, width: 84 }, + { height: 20, width: 30 }, + ), + ).toEqual({ + levelX: 89, + levelY: 45, + rankX: 33, + rankY: 0, + }); + }); + + it('keeps deck rank level sprite rank id cap stable', () => { + expect(normalizeDeckRankLevelSpriteRankId(3)).toBe(3); + expect(normalizeDeckRankLevelSpriteRankId(5)).toBe(4); + }); +});