refactor: 收口 BangDream 卡牌渲染规格

This commit is contained in:
sunlei 2026-06-06 23:23:06 +08:00
parent c726cad320
commit 319577cffa
4 changed files with 250 additions and 47 deletions

View File

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

View File

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

View File

@ -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<Image> {
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<Image> {
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<Canvas> {
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<Canvas> {
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;

View File

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