From 4220efa6b2ad605a5519f1e95014292c1a612416 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 7 Jun 2026 00:38:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20BangDream=20?= =?UTF-8?q?=E8=AF=95=E7=82=BC=E6=B8=B2=E6=9F=93=E8=A7=84=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 3 + .../tsugu/command-renderers/event-stage.ts | 32 +----- .../tsugu/render-blocks/event-stage-spec.ts | 100 ++++++++++++++++++ .../tsugu/render-blocks/list-event-stage.ts | 57 ++++++---- .../bangDream/tsugu/event-stage-spec.spec.ts | 55 ++++++++++ 5 files changed, 200 insertions(+), 47 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec.ts create mode 100644 test/qqbot/plugins/bangDream/tsugu/event-stage-spec.spec.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index ce5459b..6c96dd1 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -363,6 +363,9 @@ export interface TsuguHook { - 已新增 `render-blocks/gacha-simulate-spec.ts`,收口抽卡模拟网格宽度、单抽/汇总混排尺寸、重复卡叠层、数量右对齐和卡池横幅布局;`gacha-simulate.ts` 改为消费 spec,抽卡概率和卡池选择逻辑保持不变。 - 已新增 `gacha-simulate-spec.spec.ts`,覆盖 10 抽网格、汇总模式、重复卡叠层、计数字样和横幅尺寸;本地生成 `gacha-simulate-spec-10-259.jpg` 和 `gacha-simulate-spec-50-259-after-cache-fix.jpg`,验证抽卡模拟两种布局模式图片输出正常。 - 抽卡模拟 50 抽 smoke 暴露资源下载短时失败会把 URL 写入无过期错误缓存,导致后续重试直接输出 `asset error`;已改为只有 HTTP 404 进入错误缓存,超时/网络抖动不污染 URL,并新增 `file-cache-client.spec.ts` 覆盖瞬时失败后可再次下载、404 缓存缺失资源两种路径。 +- 已新增 `render-blocks/event-stage-spec.ts`,收口试炼列表最大列高、歌曲单元格/封面/ID/难度条尺寸和试炼类型顶部文字规格;`event-stage.ts` 与 `list-event-stage.ts` 改为消费 spec,活动/歌曲选择和输出结构保持不变。 +- 已新增 `event-stage-spec.spec.ts`,覆盖试炼歌曲行尺寸、类型顶部文字规格和按列高拆分算法;本地生成 `event-stage-spec-310.jpg`、`event-stage-spec-310-meta.jpg`,验证 `/查试炼 310` 与 `/查试炼 310 -m` 图片输出正常。 +- smoke/Jenkins/远程调试卡点继续按已固化规则处理:同一卡点第二次尝试前必须改变可验证变量;Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home;线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录。 ### Phase 6:策略 policy 和时间/档线规则 diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-stage.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-stage.ts index 8ebcf97..a72bbb4 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-stage.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-stage.ts @@ -6,7 +6,6 @@ import { } from '@/qqbot/plugins/bangDream/tsugu/models/event-stage'; import { serverNameFullList } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { drawTitle } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/title'; -import { Canvas } from 'skia-canvas'; import { drawEventStageTypeTop, drawEventStageSongHorizontal, @@ -17,6 +16,7 @@ import { stackImage, stackImageHorizontal, } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; +import { splitEventStageImagesByColumnHeight } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec'; /** * 在QQBot 图片视图层中绘制活动试炼。 @@ -81,33 +81,9 @@ export async function drawEventStage( const eventStageResults = await Promise.all(eventStagePromises); //将活动stage图片纵向并横向合并 - let tempH = 0; - const maxHeight = 6000; - - let tempEventStageImageList: Canvas[] = []; - const eventStageImageListHorizontal: Canvas[] = []; - - for (let i = 0; i < eventStageResults.length; i++) { - const tempImage = eventStageResults[i]; - tempH += tempImage.height; - if (tempH > maxHeight) { - if (tempEventStageImageList.length > 0) { - eventStageImageListHorizontal.push( - drawDataBlock({ list: tempEventStageImageList }), - ); - } - tempEventStageImageList = []; - tempH = tempImage.height; - } - tempEventStageImageList.push(tempImage); - - if (i == eventStageResults.length - 1) { - eventStageImageListHorizontal.push( - drawDataBlock({ list: tempEventStageImageList }), - ); - } - } - + const eventStageImageListHorizontal = splitEventStageImagesByColumnHeight( + eventStageResults, + ).map((list) => drawDataBlock({ list })); const eventStageListImage = stackImageHorizontal( eventStageImageListHorizontal, ); diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec.ts new file mode 100644 index 0000000..cccc3ab --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec.ts @@ -0,0 +1,100 @@ +export interface EventStageCanvasLike { + height: number; +} + +export const BANGDREAM_EVENT_STAGE_SPEC = { + list: { + maxColumnHeight: 6000, + }, + songRow: { + difficultyHeightScale: 10, + jacketInsetX: 3, + jacketY: 0, + songCountPerRow: 8, + songId: { + color: '#a7a7a7', + fontSize: 16, + x: 4, + y: 108, + }, + verticalPadding: 10, + width: 800, + }, + typeTop: { + fontSize: 25, + rightPadding: 50, + strokeWidth: 4.5, + textColor: '#ffffff', + textX: 20, + yOffset: -2, + }, +} as const; + +/** + * 获取试炼歌曲单元格宽度。 + */ +export function getEventStageSongCellWidth(): number { + return ( + BANGDREAM_EVENT_STAGE_SPEC.songRow.width / + BANGDREAM_EVENT_STAGE_SPEC.songRow.songCountPerRow + ); +} + +/** + * 获取试炼歌曲封面绘制高度。 + */ +export function getEventStageSongJacketHeight(): number { + return getEventStageSongCellWidth() - 6; +} + +/** + * 获取试炼歌曲单元格高度。 + */ +export function getEventStageSongCellHeight(): number { + return (getEventStageSongCellWidth() / 180) * 210; +} + +/** + * 获取试炼歌曲横向行尺寸。 + */ +export function getEventStageSongRowSize(): { height: number; width: number } { + return { + height: + getEventStageSongCellHeight() + + BANGDREAM_EVENT_STAGE_SPEC.songRow.verticalPadding, + width: BANGDREAM_EVENT_STAGE_SPEC.songRow.width, + }; +} + +/** + * 将试炼 stage 图片按最大列高拆成多列。 + * + * @param images - 试炼 stage 图片列表。 + * @param maxHeight - 单列最大高度,未传入时使用默认值。 + */ +export function splitEventStageImagesByColumnHeight< + T extends EventStageCanvasLike, +>( + images: T[], + maxHeight = BANGDREAM_EVENT_STAGE_SPEC.list.maxColumnHeight, +): T[][] { + const columns: T[][] = []; + let currentColumn: T[] = []; + let currentHeight = 0; + + for (const image of images) { + currentHeight += image.height; + if (currentHeight > maxHeight && currentColumn.length > 0) { + columns.push(currentColumn); + currentColumn = []; + currentHeight = image.height; + } + currentColumn.push(image); + } + + if (currentColumn.length > 0) { + columns.push(currentColumn); + } + + return columns; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-event-stage.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-event-stage.ts index 5b999b4..4363bb7 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-event-stage.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-event-stage.ts @@ -15,6 +15,13 @@ import { formatTime } from './list-time'; import { setFontStyle } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'; import { stackImageHorizontal } from './image-stack'; import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; +import { + BANGDREAM_EVENT_STAGE_SPEC, + getEventStageSongCellHeight, + getEventStageSongCellWidth, + getEventStageSongJacketHeight, + getEventStageSongRowSize, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec'; FontLibrary.use('old', [`${assetsRootPath}/Fonts/old.ttf`]); @@ -63,19 +70,22 @@ export async function drawEventStageTypeTop(stage: Stage): Promise { eventStageTypeTopImage.height, ); const ctx = canvas.getContext('2d'); + const typeTopSpec = BANGDREAM_EVENT_STAGE_SPEC.typeTop; ctx.drawImage(eventStageTypeTopImage, 0, 0); ctx.textBaseline = 'middle'; - setFontStyle(ctx, 25, 'old'); + setFontStyle(ctx, typeTopSpec.fontSize, 'old'); ctx.textAlign = 'left'; - ctx.fillStyle = '#ffffff'; - ctx.lineWidth = 4.5; + ctx.fillStyle = typeTopSpec.textColor; + ctx.lineWidth = typeTopSpec.strokeWidth; ctx.strokeStyle = stageTypeTextStrokeColor[type]; - ctx.strokeText(timeText, 20, canvas.height / 2 - 2); - ctx.fillText(timeText, 20, canvas.height / 2 - 2); + const textY = canvas.height / 2 + typeTopSpec.yOffset; + ctx.strokeText(timeText, typeTopSpec.textX, textY); + ctx.fillText(timeText, typeTopSpec.textX, textY); ctx.textAlign = 'right'; - ctx.strokeText(typeName, canvas.width - 50, canvas.height / 2 - 2); - ctx.fillText(typeName, canvas.width - 50, canvas.height / 2 - 2); + const typeNameX = canvas.width - typeTopSpec.rightPadding; + ctx.strokeText(typeName, typeNameX, textY); + ctx.fillText(typeName, typeNameX, textY); //如果是当前进行中活动,额外画标志 if (new Date() >= new Date(startAt) && new Date() <= new Date(endAt)) { @@ -98,23 +108,26 @@ async function drawSongInEventStageSongHorizontal( meta: boolean, ): Promise { //绘制活动中的每个歌曲(包括难度) - const canvas = new Canvas(800 / 8, (800 / 8 / 180) * 210); + const cellWidth = getEventStageSongCellWidth(); + const cellHeight = getEventStageSongCellHeight(); + const jacketImageHeight = getEventStageSongJacketHeight(); + const songRowSpec = BANGDREAM_EVENT_STAGE_SPEC.songRow; + const canvas = new Canvas(cellWidth, cellHeight); const ctx = canvas.getContext('2d'); - const jacketImageHeight = 800 / 8 - 6; ctx.drawImage( await song.getSongJacketImage(), - 3, - 0, + songRowSpec.jacketInsetX, + songRowSpec.jacketY, jacketImageHeight, jacketImageHeight, ); ctx.textAlign = 'start'; ctx.textBaseline = 'middle'; - setFontStyle(ctx, 16, 'old'); - ctx.fillStyle = '#a7a7a7'; - ctx.fillText(`ID:${song.songId}`, 4, 108); + setFontStyle(ctx, songRowSpec.songId.fontSize, 'old'); + ctx.fillStyle = songRowSpec.songId.color; + ctx.fillText(`ID:${song.songId}`, songRowSpec.songId.x, songRowSpec.songId.y); //难度,高度为meta*10像素 /** @@ -130,9 +143,10 @@ async function drawSongInEventStageSongHorizontal( ctx.fillStyle = difficultyColorList[difficultyId]; ctx.fillRect( 0, - jacketImageHeight - meta * 10, + jacketImageHeight - + meta * BANGDREAM_EVENT_STAGE_SPEC.songRow.difficultyHeightScale, jacketImageHeight, - meta * 10, + meta * BANGDREAM_EVENT_STAGE_SPEC.songRow.difficultyHeightScale, ); return canvas; } @@ -145,7 +159,11 @@ async function drawSongInEventStageSongHorizontal( } const difficultyLineGraph = stackImageHorizontal(difficultyLineGraphList); - ctx.drawImage(difficultyLineGraph, 3, 0); + ctx.drawImage( + difficultyLineGraph, + songRowSpec.jacketInsetX, + songRowSpec.jacketY, + ); } return canvas; @@ -165,13 +183,14 @@ export async function drawEventStageSongHorizontal( //绘制活动中的歌曲列表(横向) const songIdList = stage.songIdList; - const canvas = new Canvas(800, (800 / 8 / 180) * 210 + 10); + const songRowSize = getEventStageSongRowSize(); + const canvas = new Canvas(songRowSize.width, songRowSize.height); const ctx = canvas.getContext('2d'); for (let i = 0; i < songIdList.length; i++) { const song = new Song(songIdList[i]); ctx.drawImage( await drawSongInEventStageSongHorizontal(song, meta), - (800 / 8) * i, + getEventStageSongCellWidth() * i, 0, ); } diff --git a/test/qqbot/plugins/bangDream/tsugu/event-stage-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/event-stage-spec.spec.ts new file mode 100644 index 0000000..5ee2d3d --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/event-stage-spec.spec.ts @@ -0,0 +1,55 @@ +import { + BANGDREAM_EVENT_STAGE_SPEC, + getEventStageSongCellHeight, + getEventStageSongCellWidth, + getEventStageSongJacketHeight, + getEventStageSongRowSize, + splitEventStageImagesByColumnHeight, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec'; + +describe('BangDream event stage spec', () => { + it('keeps song row dimensions compatible with existing layout', () => { + expect(getEventStageSongCellWidth()).toBe(100); + expect(getEventStageSongJacketHeight()).toBe(94); + expect(getEventStageSongCellHeight()).toBeCloseTo((100 / 180) * 210, 6); + expect(getEventStageSongRowSize()).toEqual({ + height: getEventStageSongCellHeight() + 10, + width: 800, + }); + }); + + it('keeps type top text and column limits stable', () => { + expect(BANGDREAM_EVENT_STAGE_SPEC.typeTop.fontSize).toBe(25); + expect(BANGDREAM_EVENT_STAGE_SPEC.typeTop.strokeWidth).toBe(4.5); + expect(BANGDREAM_EVENT_STAGE_SPEC.list.maxColumnHeight).toBe(6000); + }); + + it('splits stage images by max column height', () => { + const columns = splitEventStageImagesByColumnHeight([ + { height: 2000, id: 1 }, + { height: 3000, id: 2 }, + { height: 1500, id: 3 }, + { height: 1000, id: 4 }, + ]); + + expect(columns.map((column) => column.map((item) => item.id))).toEqual([ + [1, 2], + [3, 4], + ]); + }); + + it('keeps oversized first image in its own column', () => { + const columns = splitEventStageImagesByColumnHeight( + [ + { height: 7000, id: 1 }, + { height: 100, id: 2 }, + ], + 6000, + ); + + expect(columns.map((column) => column.map((item) => item.id))).toEqual([ + [1], + [2], + ]); + }); +});