diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 10c05ae..284f222 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -14,7 +14,7 @@ ## 当前事实 - Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu` -- TS 文件:初始基线 92;当前 `tsugu` 源码 124 +- TS 文件:初始基线 92;当前 `tsugu` 源码 125 - 函数节点:481,其中稳定函数 410,匿名/内联回调 71 - 源码 JSDoc:稳定函数 410/410 已覆盖 - 变量声明:1896 @@ -352,6 +352,8 @@ export interface TsuguHook { - 线上 `/qqbot/command/test` smoke 已固化为先按 `operationKey` 查询启用命令、传 `commandId`,并保留完整命令文本;避免默认 `preview` selfId 未绑定命令时误报“未匹配到命令”。 - 已新增 `render-blocks/song-chart-preview-spec.ts`,把谱面 BPM 时间计算、双押识别、滑条拆分、音符排序、展示/计数音符分类、难度颜色和布局规格从 `song-chart-preview.ts` 拆成可测纯策略;绘制文件改为消费 `createSongChartPreviewModel`,只保留资源加载和 canvas 绘制。 - 已新增 `song-chart-preview-spec.spec.ts`,覆盖 BPM 变速时间、十六分单点、双押、滑条 bar/tick/end、布局列数和音符分类;本地生成 `song-chart-preview-spec-136-expert.jpg`,验证谱面预览重构后图片输出非空且视觉结构正常。 +- 已新增 `render-blocks/card-art-spec.ts`,收口卡牌图标/插画边框 URL 生成、icon/illustration 画布尺寸、属性/乐队/星级/突破/技能等级坐标;`card-art.ts` 改为消费 spec 和 URL factory,下载与绘制顺序保持不变。 +- 已新增 `card-art-spec.spec.ts`,覆盖 rarity=1 属性边框、其他稀有度边框和关键尺寸;本地生成 `card-art-spec-card-472.jpg`,验证卡牌详情图在规格收口后仍能正常输出。 ### Phase 6:策略 policy 和时间/档线规则 diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/card-art-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/card-art-spec.ts new file mode 100644 index 0000000..bd4905b --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/card-art-spec.ts @@ -0,0 +1,93 @@ +export type BangDreamCardArtAttribute = 'cool' | 'happy' | 'pure' | 'powerful'; + +export const BANGDREAM_CARD_ART_SPEC = { + icon: { + attribute: { height: 45.26, width: 45.26, x: 132.5, y: 3 }, + band: { height: 45, width: 45, x: 0, y: 0 }, + cardId: { fontSize: 30, x: 4, y: 195 }, + cardType: { x: 138, y: 91 }, + height: 180, + heightWithId: 210, + limitBreak: { + fontSize: 25, + height: 39, + textX: 155, + textY: 70, + width: 39, + x: 137, + y: 51, + }, + skillIconY: 142, + skillLevel: { + fontSize: 35, + height: 39, + textX: 155.5, + textY: 107.5, + width: 35, + x: 138, + y: 91, + }, + star: { height: 29, startY: 150, stepY: 26, width: 29, x: 4 }, + width: 180, + }, + illustration: { + attribute: { height: 150, width: 150, x: 1195, y: 11 }, + band: { height: 150, width: 150, x: 11, y: 11 }, + height: 905, + innerHeight: 879, + innerWidth: 1334, + innerX: 13, + innerY: 13, + listWidth: 800, + star: { height: 110, startY: 780, stepY: 100, width: 110, x: 5 }, + width: 1360, + }, +} as const; + +/** + * 创建卡牌小图边框资源 URL。 + * + * @param baseUrl - Bestdori 基础地址。 + * @param rarity - 卡牌稀有度。 + * @param attribute - 卡牌属性。 + */ +export function createCardIconFrameUrl( + baseUrl: string, + rarity: number, + attribute: BangDreamCardArtAttribute, +): string { + return createCardFrameUrl(baseUrl, 'card', rarity, attribute); +} + +/** + * 创建卡牌插画边框资源 URL。 + * + * @param baseUrl - Bestdori 基础地址。 + * @param rarity - 卡牌稀有度。 + * @param attribute - 卡牌属性。 + */ +export function createCardIllustrationFrameUrl( + baseUrl: string, + rarity: number, + attribute: BangDreamCardArtAttribute, +): string { + return createCardFrameUrl(baseUrl, 'frame', rarity, attribute); +} + +/** + * 根据 Bestdori 边框命名规则创建远程资源 URL。 + * + * @param baseUrl - Bestdori 基础地址。 + * @param prefix - 边框资源前缀。 + * @param rarity - 卡牌稀有度。 + * @param attribute - 卡牌属性。 + */ +function createCardFrameUrl( + baseUrl: string, + prefix: 'card' | 'frame', + rarity: number, + attribute: BangDreamCardArtAttribute, +): string { + const frameName = rarity === 1 ? `${rarity}-${attribute}` : `${rarity}`; + return `${baseUrl}/res/image/${prefix}-${frameName}.png`; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/card-art.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/card-art.ts index ec10d99..0ab5f71 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/card-art.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/card-art.ts @@ -10,6 +10,12 @@ import { bestdoriUrl } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { getBangDreamAssetPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest'; import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme'; +import { + BANGDREAM_CARD_ART_SPEC, + BangDreamCardArtAttribute, + createCardIconFrameUrl, + createCardIllustrationFrameUrl, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/card-art-spec'; const cardTypeIconList: { [type: string]: Image } = {}; const starList: { [type: string]: Image } = {}; @@ -52,15 +58,9 @@ loadImageOnce(); */ async function getCardIconFrame( rarity: number, - attribute: 'cool' | 'happy' | 'pure' | 'powerful', + attribute: BangDreamCardArtAttribute, ): Promise { - const baseUrl = `${bestdoriUrl}/res/image/card-`; - let imageUrl: string; - if (rarity == 1) { - imageUrl = baseUrl + '1-' + attribute + '.png'; - } else { - imageUrl = baseUrl + rarity.toString() + '.png'; - } + const imageUrl = createCardIconFrameUrl(bestdoriUrl, rarity, attribute); const imageBuffer = await downloadFileCache(imageUrl); return await loadImage(imageBuffer); } @@ -75,15 +75,13 @@ async function getCardIconFrame( */ async function getCardIllustrationFrame( rarity: number, - attribute: 'cool' | 'happy' | 'pure' | 'powerful', + attribute: BangDreamCardArtAttribute, ): Promise { - const baseUrl = `${bestdoriUrl}/res/image/frame-`; - let imageUrl: string; - if (rarity == 1) { - imageUrl = baseUrl + '1-' + attribute + '.png'; - } else { - imageUrl = baseUrl + rarity.toString() + '.png'; - } + const imageUrl = createCardIllustrationFrameUrl( + bestdoriUrl, + rarity, + attribute, + ); const imageBuffer = await downloadFileCache(imageUrl); return await loadImage(imageBuffer); } @@ -119,64 +117,118 @@ export async function drawCardIcon({ }: DrawCardIconOptions): Promise { trainingStatus = card.ableToTraining(trainingStatus); illustrationTrainingStatus ??= trainingStatus; + const spec = BANGDREAM_CARD_ART_SPEC.icon; const canvas: Canvas = cardIdVisible - ? new Canvas(180, 210) - : new Canvas(180, 180); + ? new Canvas(spec.width, spec.heightWithId) + : new Canvas(spec.width, spec.height); const ctx = canvas.getContext('2d'); ctx.drawImage( await card.getCardIconImage(illustrationTrainingStatus), 0, 0, - 180, - 180, + spec.width, + spec.height, ); //如果显示卡牌ID,画面高度为210,在下方显示 if (cardIdVisible) { ctx.textAlign = 'start'; ctx.textBaseline = 'middle'; - setFontStyle(ctx, 30, BANGDREAM_RENDER_THEME.font.body); + setFontStyle(ctx, spec.cardId.fontSize, BANGDREAM_RENDER_THEME.font.body); ctx.fillStyle = BANGDREAM_RENDER_THEME.color.mutedText; - ctx.fillText(`ID:${card.cardId}`, 4, 195); + ctx.fillText(`ID:${card.cardId}`, spec.cardId.x, spec.cardId.y); } //如果显示技能类型,在右上显示 if (skillLevel != undefined) { ctx.fillStyle = BANGDREAM_RENDER_THEME.color.skillLevelBackground; - ctx.fillRect(138, 91, 35, 39); + ctx.fillRect( + spec.skillLevel.x, + spec.skillLevel.y, + spec.skillLevel.width, + spec.skillLevel.height, + ); ctx.fillStyle = BANGDREAM_RENDER_THEME.color.surface; ctx.textAlign = 'center'; - setFontStyle(ctx, 35, BANGDREAM_RENDER_THEME.font.body); - ctx.fillText(skillLevel.toString(), 155.5, 107.5); + setFontStyle( + ctx, + spec.skillLevel.fontSize, + BANGDREAM_RENDER_THEME.font.body, + ); + ctx.fillText( + skillLevel.toString(), + spec.skillLevel.textX, + spec.skillLevel.textY, + ); } //如果显示技能类型,在右上显示 else if (cardTypeVisible) { if (cardTypeIconList[card.type] != undefined) { - ctx.drawImage(cardTypeIconList[card.type], 138, 91); + ctx.drawImage( + cardTypeIconList[card.type], + spec.cardType.x, + spec.cardType.y, + ); } } if (skillTypeVisible) { const skill = new Skill(card.skillId); const skillTypeIcon = await drawCardIconSkill(skill); - ctx.drawImage(skillTypeIcon, 180 - skillTypeIcon.width, 142); + ctx.drawImage( + skillTypeIcon, + spec.width - skillTypeIcon.width, + spec.skillIconY, + ); } //获得框 const frame = await getCardIconFrame(card.rarity, card.attribute); ctx.drawImage(frame, 0, 0); const attributeIcon = await new Attribute(card.attribute).getIcon(); - ctx.drawImage(attributeIcon, 132.5, 3, 45.26, 45.26); + ctx.drawImage( + attributeIcon, + spec.attribute.x, + spec.attribute.y, + spec.attribute.width, + spec.attribute.height, + ); const bandIcon = await new Band(card.bandId).getIcon(); - ctx.drawImage(bandIcon, 0, 0, 45, 45); + ctx.drawImage( + bandIcon, + spec.band.x, + spec.band.y, + spec.band.width, + spec.band.height, + ); if (limitBreakRank != 0) { - ctx.drawImage(limitBreakIcon, 137, 51, 39, 39); - setFontStyle(ctx, 25, BANGDREAM_RENDER_THEME.font.body); + ctx.drawImage( + limitBreakIcon, + spec.limitBreak.x, + spec.limitBreak.y, + spec.limitBreak.width, + spec.limitBreak.height, + ); + setFontStyle( + ctx, + spec.limitBreak.fontSize, + BANGDREAM_RENDER_THEME.font.body, + ); ctx.fillStyle = BANGDREAM_RENDER_THEME.color.surface; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(limitBreakRank.toString(), 155, 70); + ctx.fillText( + limitBreakRank.toString(), + spec.limitBreak.textX, + spec.limitBreak.textY, + ); } const star = starList[trainingStatus ? 'trained' : 'normal']; for (let i = 0; i < card.rarity; i++) { //星星数量 - ctx.drawImage(star, 4, 150 - 26 * i, 29, 29); + ctx.drawImage( + star, + spec.star.x, + spec.star.startY - spec.star.stepY * i, + spec.star.width, + spec.star.height, + ); } return canvas; } @@ -199,42 +251,61 @@ export async function drawCardIllustration({ isList = false, }: DrawCardIllustrationOptions): Promise { trainingStatus = card.ableToTraining(trainingStatus); + const spec = BANGDREAM_CARD_ART_SPEC.illustration; const cardIllustrationImage = await card.getCardIllustrationImage(trainingStatus); - const canvas = new Canvas(1360, 905); + const canvas = new Canvas(spec.width, spec.height); const ctx = canvas.getContext('2d'); //将cardIllustration等比例缩放至宽度为1334 - const scale = 1334 / cardIllustrationImage.width; - const illustrationCanvas = new Canvas(1334, 879); + const scale = spec.innerWidth / cardIllustrationImage.width; + const illustrationCanvas = new Canvas(spec.innerWidth, spec.innerHeight); const illustrationCtx = illustrationCanvas.getContext('2d'); const illustrationHeight = cardIllustrationImage.height * scale; illustrationCtx.drawImage( cardIllustrationImage, 0, - 879 / 2 - illustrationHeight / 2, - 1334, + spec.innerHeight / 2 - illustrationHeight / 2, + spec.innerWidth, illustrationHeight, ); - ctx.drawImage(illustrationCanvas, 13, 13); + ctx.drawImage(illustrationCanvas, spec.innerX, spec.innerY); //获得框 const frame = await getCardIllustrationFrame(card.rarity, card.attribute); - ctx.drawImage(frame, 0, 0, 1360, 905); + ctx.drawImage(frame, 0, 0, spec.width, spec.height); const attributeIcon = await new Attribute(card.attribute).getIcon(); - ctx.drawImage(attributeIcon, 1195, 11, 150, 150); + ctx.drawImage( + attributeIcon, + spec.attribute.x, + spec.attribute.y, + spec.attribute.width, + spec.attribute.height, + ); const bandIcon = await new Band(card.bandId).getIcon(); - ctx.drawImage(bandIcon, 11, 11, 150, 150); + ctx.drawImage( + bandIcon, + spec.band.x, + spec.band.y, + spec.band.width, + spec.band.height, + ); const star = starList[trainingStatus ? 'trained' : 'normal']; for (let i = 0; i < card.rarity; i++) { //星星数量 - ctx.drawImage(star, 5, 780 - 100 * i, 110, 110); + ctx.drawImage( + star, + spec.star.x, + spec.star.startY - spec.star.stepY * i, + spec.star.width, + spec.star.height, + ); } if (isList) { //等比例缩放到宽度为widthMax - const scale = 800 / 1360; - const tempCanvas = new Canvas(800, 905 * scale); + const scale = spec.listWidth / spec.width; + const tempCanvas = new Canvas(spec.listWidth, spec.height * scale); const ctx = tempCanvas.getContext('2d'); - ctx.drawImage(canvas, 0, 0, 800, 905 * scale); + ctx.drawImage(canvas, 0, 0, spec.listWidth, spec.height * scale); return tempCanvas; } else { return canvas; diff --git a/test/qqbot/plugins/bangDream/tsugu/card-art-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/card-art-spec.spec.ts new file mode 100644 index 0000000..0825b07 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/card-art-spec.spec.ts @@ -0,0 +1,37 @@ +import { + BANGDREAM_CARD_ART_SPEC, + createCardIconFrameUrl, + createCardIllustrationFrameUrl, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/card-art-spec'; + +describe('BangDream card art spec', () => { + it('creates icon frame urls with Bestdori rarity rules', () => { + const baseUrl = 'https://bestdori.com'; + + expect(createCardIconFrameUrl(baseUrl, 1, 'cool')).toBe( + 'https://bestdori.com/res/image/card-1-cool.png', + ); + expect(createCardIconFrameUrl(baseUrl, 5, 'happy')).toBe( + 'https://bestdori.com/res/image/card-5.png', + ); + }); + + it('creates illustration frame urls with Bestdori rarity rules', () => { + const baseUrl = 'https://bestdori.com'; + + expect(createCardIllustrationFrameUrl(baseUrl, 1, 'pure')).toBe( + 'https://bestdori.com/res/image/frame-1-pure.png', + ); + expect(createCardIllustrationFrameUrl(baseUrl, 4, 'powerful')).toBe( + 'https://bestdori.com/res/image/frame-4.png', + ); + }); + + it('keeps icon and illustration dimensions stable', () => { + expect(BANGDREAM_CARD_ART_SPEC.icon.width).toBe(180); + expect(BANGDREAM_CARD_ART_SPEC.icon.heightWithId).toBe(210); + expect(BANGDREAM_CARD_ART_SPEC.illustration.width).toBe(1360); + expect(BANGDREAM_CARD_ART_SPEC.illustration.innerWidth).toBe(1334); + expect(BANGDREAM_CARD_ART_SPEC.illustration.listWidth).toBe(800); + }); +});