refactor: 接入 BangDream 详情区块构建器

This commit is contained in:
sunlei 2026-06-06 22:17:29 +08:00
parent 1603f7f657
commit 5a02effd9d
5 changed files with 107 additions and 54 deletions

View File

@ -345,6 +345,8 @@ export interface TsuguHook {
- 已新增 `render-blocks/layout-spec.ts`,统一横向/纵向虚线分割规格,歌曲列表、活动列表、活动详情和通用列表框架不再重复维护分割线宽高/颜色。
- 已新增 `runtime/asset-manifest.ts`,收口本地 `BG`、`Card`、`Skill`、`SongChart`、字体和标题资源路径;`canvas/text.ts`、`canvas/rect.ts`、`canvas/output.ts`、`canvas/background.ts`、`card-art.ts`、`list-rarity.ts`、`skill-text.ts`、`title.ts`、`song-chart-preview.ts` 已改走 manifest。
- 已生成查曲、查活动和查谱面 smoke 图片,验证 theme/spec/manifest 第一段迁移后本地图片输出非空,谱面预览资源路径未断。
- 已新增 `render-blocks/detail-block-builder.ts`,活动详情页先接入 `DetailBlockBuilder` 统一 section、spacer 和 data block 拼装,去掉局部 `pushSection` 和手动分割线维护;`detail-block-builder.spec.ts` 覆盖 section 分割线与 spacer/data block 输出。
- 本地验证时发现 Jest 无法解析 `skia-canvas/lib/v6/index.node`,根因是 `moduleFileExtensions` 未包含 `node`;已把 `.node` 原生扩展解析固化进 Jest 配置,避免后续渲染单测重复卡在同类问题。
- 线上 `/qqbot/command/test` smoke 已固化为先按 `operationKey` 查询启用命令、传 `commandId`,并保留完整命令文本;避免默认 `preview` selfId 未绑定命令时误报“未匹配到命令”。
### Phase 6策略 policy 和时间/档线规则

View File

@ -104,6 +104,7 @@
"moduleFileExtensions": [
"js",
"json",
"node",
"ts"
],
"rootDir": ".",

View File

@ -2,7 +2,6 @@ import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event';
import { Card } from '@/qqbot/plugins/bangDream/tsugu/models/card';
import {
drawList,
line,
drawListByServerList,
drawListMerge,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame';
@ -37,7 +36,7 @@ import {
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-song';
import { drawDottedLine } from '@/qqbot/plugins/bangDream/tsugu/canvas/dotted-line';
import { createHorizontalSeparatorSpec } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/layout-spec';
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
import { DetailBlockBuilder } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-builder';
const songSeparatorLine = drawDottedLine(
createHorizontalSeparatorSpec({ height: 10 }),
@ -59,17 +58,6 @@ async function drawSongListDataBlock(songList: Song[], topLeftText?: string) {
return drawDataBlock({ list, topLeftText });
}
/**
* QQBot
*
* @param list -
* @param section -
*/
function pushSection(list: Array<Image | Canvas>, section: Image | Canvas) {
list.push(section);
list.push(line);
}
/**
* QQBot
*
@ -84,21 +72,20 @@ function hasOwn(source: object, key: string): boolean {
/**
* QQBot
*
* @param list -
* @param builder -
* @param event -
*/
async function appendEventBonusSections(
list: Array<Image | Canvas>,
builder: DetailBlockBuilder,
event: Event,
): Promise<void> {
pushSection(list, drawList({ key: '活动加成' }));
builder.addSection(drawList({ key: '活动加成' }));
const attributeList = event.getAttributeList();
for (const percent in attributeList) {
if (!hasOwn(attributeList, percent)) {
continue;
}
pushSection(
list,
builder.addSection(
await drawAttributeInList({
content: attributeList[percent],
text: ` +${percent}%`,
@ -106,14 +93,13 @@ async function appendEventBonusSections(
);
}
pushSection(list, drawList({ key: '活动角色加成' }));
builder.addSection(drawList({ key: '活动角色加成' }));
const characterList = event.getCharacterList();
for (const percent in characterList) {
if (!hasOwn(characterList, percent)) {
continue;
}
pushSection(
list,
builder.addSection(
await drawCharacterInList({
content: characterList[percent],
text: ` +${percent}%`,
@ -146,16 +132,18 @@ function getEventStatBonusText(event: Event): string {
/**
* QQBot
*
* @param list -
* @param builder -
* @param event -
*/
function appendEventStatBonus(list: Array<Image | Canvas>, event: Event): void {
function appendEventStatBonus(
builder: DetailBlockBuilder,
event: Event,
): void {
const statText = getEventStatBonusText(event);
if (!statText) {
return;
}
pushSection(
list,
builder.addSection(
drawList({
key: '活动偏科加成',
text: statText,
@ -166,19 +154,18 @@ function appendEventStatBonus(list: Array<Image | Canvas>, event: Event): void {
/**
* QQBot
*
* @param list -
* @param builder -
* @param event -
* @param displayedServerList -
*/
async function appendEventRewardSections(
list: Array<Image | Canvas>,
builder: DetailBlockBuilder,
event: Event,
displayedServerList: Server[],
): Promise<void> {
const decoImage = await event.getRewardDeco(displayedServerList[0]);
if (decoImage) {
pushSection(
list,
builder.addSection(
await drawList({
key: '活动装饰',
content: [decoImage],
@ -188,12 +175,11 @@ async function appendEventRewardSections(
);
}
pushSection(list, await drawDegreeListOfEvent(event, displayedServerList));
builder.addSection(await drawDegreeListOfEvent(event, displayedServerList));
const stampImage = await event.getRewardStamp(displayedServerList[0]);
if (stampImage) {
pushSection(
list,
builder.addSection(
await drawList({
key: '活动表情',
content: [stampImage],
@ -204,8 +190,7 @@ async function appendEventRewardSections(
}
const rewardCardList = event.rewardCards.map((cardId) => new Card(cardId));
pushSection(
list,
builder.addSection(
await drawCardListInList({
key: '奖励卡牌',
cardList: rewardCardList,
@ -231,12 +216,12 @@ function getEventMusicServer(event: Event, displayedServerList: Server[]) {
/**
* QQBot
*
* @param list -
* @param builder -
* @param event -
* @param displayedServerList -
*/
async function appendEventMusicSection(
list: Array<Image | Canvas>,
builder: DetailBlockBuilder,
event: Event,
displayedServerList: Server[],
): Promise<void> {
@ -253,7 +238,7 @@ async function appendEventMusicSection(
const songs = event.musics[musicServer].map(
(music) => new Song(music.musicId),
);
pushSection(list, await drawSongListInList(songs));
builder.addSection(await drawSongListInList(songs));
}
interface EventGachaSections {
@ -381,23 +366,21 @@ export async function drawEventDetail(
return ['错误: 活动不存在'];
}
await event.initFull();
const list: Array<Image | Canvas> = [];
const builder = new DetailBlockBuilder();
//bannner
const eventBannerImage = await event.getBannerImage();
const eventBannerImageCanvas = drawBannerImageCanvas(eventBannerImage);
list.push(eventBannerImageCanvas);
list.push(new Canvas(BANGDREAM_RENDER_THEME.layout.contentWidth, 30));
builder.add(eventBannerImageCanvas).addSpacer(30);
//标题
list.push(
builder.addSection(
await drawListByServerList(
event.eventName,
'活动名称',
displayedServerList,
),
);
list.push(line);
//类型
const typeImage = drawList({
@ -411,11 +394,10 @@ export async function drawEventDetail(
text: event.eventId.toString(),
});
list.push(drawListMerge([typeImage, idImage]));
list.push(line);
builder.addSection(drawListMerge([typeImage, idImage]));
//开始时间
list.push(
builder.addSection(
await drawTimeInList({
key: '开始时间',
content: event.startAt,
@ -423,35 +405,33 @@ export async function drawEventDetail(
estimateCNTime: true,
}),
);
list.push(line);
//结束时间
list.push(
builder.addSection(
await drawTimeInList({
key: '结束时间',
content: event.endAt,
}),
);
list.push(line);
//活动属性加成
await appendEventBonusSections(list, event);
await appendEventBonusSections(builder, event);
//活动偏科加成(stat)
appendEventStatBonus(list, event);
appendEventStatBonus(builder, event);
//有歌榜活动的歌榜歌曲
await appendEventMusicSection(list, event, displayedServerList);
await appendEventMusicSection(builder, event, displayedServerList);
// 活动装饰、牌子、表情和奖励卡牌
await appendEventRewardSections(list, event, displayedServerList);
await appendEventRewardSections(builder, event, displayedServerList);
const { gachaCardList, gachaImageList } = await collectEventGachaSections(
event,
displayedServerList,
);
list.push(
builder.add(
await drawCardListInList({
key: '活动期间卡池卡牌',
cardList: gachaCardList,
@ -462,7 +442,7 @@ export async function drawEventDetail(
}),
);
const listImage = drawDataBlock({ list });
const listImage = builder.toDataBlock();
//创建最终输出数组
const all = [];

View File

@ -0,0 +1,44 @@
import { Canvas, Image } from 'skia-canvas';
import { drawDataBlock } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/data-block';
import { line } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame';
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
export interface DetailBlockDataOptions {
BG?: boolean;
opacity?: number;
topLeftText?: string;
}
/**
* 线
*/
export class DetailBlockBuilder {
private readonly list: Array<Canvas | Image> = [];
add(block: Canvas | Image): this {
this.list.push(block);
return this;
}
addSection(block: Canvas | Image): this {
this.list.push(block);
this.list.push(line);
return this;
}
addSpacer(
height: number,
width: number = BANGDREAM_RENDER_THEME.layout.contentWidth,
): this {
this.list.push(new Canvas(width, height));
return this;
}
toList(): Array<Canvas | Image> {
return [...this.list];
}
toDataBlock(options: DetailBlockDataOptions = {}): Canvas {
return drawDataBlock({ ...options, list: this.list });
}
}

View File

@ -0,0 +1,26 @@
import { Canvas } from 'skia-canvas';
import { DetailBlockBuilder } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-builder';
import { line } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame';
describe('DetailBlockBuilder', () => {
it('keeps section separators centralized', () => {
const first = new Canvas(10, 20);
const second = new Canvas(30, 40);
const list = new DetailBlockBuilder()
.addSection(first)
.add(second)
.toList();
expect(list).toEqual([first, line, second]);
});
it('creates spacers and data blocks from collected sections', () => {
const dataBlock = new DetailBlockBuilder()
.add(new Canvas(100, 50))
.addSpacer(30, 100)
.toDataBlock();
expect(dataBlock.width).toBeGreaterThan(0);
expect(dataBlock.height).toBeGreaterThan(0);
});
});