refactor: 收口 BangDream 卡牌渲染规格
This commit is contained in:
parent
c726cad320
commit
319577cffa
@ -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 和时间/档线规则
|
||||
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
37
test/qqbot/plugins/bangDream/tsugu/card-art-spec.spec.ts
Normal file
37
test/qqbot/plugins/bangDream/tsugu/card-art-spec.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user