refactor: 收口 BangDream 详情区块规格
This commit is contained in:
parent
319577cffa
commit
08627646e5
@ -14,7 +14,7 @@
|
||||
## 当前事实
|
||||
|
||||
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
|
||||
- TS 文件:初始基线 92;当前 `tsugu` 源码 125
|
||||
- TS 文件:初始基线 92;当前 `tsugu` 源码 126
|
||||
- 函数节点:481,其中稳定函数 410,匿名/内联回调 71
|
||||
- 源码 JSDoc:稳定函数 410/410 已覆盖
|
||||
- 变量声明:1896
|
||||
@ -354,6 +354,8 @@ export interface TsuguHook {
|
||||
- 已新增 `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`,验证卡牌详情图在规格收口后仍能正常输出。
|
||||
- 已新增 `render-blocks/detail-block-spec.ts`,收口歌曲详情、歌曲 meta、角色半身块和玩家详情头图的尺寸、间距、字号与相对分数舍入规则;`detail-blocks.ts` 改为消费 spec,数据查询和绘制顺序保持不变。
|
||||
- 已新增 `detail-block-spec.spec.ts`,覆盖歌曲详情尺寸、角色/玩家详情尺寸和 meta 相对百分比舍入;本地生成 `detail-spec-song-136.jpg`、`detail-spec-event-50.jpg`、`detail-spec-character-1.jpg`,验证详情区块规格收口后查曲/查活动/查角色图片输出正常。
|
||||
|
||||
### Phase 6:策略 policy 和时间/档线规则
|
||||
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
export const BANGDREAM_DETAIL_BLOCK_SPEC = {
|
||||
characterHalf: {
|
||||
footerHeight: 100,
|
||||
height: 800,
|
||||
idTextSize: 30,
|
||||
illustrationInsetY: 2,
|
||||
illustrationPaddingHeight: 20,
|
||||
nameTextSize: 40,
|
||||
opaqueOpacity: 1,
|
||||
overlayOpacity: 0.25,
|
||||
radius: 20,
|
||||
strokeWidth: 4,
|
||||
width: 250,
|
||||
},
|
||||
metaList: {
|
||||
maxRows: 50,
|
||||
percentRoundScale: 100,
|
||||
percentScale: 100,
|
||||
},
|
||||
playerDetail: {
|
||||
dataBlockOpacity: 1,
|
||||
dataBlockY: 900,
|
||||
degreeGapWidth: 20,
|
||||
illustrationHeight: 1000,
|
||||
illustrationWidth: 1000,
|
||||
infoTextSize: 35,
|
||||
nameTextSize: 75,
|
||||
spacerHeight: 25,
|
||||
},
|
||||
songDetail: {
|
||||
detailSeparator: {
|
||||
endX: 360,
|
||||
height: 20,
|
||||
width: 365,
|
||||
},
|
||||
detailTextSize: 30,
|
||||
difficultyGap: 10,
|
||||
difficultyHeight: 60,
|
||||
difficultyX: 435,
|
||||
horizontalGapWidth: 35,
|
||||
jacketMaxWidth: 400,
|
||||
metaSeparatorHeight: 10,
|
||||
rightBottomGapHeight: 60,
|
||||
textMaxWidth: 365,
|
||||
titleTextSize: 40,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 计算歌曲 meta 相对百分比,保持历史两位小数舍入策略。
|
||||
*
|
||||
* @param meta - 当前歌曲 meta 值。
|
||||
* @param maxMeta - 当前榜单最大 meta 值。
|
||||
*/
|
||||
export function getRelativeMetaPercent(meta: number, maxMeta: number): number {
|
||||
const { percentRoundScale, percentScale } =
|
||||
BANGDREAM_DETAIL_BLOCK_SPEC.metaList;
|
||||
return (
|
||||
Math.round((meta / maxMeta) * percentScale * percentRoundScale) /
|
||||
percentRoundScale
|
||||
);
|
||||
}
|
||||
@ -49,18 +49,22 @@ import { Canvas, Image } from 'skia-canvas';
|
||||
import { Band } from '@/qqbot/plugins/bangDream/tsugu/models/band';
|
||||
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
|
||||
import { createHorizontalSeparatorSpec } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/layout-spec';
|
||||
import {
|
||||
BANGDREAM_DETAIL_BLOCK_SPEC,
|
||||
getRelativeMetaPercent,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-spec';
|
||||
|
||||
const songDetailSeparator = drawDottedLine(
|
||||
createHorizontalSeparatorSpec({
|
||||
width: 365,
|
||||
height: 20,
|
||||
endX: 360,
|
||||
width: BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.detailSeparator.width,
|
||||
height: BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.detailSeparator.height,
|
||||
endX: BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.detailSeparator.endX,
|
||||
}),
|
||||
);
|
||||
|
||||
const songMetaSeparator = drawDottedLine(
|
||||
createHorizontalSeparatorSpec({
|
||||
height: 10,
|
||||
height: BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.metaSeparatorHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -174,18 +178,19 @@ export async function drawSongDataBlock(
|
||||
text?: string,
|
||||
displayedServerList: Server[] = globalDefaultServer,
|
||||
) {
|
||||
const spec = BANGDREAM_DETAIL_BLOCK_SPEC.songDetail;
|
||||
const server = getServerByPriority(song.publishedAt, displayedServerList);
|
||||
const songJacketCanvas = resizeImage({
|
||||
image: await song.getSongJacketImage(),
|
||||
widthMax: 400,
|
||||
widthMax: spec.jacketMaxWidth,
|
||||
});
|
||||
const songName = song.musicTitle[server];
|
||||
const bandName = new Band(song.bandId).bandName[server];
|
||||
const songTipsName = song.getTagName();
|
||||
const songNameImage = drawText({
|
||||
text: songName,
|
||||
textSize: 40,
|
||||
maxWidth: 365,
|
||||
textSize: spec.titleTextSize,
|
||||
maxWidth: spec.textMaxWidth,
|
||||
});
|
||||
let songDetail = `${bandName}\n${songTipsName}\nID:${song.songId}`;
|
||||
if (text != undefined) {
|
||||
@ -193,23 +198,31 @@ export async function drawSongDataBlock(
|
||||
}
|
||||
const songDetailImage = drawText({
|
||||
text: songDetail,
|
||||
textSize: 30,
|
||||
maxWidth: 365,
|
||||
textSize: spec.detailTextSize,
|
||||
maxWidth: spec.textMaxWidth,
|
||||
});
|
||||
const difficultyImage = drawDifficultyList(song, 60, 10);
|
||||
const difficultyImage = drawDifficultyList(
|
||||
song,
|
||||
spec.difficultyHeight,
|
||||
spec.difficultyGap,
|
||||
);
|
||||
const rightCanvas = stackImage([
|
||||
songNameImage,
|
||||
songDetailSeparator,
|
||||
songDetailImage,
|
||||
new Canvas(1, 60),
|
||||
new Canvas(1, spec.rightBottomGapHeight),
|
||||
]);
|
||||
const canvas = stackImageHorizontal([
|
||||
songJacketCanvas,
|
||||
new Canvas(35, 1),
|
||||
new Canvas(spec.horizontalGapWidth, 1),
|
||||
rightCanvas,
|
||||
]);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(difficultyImage, 435, canvas.height - difficultyImage.height);
|
||||
ctx.drawImage(
|
||||
difficultyImage,
|
||||
spec.difficultyX,
|
||||
canvas.height - difficultyImage.height,
|
||||
);
|
||||
return drawDataBlock({ list: [canvas] });
|
||||
}
|
||||
|
||||
@ -250,9 +263,10 @@ export async function drawSongMetaListDataBlock(
|
||||
const tempSongMetaRanking = songMetaRanking[server].data;
|
||||
for (let j = 0; j < tempSongMetaRanking.length; j++) {
|
||||
if (tempSongMetaRanking[j].difficulty == difficultyId) {
|
||||
let percent =
|
||||
(tempSongMetaRanking[j].meta / metaRanking[server].maxMeta) * 100;
|
||||
percent = Math.round(percent * 100) / 100;
|
||||
const percent = getRelativeMetaPercent(
|
||||
tempSongMetaRanking[j].meta,
|
||||
metaRanking[server].maxMeta,
|
||||
);
|
||||
text += `${serverNameFullList[server]}: ${percent}% #${tempSongMetaRanking[j].rank + 1} `;
|
||||
}
|
||||
}
|
||||
@ -280,14 +294,14 @@ export async function drawMetaListDataBlock(
|
||||
const metaRanking = getMetaRanking(withFever, server);
|
||||
const maxMeta = metaRanking[0].meta;
|
||||
const list: Array<Image | Canvas> = [];
|
||||
const max = 50;
|
||||
for (let i = 0; i < max; i++) {
|
||||
const maxRows = BANGDREAM_DETAIL_BLOCK_SPEC.metaList.maxRows;
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
if (i >= metaRanking.length) {
|
||||
break;
|
||||
}
|
||||
const song = new Song(metaRanking[i].songId);
|
||||
const difficultyId = metaRanking[i].difficulty;
|
||||
const percent = (metaRanking[i].meta / maxMeta) * 100;
|
||||
const percent = getRelativeMetaPercent(metaRanking[i].meta, maxMeta);
|
||||
list.push(
|
||||
await drawSongInList(
|
||||
song,
|
||||
@ -312,8 +326,9 @@ export async function drawCharacterHalfBlock(
|
||||
character: Character,
|
||||
displayedServerList: Server[] = globalDefaultServer,
|
||||
): Promise<Canvas> {
|
||||
const width = 250;
|
||||
const height = 800;
|
||||
const spec = BANGDREAM_DETAIL_BLOCK_SPEC.characterHalf;
|
||||
const width = spec.width;
|
||||
const height = spec.height;
|
||||
const canvas = new Canvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
await character.initFull(false);
|
||||
@ -324,32 +339,32 @@ export async function drawCharacterHalfBlock(
|
||||
drawRoundedRect({
|
||||
width,
|
||||
height,
|
||||
radius: 20,
|
||||
radius: spec.radius,
|
||||
color,
|
||||
opacity: 0.25,
|
||||
opacity: spec.overlayOpacity,
|
||||
}),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const characterIllustration = resizeImage({
|
||||
image: await character.getIllustration(),
|
||||
heightMax: height - 20,
|
||||
heightMax: height - spec.illustrationPaddingHeight,
|
||||
});
|
||||
|
||||
ctx.drawImage(
|
||||
characterIllustration,
|
||||
width / 2 - characterIllustration.width / 2,
|
||||
2,
|
||||
spec.illustrationInsetY,
|
||||
);
|
||||
ctx.drawImage(
|
||||
drawRoundedRect({
|
||||
width,
|
||||
height,
|
||||
radius: 20,
|
||||
opacity: 1,
|
||||
radius: spec.radius,
|
||||
opacity: spec.opaqueOpacity,
|
||||
color: color + '00',
|
||||
strokeColor: color,
|
||||
strokeWidth: 4,
|
||||
strokeWidth: spec.strokeWidth,
|
||||
}),
|
||||
0,
|
||||
0,
|
||||
@ -357,13 +372,13 @@ export async function drawCharacterHalfBlock(
|
||||
ctx.drawImage(
|
||||
drawRoundedRect({
|
||||
width,
|
||||
height: 100,
|
||||
radius: 20,
|
||||
opacity: 1,
|
||||
height: spec.footerHeight,
|
||||
radius: spec.radius,
|
||||
opacity: spec.opaqueOpacity,
|
||||
color,
|
||||
}),
|
||||
0,
|
||||
height - 100,
|
||||
height - spec.footerHeight,
|
||||
);
|
||||
|
||||
const list: Canvas[] = [];
|
||||
@ -373,19 +388,19 @@ export async function drawCharacterHalfBlock(
|
||||
);
|
||||
const nameTextImage = drawText({
|
||||
text: character.characterName[server],
|
||||
textSize: 40,
|
||||
textSize: spec.nameTextSize,
|
||||
color: BANGDREAM_RENDER_THEME.color.surface,
|
||||
maxWidth: width,
|
||||
});
|
||||
list.push(drawImageListCenter([nameTextImage], width));
|
||||
const idTextImage = drawText({
|
||||
text: `ID: ${character.characterId}`,
|
||||
textSize: 30,
|
||||
textSize: spec.idTextSize,
|
||||
color: BANGDREAM_RENDER_THEME.color.surface,
|
||||
maxWidth: width,
|
||||
});
|
||||
list.push(drawImageListCenter([idTextImage], width));
|
||||
ctx.drawImage(stackImage(list), 0, height - 100);
|
||||
ctx.drawImage(stackImage(list), 0, height - spec.footerHeight);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@ -398,20 +413,21 @@ export async function drawCharacterHalfBlock(
|
||||
export async function drawPlayerDetailBlockWithIllustration(
|
||||
player: Player,
|
||||
): Promise<Canvas> {
|
||||
const spec = BANGDREAM_DETAIL_BLOCK_SPEC.playerDetail;
|
||||
const list: Array<Canvas | Image> = [];
|
||||
const playerText = drawText({
|
||||
text: player.profile.userName,
|
||||
maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth,
|
||||
textSize: 75,
|
||||
textSize: spec.nameTextSize,
|
||||
});
|
||||
list.push(drawImageListCenter([playerText]));
|
||||
const levelText = drawText({
|
||||
text: `等级 ${player.profile.rank}`,
|
||||
maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth,
|
||||
textSize: 35,
|
||||
textSize: spec.infoTextSize,
|
||||
});
|
||||
list.push(drawImageListCenter([levelText]));
|
||||
list.push(new Canvas(1, 25));
|
||||
list.push(new Canvas(1, spec.spacerHeight));
|
||||
|
||||
const degreeImageList: Array<Canvas | Image> = [];
|
||||
const userProfileDegreeMap = player.profile.userProfileDegreeMap.entries;
|
||||
@ -420,19 +436,19 @@ export async function drawPlayerDetailBlockWithIllustration(
|
||||
degreeImageList.push(
|
||||
await drawDegree(new Degree(tempDegree.degreeId), player.server),
|
||||
);
|
||||
degreeImageList.push(new Canvas(20, 1));
|
||||
degreeImageList.push(new Canvas(spec.degreeGapWidth, 1));
|
||||
}
|
||||
degreeImageList.pop();
|
||||
list.push(drawImageListCenter(degreeImageList));
|
||||
list.push(new Canvas(1, 25));
|
||||
list.push(new Canvas(1, spec.spacerHeight));
|
||||
|
||||
const introductionText = drawText({
|
||||
text: player.profile.introduction,
|
||||
maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth,
|
||||
textSize: 35,
|
||||
textSize: spec.infoTextSize,
|
||||
});
|
||||
list.push(drawImageListCenter([introductionText]));
|
||||
list.push(new Canvas(1, 25));
|
||||
list.push(new Canvas(1, spec.spacerHeight));
|
||||
|
||||
const userId = player.profile.publishUserIdFlg
|
||||
? player.profile.userId.toString()
|
||||
@ -440,10 +456,10 @@ export async function drawPlayerDetailBlockWithIllustration(
|
||||
const idText = drawTextWithImages({
|
||||
content: [await getIcon(player.server), userId],
|
||||
maxWidth: BANGDREAM_RENDER_THEME.layout.contentWidth,
|
||||
textSize: 35,
|
||||
textSize: spec.infoTextSize,
|
||||
});
|
||||
list.push(drawImageListCenter([idText]));
|
||||
const dataBlock = drawDataBlock({ list, opacity: 1 });
|
||||
const dataBlock = drawDataBlock({ list, opacity: spec.dataBlockOpacity });
|
||||
|
||||
const userIllustrationData = player.profile.userIllustration;
|
||||
const illustrationCard = new Card(userIllustrationData.cardId);
|
||||
@ -451,10 +467,19 @@ export async function drawPlayerDetailBlockWithIllustration(
|
||||
userIllustrationData.trainingStatus,
|
||||
);
|
||||
const titleImage = drawTitle('查询', '玩家信息');
|
||||
const canvas = new Canvas(1000, 900 + dataBlock.height);
|
||||
const canvas = new Canvas(
|
||||
spec.illustrationWidth,
|
||||
spec.dataBlockY + dataBlock.height,
|
||||
);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(illustrationImage, 0, 0, 1000, 1000);
|
||||
ctx.drawImage(
|
||||
illustrationImage,
|
||||
0,
|
||||
0,
|
||||
spec.illustrationWidth,
|
||||
spec.illustrationHeight,
|
||||
);
|
||||
ctx.drawImage(titleImage, 0, 0);
|
||||
ctx.drawImage(dataBlock, 0, 900);
|
||||
ctx.drawImage(dataBlock, 0, spec.dataBlockY);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
29
test/qqbot/plugins/bangDream/tsugu/detail-block-spec.spec.ts
Normal file
29
test/qqbot/plugins/bangDream/tsugu/detail-block-spec.spec.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {
|
||||
BANGDREAM_DETAIL_BLOCK_SPEC,
|
||||
getRelativeMetaPercent,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/detail-block-spec';
|
||||
|
||||
describe('BangDream detail block spec', () => {
|
||||
it('keeps song detail dimensions stable', () => {
|
||||
expect(BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.jacketMaxWidth).toBe(400);
|
||||
expect(BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.textMaxWidth).toBe(365);
|
||||
expect(BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.difficultyX).toBe(435);
|
||||
expect(BANGDREAM_DETAIL_BLOCK_SPEC.songDetail.detailSeparator.endX).toBe(
|
||||
360,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps character and player detail dimensions stable', () => {
|
||||
expect(BANGDREAM_DETAIL_BLOCK_SPEC.characterHalf.width).toBe(250);
|
||||
expect(BANGDREAM_DETAIL_BLOCK_SPEC.characterHalf.height).toBe(800);
|
||||
expect(BANGDREAM_DETAIL_BLOCK_SPEC.playerDetail.illustrationWidth).toBe(
|
||||
1000,
|
||||
);
|
||||
expect(BANGDREAM_DETAIL_BLOCK_SPEC.playerDetail.dataBlockY).toBe(900);
|
||||
});
|
||||
|
||||
it('rounds relative meta percent with the historical precision', () => {
|
||||
expect(getRelativeMetaPercent(333, 400)).toBe(83.25);
|
||||
expect(getRelativeMetaPercent(1, 3)).toBe(33.33);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user