diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 529aafb..d810419 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -388,6 +388,7 @@ export interface TsuguHook { - 已新增 `render-blocks/list-player-card-icon-spec.ts`,收口玩家详情主卡组展示顺序、默认行高、文本字号比例、卡牌间距比例和卡牌图标可见性标记;`list-player-card-icon-list.ts` 改为消费 spec,主卡组渲染顺序和输出结构保持不变;`list-player-card-icon-spec.spec.ts` 覆盖历史卡牌顺序、缺失条目跳过和字号/间距计算,本地 `/查玩家 26591455 jp` 图片 smoke 输出非空。 - 已新增 `render-blocks/list-card-icon-spec.ts`,收口通用卡牌图标列表默认行高、文本字号比例、卡牌间距比例、默认可见性标记和历史排序比较器;`list-card-icon-list.ts` 改为消费 spec,卡牌图标列表排序和输出结构保持不变;`list-card-icon-spec.spec.ts` 覆盖稀有度排序、优先卡牌类型排序、普通卡按 ID 排序和字号/间距计算,本地 `/查卡 472` 图片 smoke 输出非空。 - 已新增 `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 输出非空。 - 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-difficulty-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-difficulty-spec.ts new file mode 100644 index 0000000..176a0c8 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-difficulty-spec.ts @@ -0,0 +1,116 @@ +interface ImageLike { + height: number; + width: number; +} + +export const BANGDREAM_DIFFICULTY_LIST_SPEC = { + badge: { + arcStart: 0, + fallbackColor: '#f1f1f1', + fullCircleRadian: Math.PI * 2, + textMaxWidthRatio: 3, + textSizeRatio: 2 / 3, + }, + list: { + defaultImageHeight: 60, + defaultSpacing: 10, + }, +} as const; + +/** + * 计算难度列表画布宽度。 + * + * @param difficultyCount - 难度数量。 + * @param imageHeight - 单个难度徽章高度。 + * @param spacing - 难度徽章间距。 + */ +export function getDifficultyListCanvasWidth( + difficultyCount: number, + imageHeight: number, + spacing: number, +) { + return imageHeight * difficultyCount + (difficultyCount - 1) * spacing; +} + +/** + * 计算难度徽章在列表中的横向位置。 + * + * @param index - 难度下标。 + * @param imageHeight - 单个难度徽章高度。 + * @param spacing - 难度徽章间距。 + */ +export function getDifficultyListItemX( + index: number, + imageHeight: number, + spacing: number, +) { + return index * (imageHeight + spacing); +} + +/** + * 获取难度徽章颜色。 + * + * @param difficultyType - 难度类型。 + * @param colors - 难度颜色表。 + */ +export function getDifficultyBadgeColor( + difficultyType: number, + colors: ReadonlyArray, +) { + return ( + colors[difficultyType] ?? + BANGDREAM_DIFFICULTY_LIST_SPEC.badge.fallbackColor + ); +} + +/** + * 计算难度徽章圆形布局。 + * + * @param imageHeight - 难度徽章高度。 + */ +export function createDifficultyBadgeLayout(imageHeight: number) { + return { + arcEnd: BANGDREAM_DIFFICULTY_LIST_SPEC.badge.fullCircleRadian, + arcRadius: imageHeight / 2, + arcStart: BANGDREAM_DIFFICULTY_LIST_SPEC.badge.arcStart, + arcX: imageHeight / 2, + arcY: imageHeight / 2, + canvasHeight: imageHeight, + canvasWidth: imageHeight, + }; +} + +/** + * 计算难度等级文字绘制参数。 + * + * @param imageHeight - 难度徽章高度。 + * @param playLevel - 谱面等级。 + */ +export function createDifficultyLevelTextSpec( + imageHeight: number, + playLevel: number, +) { + return { + maxWidth: + imageHeight * BANGDREAM_DIFFICULTY_LIST_SPEC.badge.textMaxWidthRatio, + text: playLevel.toString(), + textSize: + imageHeight * BANGDREAM_DIFFICULTY_LIST_SPEC.badge.textSizeRatio, + }; +} + +/** + * 计算难度等级文字居中位置。 + * + * @param imageHeight - 难度徽章高度。 + * @param levelText - 已渲染的等级文字图片。 + */ +export function getDifficultyLevelTextPosition( + imageHeight: number, + levelText: ImageLike, +) { + return { + x: imageHeight / 2 - levelText.width / 2, + y: imageHeight / 2 - levelText.height / 2, + }; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-difficulty.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-difficulty.ts index a670ab6..a02ccdd 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-difficulty.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-difficulty.ts @@ -2,6 +2,15 @@ import { Canvas } from 'skia-canvas'; import { Song } from '@/qqbot/plugins/bangDream/tsugu/models/song'; import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'; import { difficultyColorList } from '@/qqbot/plugins/bangDream/tsugu/models/song'; +import { + BANGDREAM_DIFFICULTY_LIST_SPEC, + createDifficultyBadgeLayout, + createDifficultyLevelTextSpec, + getDifficultyBadgeColor, + getDifficultyLevelTextPosition, + getDifficultyListCanvasWidth, + getDifficultyListItemX, +} from './list-difficulty-spec'; /** * 在图片布局层中绘制难度列表。 @@ -13,12 +22,13 @@ import { difficultyColorList } from '@/qqbot/plugins/bangDream/tsugu/models/song */ export function drawDifficultyList( song: Song, - imageHeight: number = 60, - spacing: number = 10, + imageHeight: number = BANGDREAM_DIFFICULTY_LIST_SPEC.list + .defaultImageHeight, + spacing: number = BANGDREAM_DIFFICULTY_LIST_SPEC.list.defaultSpacing, ): Canvas { const difficultyCount = Object.keys(song.difficulty).length; const canvas = new Canvas( - imageHeight * difficultyCount + (difficultyCount - 1) * spacing, + getDifficultyListCanvasWidth(difficultyCount, imageHeight, spacing), imageHeight, ); const ctx = canvas.getContext('2d'); @@ -26,7 +36,7 @@ export function drawDifficultyList( const i = parseInt(d); ctx.drawImage( drawDifficulty(i, song.difficulty[i].playLevel, imageHeight), - i * (imageHeight + spacing), + getDifficultyListItemX(i, imageHeight, spacing), 0, ); } @@ -47,22 +57,20 @@ export function drawDifficulty( ) { const tempCanvas = new Canvas(imageHeight, imageHeight); const ctx = tempCanvas.getContext('2d'); - if (difficultyColorList[difficultyType] != undefined) { - ctx.fillStyle = difficultyColorList[difficultyType]; - } else { - ctx.fillStyle = '#f1f1f1'; - } - ctx.arc(imageHeight / 2, imageHeight / 2, imageHeight / 2, 0, 2 * Math.PI); - ctx.fill(); - const levelText = drawText({ - textSize: (imageHeight / 3) * 2, - text: playLevel.toString(), - maxWidth: imageHeight * 3, - }); - ctx.drawImage( - levelText, - imageHeight / 2 - levelText.width / 2, - imageHeight / 2 - levelText.height / 2, + const badgeLayout = createDifficultyBadgeLayout(imageHeight); + ctx.fillStyle = getDifficultyBadgeColor(difficultyType, difficultyColorList); + ctx.arc( + badgeLayout.arcX, + badgeLayout.arcY, + badgeLayout.arcRadius, + badgeLayout.arcStart, + badgeLayout.arcEnd, ); + ctx.fill(); + const levelText = drawText( + createDifficultyLevelTextSpec(imageHeight, playLevel), + ); + const position = getDifficultyLevelTextPosition(imageHeight, levelText); + ctx.drawImage(levelText, position.x, position.y); return tempCanvas; } diff --git a/test/qqbot/plugins/bangDream/tsugu/list-difficulty-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/list-difficulty-spec.spec.ts new file mode 100644 index 0000000..e18c3ec --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/list-difficulty-spec.spec.ts @@ -0,0 +1,51 @@ +import { + BANGDREAM_DIFFICULTY_LIST_SPEC, + createDifficultyBadgeLayout, + createDifficultyLevelTextSpec, + getDifficultyBadgeColor, + getDifficultyLevelTextPosition, + getDifficultyListCanvasWidth, + getDifficultyListItemX, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-difficulty-spec'; + +describe('BangDream difficulty list spec', () => { + it('keeps default difficulty list dimensions stable', () => { + expect(BANGDREAM_DIFFICULTY_LIST_SPEC.list.defaultImageHeight).toBe(60); + expect(BANGDREAM_DIFFICULTY_LIST_SPEC.list.defaultSpacing).toBe(10); + expect(getDifficultyListCanvasWidth(5, 60, 10)).toBe(340); + expect(getDifficultyListItemX(3, 60, 10)).toBe(210); + }); + + it('keeps difficulty badge color fallback stable', () => { + expect(getDifficultyBadgeColor(1, ['#111111', '#222222'])).toBe('#222222'); + expect(getDifficultyBadgeColor(6, ['#111111', '#222222'])).toBe( + '#f1f1f1', + ); + }); + + it('keeps difficulty badge circle layout stable', () => { + expect(createDifficultyBadgeLayout(45)).toEqual({ + arcEnd: Math.PI * 2, + arcRadius: 22.5, + arcStart: 0, + arcX: 22.5, + arcY: 22.5, + canvasHeight: 45, + canvasWidth: 45, + }); + }); + + it('keeps difficulty level text layout stable', () => { + expect(createDifficultyLevelTextSpec(45, 26)).toEqual({ + maxWidth: 135, + text: '26', + textSize: 30, + }); + expect(getDifficultyLevelTextPosition(45, { height: 12, width: 20 })).toEqual( + { + x: 12.5, + y: 16.5, + }, + ); + }); +});