From 4b3a2bf3b6963b4e21366cc5dca51b97e1a2368b Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 6 Jun 2026 19:47:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20BangDream=20?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E6=95=B0=E6=8D=AE=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 4 +- .../qqbot-bangdream-renderer.service.ts | 2 +- .../bangDream/tsugu/canvas/background.ts | 27 ++ .../plugins/bangDream/tsugu/canvas/output.ts | 15 ++ .../tsugu/command-renderers/event-detail.ts | 1 + .../tsugu/models/event-data-repository.ts | 247 ++++++++++++++++++ .../plugins/bangDream/tsugu/models/event.ts | 144 +--------- 7 files changed, 305 insertions(+), 135 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/tsugu/models/event-data-repository.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 0048886..2b47013 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -290,8 +290,10 @@ export interface TsuguHook { - 已新增 `models/main-data-repository.ts`、`song-repository.ts`、`card-repository.ts`、`event-repository.ts`、`gacha-repository.ts`、`player-repository.ts`。 - `command-renderers/song-list.ts`、`event-list.ts`、`card-list.ts`、`gacha-detail.ts`、`player-detail.ts`、`cutoff-detail.ts` 已开始改走 repository 创建模型或读取主数据。 - `models/song.ts`、`card.ts`、`event.ts`、`gacha.ts`、`player.ts` 的 Bestdori API/素材请求已开始改走 Bestdori provider;`models/cutoff.ts` 的 Bestdori/HHWX Tracker fallback 已改走 provider,并在 fallback 时输出数据源切换日志。 +- 已新增 `models/event-data-repository.ts` 收口活动详情、横幅、背景、轮播、Logo、奖励表情和装饰素材请求;`models/event.ts` 不再直接依赖 Bestdori provider 或 `loadImage`。 +- `drawEventDetail` 的活动真实底图改为轻量图片背景,避免长图走模糊三角纹理导致超时;活动搜索默认遵循 `BANGDREAM_TSUGU_USE_EASY_BG=false`,不再强制简易背景。 - 已新增 `test/qqbot/plugins/bangDream/tsugu/data-provider.spec.ts`,覆盖 provider URL 解析、Bestdori/HHWX mock 数据源、retry/cache wrapper。 -- 本地图片烟测已生成查歌 `136` 和查活动 `50` 图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 +- 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 ### Phase 4:搜索 specification 和 matcher diff --git a/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts b/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts index 3123bfe..f075460 100644 --- a/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts +++ b/src/qqbot/plugins/bangDream/renderer/qqbot-bangdream-renderer.service.ts @@ -170,7 +170,7 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap { async searchEvent(input: QqbotBangDreamCommandInput) { const query = this.requireText(input, '请提供活动关键词或活动 ID'); - const options = this.getRenderOptions(input, { useEasyBG: true }); + const options = this.getRenderOptions(input); const images = this.isInteger(query) ? await drawEventDetail( Number(query), diff --git a/src/qqbot/plugins/bangDream/tsugu/canvas/background.ts b/src/qqbot/plugins/bangDream/tsugu/canvas/background.ts index fd1db68..81e6877 100644 --- a/src/qqbot/plugins/bangDream/tsugu/canvas/background.ts +++ b/src/qqbot/plugins/bangDream/tsugu/canvas/background.ts @@ -162,6 +162,33 @@ export async function createEasyBackground({ width, height }) { return canvas; } +/** + * 使用业务图片生成轻量背景。 + * + * @param options1 - 背景参数。 + */ +export async function createImageBackground({ + image, + width, + height, +}: BackgroundOptions): Promise { + const backgroundBuffer = await spreadBackgroundImage( + image, + width, + height, + 40, + ); + const backgroundImage = await loadImage(backgroundBuffer); + const canvas = new Canvas(width, height); + const ctx = canvas.getContext('2d'); + + ctx.drawImage(backgroundImage, 0, 0); + ctx.fillStyle = 'rgba(255, 255, 255, 0.72)'; + ctx.fillRect(0, 0, width, height); + + return canvas; +} + /** * 在底层绘图工具层中创建Background。 * diff --git a/src/qqbot/plugins/bangDream/tsugu/canvas/output.ts b/src/qqbot/plugins/bangDream/tsugu/canvas/output.ts index 1c26f18..502e0c1 100644 --- a/src/qqbot/plugins/bangDream/tsugu/canvas/output.ts +++ b/src/qqbot/plugins/bangDream/tsugu/canvas/output.ts @@ -2,6 +2,7 @@ import { Canvas, Image } from 'skia-canvas'; import { createBackground, createEasyBackground, + createImageBackground, } from '@/qqbot/plugins/bangDream/tsugu/canvas/background'; import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import * as path from 'path'; @@ -22,6 +23,7 @@ export interface OutputFinalOptions { startWithSpace?: boolean; imageList: Array; useEasyBG?: boolean; + useImageBG?: boolean; text?: string; BGimage?: Image | Canvas; compress?: boolean; @@ -40,6 +42,7 @@ export const outputFinalCanv = async function ({ imageList, startWithSpace = true, useEasyBG = true, + useImageBG = false, text = 'BanG Dream!', BGimage = BGDefaultImage, }: OutputFinalOptions): Promise { @@ -67,6 +70,16 @@ export const outputFinalCanv = async function ({ 0, 0, ); + } else if (useImageBG) { + ctx.drawImage( + await createImageBackground({ + image: BGimage, + width: maxW, + height: allH, + }), + 0, + 0, + ); } else { ctx.drawImage( await createBackground({ @@ -104,6 +117,7 @@ export const outputFinalBuffer = async function ({ startWithSpace = true, imageList, useEasyBG = true, + useImageBG, text, BGimage, compress, @@ -112,6 +126,7 @@ export const outputFinalBuffer = async function ({ startWithSpace, imageList, useEasyBG, + useImageBG, text, BGimage, }); diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-detail.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-detail.ts index b414de7..54e041a 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-detail.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-detail.ts @@ -488,6 +488,7 @@ export async function drawEventDetail( return await createOutputFinalImages({ useEasyBG, + useImageBG: true, BGimage, text: 'Event', compress, diff --git a/src/qqbot/plugins/bangDream/tsugu/models/event-data-repository.ts b/src/qqbot/plugins/bangDream/tsugu/models/event-data-repository.ts new file mode 100644 index 0000000..4a9f42f --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/event-data-repository.ts @@ -0,0 +1,247 @@ +import { Image, loadImage } from 'skia-canvas'; +import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; +import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; +import { + getServerByPriority, + Server, +} from '@/qqbot/plugins/bangDream/tsugu/models/server'; +import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; + +export interface EventAssetContext { + assetBundleName: string; + bannerAssetBundleName?: string; + startAt: Array; +} + +export interface EventRewardContext { + pointRewards?: Array | null>; + rankingRewards?: Array | null>; + startAt: Array; +} + +type RewardWithId = { + rewardId?: number; + rewardType: string; +}; + +class EventDataRepository { + /** + * 获取活动远端详情数据。 + * + * @param eventId - 活动 ID。 + * @param update - 是否强制更新缓存。 + */ + async getDetail(eventId: number, update = true): Promise> { + const cacheTime = update ? 0 : 1 / 0; + return await bangDreamBestdoriProvider.getJson>( + `/api/events/${eventId}.json`, + { cacheTime }, + ); + } + + /** + * 获取活动横幅图,优先活动资源,失败时回退 homebanner。 + * + * @param event - 活动资源上下文。 + * @param displayedServerList - 展示服务器优先级。 + */ + async getBannerImage( + event: EventAssetContext, + displayedServerList: Server[] = globalDefaultServer, + ): Promise { + const server = getServerByPriority(event.startAt, displayedServerList); + try { + const bannerImageBuffer = await bangDreamBestdoriProvider.getAsset( + `/assets/${Server[server]}/event/${event.assetBundleName}/images_rip/banner.png`, + { ignoreError: false }, + ); + return await loadImage(bannerImageBuffer); + } catch { + const bannerImageBuffer = await bangDreamBestdoriProvider.getAsset( + `/assets/jp/homebanner_rip/${event.bannerAssetBundleName}.png`, + ); + return await loadImage(bannerImageBuffer); + } + } + + /** + * 获取活动背景图。 + * + * @param event - 活动资源上下文。 + */ + async getBackgroundImage(event: EventAssetContext): Promise { + const server = getServerByPriority(event.startAt); + const bgImageBuffer = await bangDreamBestdoriProvider.getAsset( + `/assets/${Server[server]}/event/${event.assetBundleName}/topscreen_rip/bg_eventtop.png`, + ); + return await loadImage(bgImageBuffer); + } + + /** + * 获取活动规则轮播图列表。 + * + * @param event - 活动资源上下文。 + * @param tempServer - 目标服务器。 + */ + async getSlideImages( + event: EventAssetContext, + tempServer: Server, + ): Promise { + const server = getServerByPriority(event.startAt, [tempServer]); + const result: Image[] = []; + const basePath = `/assets/${Server[server]}/event/${event.assetBundleName}/slide_rip/`; + let ruleNumber = 1; + while (true) { + try { + const slideImageBuffer = await bangDreamBestdoriProvider.getAsset( + `${basePath}rule${ruleNumber}.png`, + { ignoreError: false }, + ); + result.push(await loadImage(slideImageBuffer)); + } catch { + break; + } + ruleNumber++; + } + return result; + } + + /** + * 获取活动主界面裁切图。 + * + * @param event - 活动资源上下文。 + */ + async getTopscreenTrimImage(event: EventAssetContext): Promise { + const server = getServerByPriority(event.startAt); + const topscreenTrimImageBuffer = await bangDreamBestdoriProvider.getAsset( + `/assets/${Server[server]}/event/${event.assetBundleName}/topscreen_rip/trim_eventtop.png`, + ); + return await loadImage(topscreenTrimImageBuffer); + } + + /** + * 获取活动 Logo 图。 + * + * @param event - 活动资源上下文。 + * @param tempServer - 目标服务器。 + */ + async getLogoImage( + event: EventAssetContext, + tempServer: Server, + ): Promise { + const server = getServerByPriority(event.startAt, [tempServer]); + const logoImageBuffer = await bangDreamBestdoriProvider.getAsset( + `/assets/${Server[server]}/event/${event.assetBundleName}/images_rip/logo.png`, + ); + return await loadImage(logoImageBuffer); + } + + /** + * 获取活动奖励表情图;缺失或上游资源不可用时返回 undefined。 + * + * @param event - 活动奖励上下文。 + * @param server - 目标服务器。 + */ + async getRewardStampImage( + event: EventRewardContext, + server: Server, + ): Promise { + const allStamps = await bangDreamBestdoriProvider.getJson< + Record + >('/api/stamps/all.2.json'); + const rewardId = this.pickRewardId(event.pointRewards, 'stamp'); + if (rewardId === undefined) return undefined; + + const stampAssetName = this.pickServerValue( + allStamps[rewardId]?.imageName, + server, + ); + if (!stampAssetName) return undefined; + + const serverName = this.pickReleasedServerName(event.startAt, server, 'jp'); + try { + const stampBuffer = await bangDreamBestdoriProvider.getAsset( + `/assets/${serverName}/stamp/01_rip/${stampAssetName}.png`, + { ignoreError: false }, + ); + return await loadImage(stampBuffer); + } catch { + return undefined; + } + } + + /** + * 获取活动奖励装饰图;缺失或上游资源不可用时返回 undefined。 + * + * @param event - 活动奖励上下文。 + * @param server - 目标服务器。 + */ + async getRewardDecoImage( + event: EventRewardContext, + server: Server, + ): Promise { + if (!event.rankingRewards?.[server]) return undefined; + + const rewardId = this.pickRewardId(event.rankingRewards, 'deco_pins'); + if (rewardId === undefined) return undefined; + + const allDeco = bangDreamMainDataRepository.getCollection< + Record + >('deco'); + const decoAssetName = allDeco[rewardId]?.assetBundleName; + if (!decoAssetName) return undefined; + + const serverName = this.pickReleasedServerName(event.startAt, server, 'cn'); + try { + const decoBuffer = await bangDreamBestdoriProvider.getAsset( + `/assets/${serverName}/deco/pins_rip/${decoAssetName}.png`, + { ignoreError: false }, + ); + return await loadImage(decoBuffer); + } catch { + return undefined; + } + } + + private pickRewardId( + rewardsByServer: Array | null> | undefined, + rewardType: string, + ): number | undefined { + const rewards = rewardsByServer?.filter(Boolean)[0]; + return rewards?.find((reward) => reward.rewardType === rewardType) + ?.rewardId; + } + + private pickReleasedServerName( + startAt: Array, + server: Server, + fallback: string, + ) { + return startAt[server] && startAt[server] < Date.now() + ? Server[server] + : fallback; + } + + private pickServerValue( + value: T | T[] | undefined | null, + server: Server, + ): T | undefined { + if (Array.isArray(value)) { + return value[server] ?? value[Server.jp] ?? value.find(Boolean); + } + return value ?? undefined; + } +} + +export const eventDataRepository = new EventDataRepository(); diff --git a/src/qqbot/plugins/bangDream/tsugu/models/event.ts b/src/qqbot/plugins/bangDream/tsugu/models/event.ts index da999f2..82a3311 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/event.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/event.ts @@ -1,9 +1,5 @@ -import { Image, loadImage } from 'skia-canvas'; -import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; -import { - Server, - getServerByPriority, -} from '@/qqbot/plugins/bangDream/tsugu/models/server'; +import { Image } from 'skia-canvas'; +import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute'; import { Character } from '@/qqbot/plugins/bangDream/tsugu/models/character'; @@ -13,6 +9,7 @@ import { import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; import { getProbableTimeDifference } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time'; import { BANGDREAM_EVENT_TYPE_NAME } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; +import { eventDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/event-data-repository'; const typeName: Record = BANGDREAM_EVENT_TYPE_NAME; @@ -197,14 +194,7 @@ export class Event { * @param update - update参数,未传入时使用默认值。 */ async getData(update: boolean = true) { - const time = update ? 0 : 1 / 0; - const eventData = await bangDreamBestdoriProvider.getJson< - Record - >( - `/api/events/${this.eventId}.json`, - { cacheTime: time }, - ); - return eventData; + return await eventDataRepository.getDetail(this.eventId, update); } /** * 在 Event 模型中获取横幅图片。 @@ -216,20 +206,7 @@ export class Event { displayedServerList: Server[] = globalDefaultServer, ): Promise { if (!displayedServerList) displayedServerList = globalDefaultServer; - const server = getServerByPriority(this.startAt, displayedServerList); - try { - const BannerImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[server]}/event/${this.assetBundleName}/images_rip/banner.png`, - { ignoreError: false }, - ); - return await loadImage(BannerImageBuffer); - } catch { - const server = Server.jp; - const BannerImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[server]}/homebanner_rip/${this.bannerAssetBundleName}.png`, - ); - return await loadImage(BannerImageBuffer); - } + return await eventDataRepository.getBannerImage(this, displayedServerList); } /** * 在 Event 模型中获取活动背景图片。 @@ -237,11 +214,7 @@ export class Event { * @returns 异步处理结果。 */ async getEventBGImage(): Promise { - const server = getServerByPriority(this.startAt); - const BGImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[server]}/event/${this.assetBundleName}/topscreen_rip/bg_eventtop.png`, - ); - return await loadImage(BGImageBuffer); + return await eventDataRepository.getBackgroundImage(this); } //活动规则轮播图 /** @@ -251,23 +224,7 @@ export class Event { * @returns 异步处理结果。 */ async getEventSlideImage(tempServer: Server): Promise { - const server = getServerByPriority(this.startAt, [tempServer]); - const result: Image[] = []; - const basePath = `/assets/${Server[server]}/event/${this.assetBundleName}/slide_rip/`; - let ruleNumber = 1; - while (true) { - try { - const SlideImageBuffer = await bangDreamBestdoriProvider.getAsset( - `${basePath}rule${ruleNumber}.png`, - { ignoreError: false }, - ); - result.push(await loadImage(SlideImageBuffer)); - } catch { - break; - } - ruleNumber++; - } - return result; + return await eventDataRepository.getSlideImages(this, tempServer); } //活动主界面trim /** @@ -276,11 +233,7 @@ export class Event { * @returns 异步处理结果。 */ async getEventTopscreenTrimImage(): Promise { - const server = getServerByPriority(this.startAt); - const TopscreenTrimImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[server]}/event/${this.assetBundleName}/topscreen_rip/trim_eventtop.png`, - ); - return await loadImage(TopscreenTrimImageBuffer); + return await eventDataRepository.getTopscreenTrimImage(this); } /** * 在 Event 模型中获取活动Logo图片。 @@ -289,11 +242,7 @@ export class Event { * @returns 异步处理结果。 */ async getEventLogoImage(tempServer: Server): Promise { - const server = getServerByPriority(this.startAt, [tempServer]); - const LogoImageBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${Server[server]}/event/${this.assetBundleName}/images_rip/logo.png`, - ); - return await loadImage(LogoImageBuffer); + return await eventDataRepository.getLogoImage(this, tempServer); } /** * 在 Event 模型中获取类型名称。 @@ -352,41 +301,7 @@ export class Event { * @returns 异步处理结果。 */ async getRewardStamp(server: Server): Promise { - const allStamps = await bangDreamBestdoriProvider.getJson< - Record - >( - `/api/stamps/all.2.json`, - ); - const rewards = this.pointRewards.filter(Boolean)[0]; - let rewardId = -1; - for (let i = 0; i < rewards?.length; i++) { - if (rewards[i].rewardType == 'stamp') { - rewardId = rewards[i].rewardId; - break; - } - } - let stampAssetName = ''; - for (const i in allStamps) { - if (i == rewardId.toString()) { - stampAssetName = allStamps[i]['imageName']; - } - } - if (stampAssetName == '') { - return undefined; - } - let serverName = 'jp'; - if (this.startAt[server] && this.startAt[server] < Date.now()) { - serverName = Server[server]; - } - try { - const stampBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${serverName}/stamp/01_rip/${stampAssetName}.png`, - { ignoreError: false }, - ); - return await loadImage(stampBuffer); - } catch { - return undefined; - } + return await eventDataRepository.getRewardStampImage(this, server); } /** * 在 Event 模型中获取奖励Deco。 @@ -395,44 +310,7 @@ export class Event { * @returns 异步处理结果。 */ async getRewardDeco(server: Server): Promise { - const allDeco = bangDreamMainDataRepository.getCollection< - Record - >('deco'); - if (!this.rankingRewards[server]) { - // Undefined处理 - return undefined; - } - const rewards = this.rankingRewards[server].filter(Boolean); - let rewardId = -1; - for (let i = 0; i < rewards?.length; i++) { - if (rewards[i].rewardType == 'deco_pins') { - rewardId = rewards[i].rewardId; - break; - } - } - if (rewardId == -1) return undefined; - let decoAssetName = ''; - for (const i in allDeco) { - if (i == rewardId.toString()) { - decoAssetName = allDeco[i]['assetBundleName']; - } - } - if (decoAssetName == '') { - return undefined; - } - let serverName = 'cn'; - if (this.startAt[server] && this.startAt[server] < Date.now()) { - serverName = Server[server]; - } - try { - const decoBuffer = await bangDreamBestdoriProvider.getAsset( - `/assets/${serverName}/deco/pins_rip/${decoAssetName}.png`, - { ignoreError: false }, - ); - return await loadImage(decoBuffer); - } catch { - return undefined; - } + return await eventDataRepository.getRewardDecoImage(this, server); } }