refactor: 收口 BangDream 试炼渲染规格

This commit is contained in:
sunlei 2026-06-07 00:38:57 +08:00
parent f8d964c6ee
commit 4220efa6b2
5 changed files with 200 additions and 47 deletions

View File

@ -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 和时间/档线规则

View File

@ -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,
);

View File

@ -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;
}

View File

@ -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<Canvas> {
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<Canvas> {
//绘制活动中的每个歌曲(包括难度)
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,
);
}

View File

@ -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],
]);
});
});