refactor: 批量收口 BangDream 渲染规格

This commit is contained in:
sunlei 2026-06-07 06:15:07 +08:00
parent bef1750022
commit 36c76ce959
35 changed files with 1537 additions and 382 deletions

View File

@ -397,6 +397,8 @@ export interface TsuguHook {
- 已新增 `render-blocks/list-stat-spec.ts`,收口卡牌/玩家综合力列表的间隔、数值行画布、文字规格、进度条布局和条宽算法;`list-stat.ts` 改为消费 spec综合力计算、突破加成和绘制顺序保持不变`list-stat-spec.spec.ts` 覆盖间隔/画布/文字/进度条规格、文本生成和条宽布局,本地 `/查卡 472` 图片 smoke 输出非空。 - 已新增 `render-blocks/list-stat-spec.ts`,收口卡牌/玩家综合力列表的间隔、数值行画布、文字规格、进度条布局和条宽算法;`list-stat.ts` 改为消费 spec综合力计算、突破加成和绘制顺序保持不变`list-stat-spec.spec.ts` 覆盖间隔/画布/文字/进度条规格、文本生成和条宽布局,本地 `/查卡 472` 图片 smoke 输出非空。
- 已新增 `render-blocks/title-spec.ts`,收口通用标题条的背景偏移、两行文字字号/行高/颜色/字体/坐标;`title.ts` 改为消费 spec标题底图加载、标题文案和绘制顺序保持不变`title-spec.spec.ts` 覆盖背景偏移、两行文字布局、绘制参数和坐标,本地 `/查卡 472` 图片 smoke 输出非空。 - 已新增 `render-blocks/title-spec.ts`,收口通用标题条的背景偏移、两行文字字号/行高/颜色/字体/坐标;`title.ts` 改为消费 spec标题底图加载、标题文案和绘制顺序保持不变`title-spec.spec.ts` 覆盖背景偏移、两行文字布局、绘制参数和坐标,本地 `/查卡 472` 图片 smoke 输出非空。
- 已新增 `render-blocks/list-rarity-spec.ts`,收口星级列表的文字大小、间距和特训星图使用阈值;`list-rarity.ts` 改为消费 spec星图资源加载、星级数量和列表绘制顺序保持不变`list-rarity-spec.spec.ts` 覆盖列表规格和 3/4/5 星及未特训边界,本地 `/查卡 472` 图片 smoke 输出非空。 - 已新增 `render-blocks/list-rarity-spec.ts`,收口星级列表的文字大小、间距和特训星图使用阈值;`list-rarity.ts` 改为消费 spec星图资源加载、星级数量和列表绘制顺序保持不变`list-rarity-spec.spec.ts` 覆盖列表规格和 3/4/5 星及未特训边界,本地 `/查卡 472` 图片 smoke 输出非空。
- 已批量新增 `render-blocks/skill-text-spec.ts`、`list-entity-spec.ts`、`degree-list-spec.ts`、`data-block-spec.ts`、`list-player-ranking-spec.ts`、`list-time-spec.ts`、`timeline-chart-spec.ts`、`cutoff-chart-spec.ts`、`gacha-list-spec.ts`,收口技能角标、基础实体列表、称号/活动奖励称号、数据块、玩家排名行、时间格式、时间线/档线图表和卡池列表文案/布局规格;`skill-text.ts`、`list-attribute.ts`、`list-band.ts`、`list-character.ts`、`degree-badge.ts`、`list-degree-list.ts`、`data-block.ts`、`list-player-ranking.ts`、`list-time.ts`、`timeline-chart.ts`、`cutoff-chart.ts`、`list-gacha-payment-method.ts`、`list-gacha-pick-up.ts`、`list-gacha-rate.ts` 已改为消费 spec同步修复 `list-band.ts` 多乐队分支误用 `this.hasIcon` 导致图标判断失效的问题;新增对应 8 组 spec 单测,本地 `/查卡 472`、`/查卡池 259`、`/查活动 50`、`/ycx 100 50 cn`、`/查谱面 136 expert` 图片 smoke 输出非空。
- 已补充 `song-chart-preview-spec.ts` 的封面信息块偏移、ID/难度面板、轨道渐变和双押线高度规格,`song-chart-preview.ts` 不再在绘制函数中散落这些视觉常量;`song-chart-preview-spec.spec.ts` 覆盖新增规格,本地 `/查谱面 136 expert` 图片 smoke 输出非空。
- smoke/Jenkins/远程调试卡点继续按已固化规则处理同一卡点第二次尝试前必须改变可验证变量Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录PowerShell 双引号 here-string 中不要写 JS `${...}` 模板字面量,避免被 PowerShell 提前插值smoke 没有真实图片落盘时必须失败。 - smoke/Jenkins/远程调试卡点继续按已固化规则处理同一卡点第二次尝试前必须改变可验证变量Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录PowerShell 双引号 here-string 中不要写 JS `${...}` 模板字面量,避免被 PowerShell 提前插值smoke 没有真实图片落盘时必须失败。
### Phase 6策略 policy 和时间/档线规则 ### Phase 6策略 policy 和时间/档线规则

View File

@ -0,0 +1,38 @@
export const BANGDREAM_CUTOFF_CHART_SPEC = {
currentTimeDataset: {
label: '当前时间',
pointHoverRadius: 15,
pointRadius: 10,
},
emptyCanvas: {
height: 1,
width: 1,
},
eventTopDataset: {
borderWidth: 4,
pointAlpha: 0,
},
legend: {
colorBlockOpacity: 0.8,
textSize: 20,
},
lineDataset: {
borderWidth: 5,
predictionBackgroundAlpha: 1,
predictionBorderAlpha: 1,
predictionDash: [20, 10],
predictionPointHoverRadius: 0,
predictionPointRadius: 0,
predictionSuffix: '预测线',
singleLineFillBackgroundAlpha: 0.2,
},
} as const;
/**
*
*
* @param text -
*/
export function stripCutoffChartLabelTags(text: string) {
return text.replace(/\[[^\]]*\]/g, '');
}

View File

@ -8,6 +8,10 @@ import { getPresetColor } from '@/qqbot/plugins/bangDream/tsugu/models/color';
import { drawList } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame'; import { drawList } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame';
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
import { BangDreamEventStatus } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; import { BangDreamEventStatus } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants';
import {
BANGDREAM_CUTOFF_CHART_SPEC,
stripCutoffChartLabelTags,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart-spec';
/** /**
* 线 * 线
@ -25,7 +29,10 @@ export async function drawCutoffChart(
const datasets = []; const datasets = [];
const time = new Date().getTime(); const time = new Date().getTime();
if (cutoffList.length == 0) { if (cutoffList.length == 0) {
return new Canvas(1, 1); return new Canvas(
BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.width,
BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.height,
);
} }
const list = []; const list = [];
@ -43,27 +50,38 @@ export async function drawCutoffChart(
list.push( list.push(
drawList({ drawList({
content: [ content: [
tempColor.generateColorBlock(0.8), tempColor.generateColorBlock(
BANGDREAM_CUTOFF_CHART_SPEC.legend.colorBlockOpacity,
),
`[${tempEvent.eventId}] ${tempEvent.eventName[server]} T${cutoff.tier}`, `[${tempEvent.eventId}] ${tempEvent.eventName[server]} T${cutoff.tier}`,
], ],
textSize: 20, textSize: BANGDREAM_CUTOFF_CHART_SPEC.legend.textSize,
}), }),
); );
} else { } else {
lableName = `T${cutoff.tier}`; lableName = `T${cutoff.tier}`;
list.push( list.push(
drawList({ drawList({
content: [tempColor.generateColorBlock(0.8), `T${cutoff.tier}`], content: [
textSize: 20, tempColor.generateColorBlock(
BANGDREAM_CUTOFF_CHART_SPEC.legend.colorBlockOpacity,
),
`T${cutoff.tier}`,
],
textSize: BANGDREAM_CUTOFF_CHART_SPEC.legend.textSize,
}), }),
); );
} }
datasets.push({ datasets.push({
label: lableName, label: lableName,
data: cutoff.getChartData(setStartToZero), data: cutoff.getChartData(setStartToZero),
borderWidth: 5, borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth,
borderColor: [tempColor.getRGBA(1)], borderColor: [tempColor.getRGBA(1)],
backgroundColor: [tempColor.getRGBA(0.2)], backgroundColor: [
tempColor.getRGBA(
BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.singleLineFillBackgroundAlpha,
),
],
pointBackgroundColor: tempColor.getRGBA(1), pointBackgroundColor: tempColor.getRGBA(1),
pointBorderColor: tempColor.getRGBA(1), pointBorderColor: tempColor.getRGBA(1),
fill: onlyOne, fill: onlyOne,
@ -109,15 +127,25 @@ export async function drawCutoffChart(
} }
const tempColor = getPresetColor(i); const tempColor = getPresetColor(i);
datasets.push({ datasets.push({
label: `T${cutoff.tier} 预测线`, label: `T${cutoff.tier} ${BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionSuffix}`,
borderColor: [tempColor.getRGBA(1)], borderColor: [
backgroundColor: [tempColor.getRGBA(1)], tempColor.getRGBA(
BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionBorderAlpha,
),
],
backgroundColor: [
tempColor.getRGBA(
BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionBackgroundAlpha,
),
],
data: data, data: data,
borderWidth: 5, borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth,
borderDash: [20, 10], borderDash: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionDash,
fill: false, fill: false,
pointRadius: 0, pointRadius:
pointHoverRadius: 0, BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionPointRadius,
pointHoverRadius:
BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionPointHoverRadius,
}); });
} }
} }
@ -126,13 +154,14 @@ export async function drawCutoffChart(
if (time < cutoffList[0].endAt) { if (time < cutoffList[0].endAt) {
const tempColor = getPresetColor(0); const tempColor = getPresetColor(0);
datasets.push({ datasets.push({
label: '当前时间', label: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.label,
borderColor: [tempColor.getRGBA(1)], borderColor: [tempColor.getRGBA(1)],
backgroundColor: [tempColor.getRGBA(1)], backgroundColor: [tempColor.getRGBA(1)],
data: [{ x: new Date(time), y: 0 }], data: [{ x: new Date(time), y: 0 }],
fill: false, fill: false,
pointRadius: 10, pointRadius: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.pointRadius,
pointHoverRadius: 15, pointHoverRadius:
BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.pointHoverRadius,
showLine: false, showLine: false,
}); });
} }
@ -184,30 +213,33 @@ export async function drawCutoffEventTopChart(
) { ) {
const datasets = []; const datasets = [];
if (CutoffEventTop == undefined) { if (CutoffEventTop == undefined) {
return new Canvas(1, 1); return new Canvas(
BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.width,
BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.height,
);
} }
const allData = CutoffEventTop.getChartData(); const allData = CutoffEventTop.getChartData();
/**
* Braces
*
* @param text -
* @returns
*/
function removeBraces(text: string): string {
const newText = text.replace(/\[[^\]]*\]/g, '');
return newText;
}
let colorNumber = 0; let colorNumber = 0;
for (const key in allData) { for (const key in allData) {
const tempColor = getPresetColor(colorNumber); const tempColor = getPresetColor(colorNumber);
datasets.push({ datasets.push({
label: removeBraces(CutoffEventTop.getUserNameById(Number(key))), label: stripCutoffChartLabelTags(
CutoffEventTop.getUserNameById(Number(key)),
),
data: allData[key], data: allData[key],
borderWidth: 4, borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.borderWidth,
borderColor: [tempColor.getRGBA(1)], borderColor: [tempColor.getRGBA(1)],
backgroundColor: [tempColor.getRGBA(0.2)], backgroundColor: [
pointBackgroundColor: tempColor.getRGBA(0), tempColor.getRGBA(
pointBorderColor: tempColor.getRGBA(0), BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.singleLineFillBackgroundAlpha,
),
],
pointBackgroundColor: tempColor.getRGBA(
BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.pointAlpha,
),
pointBorderColor: tempColor.getRGBA(
BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.pointAlpha,
),
pointStyle: false, pointStyle: false,
fill: false, fill: false,
}); });

View File

@ -0,0 +1,114 @@
export const BANGDREAM_DATA_BLOCK_SPEC = {
banner: {
widthMax: 800,
},
background: {
defaultOpacity: 0.9,
fillExtraHeight: 100,
fillExtraWidth: 100,
outerExtraWidth: 200,
radius: 25,
},
title: {
backgroundColor: '#ea4e73',
blockSize: 380,
height: 70,
rectExtraSize: 5,
strokeColor: '#ffffff',
strokeWidth: 5,
textColor: '#ffffff',
textInset: 5,
},
vertical: {
backgroundOffsetX: 50,
backgroundOffsetY: 0,
contentOffsetX: 100,
contentOffsetY: 50,
titleCenterX: 240,
titleRectX: 50,
titleRectY: 0,
titleTextY: 5,
},
horizontal: {
backgroundOffsetX: 50,
backgroundOffsetY: 0,
contentOffsetX: 100,
contentOffsetY: 50,
titleCenterY: 240,
titleRectX: 0,
titleRectY: 50,
},
} as const;
/**
*
*
* @param options - /
*/
export function calculateVerticalDataBlockSize({
contentHeight,
maxContentWidth,
withBackground,
withTitle,
}: {
contentHeight: number;
maxContentWidth: number;
withBackground: boolean;
withTitle: boolean;
}) {
const bodyHeight =
contentHeight +
(withBackground ? BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight : 0);
return {
height:
bodyHeight +
(withBackground && withTitle
? BANGDREAM_DATA_BLOCK_SPEC.title.height
: 0),
width:
maxContentWidth +
(withBackground
? BANGDREAM_DATA_BLOCK_SPEC.background.outerExtraWidth
: BANGDREAM_DATA_BLOCK_SPEC.background.outerExtraWidth),
};
}
/**
*
*
* @param options - /
*/
export function calculateHorizontalDataBlockSize({
contentWidth,
maxContentHeight,
withBackground,
withTitle,
}: {
contentWidth: number;
maxContentHeight: number;
withBackground: boolean;
withTitle: boolean;
}) {
const bodyWidth =
contentWidth +
(withBackground ? BANGDREAM_DATA_BLOCK_SPEC.background.outerExtraWidth : 0);
return {
height:
maxContentHeight + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight,
width:
bodyWidth +
(withBackground && withTitle
? BANGDREAM_DATA_BLOCK_SPEC.title.height
: 0),
};
}
/**
*
*/
export function getDataBlockTitleLineHeight() {
return (
BANGDREAM_DATA_BLOCK_SPEC.title.height -
BANGDREAM_DATA_BLOCK_SPEC.title.textInset
);
}

View File

@ -2,6 +2,12 @@ import { Canvas, Image } from 'skia-canvas';
import { drawRoundedRect } from '@/qqbot/plugins/bangDream/tsugu/canvas/rect'; import { drawRoundedRect } from '@/qqbot/plugins/bangDream/tsugu/canvas/rect';
import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'; import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text';
import { resizeImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { resizeImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
import {
BANGDREAM_DATA_BLOCK_SPEC,
calculateHorizontalDataBlockSize,
calculateVerticalDataBlockSize,
getDataBlockTitleLineHeight,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/data-block-spec';
interface DataBlockOptions { interface DataBlockOptions {
list: Array<Canvas | Image>; list: Array<Canvas | Image>;
@ -20,27 +26,29 @@ export function drawDataBlock({
list, list,
BG = true, BG = true,
topLeftText, topLeftText,
opacity = 0.9, opacity = BANGDREAM_DATA_BLOCK_SPEC.background.defaultOpacity,
}: DataBlockOptions): Canvas { }: DataBlockOptions): Canvas {
const topLeftTextHeight = 70;
//计算高度 //计算高度
let allH = 0; let contentHeight = 0;
let maxW = 0; let maxW = 0;
if (BG) {
allH += 100;
}
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
allH = allH + list[i].height; contentHeight += list[i].height;
if (list[i].width > maxW) { if (list[i].width > maxW) {
maxW = list[i].width; maxW = list[i].width;
} }
} }
const allH =
contentHeight +
(BG ? BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight : 0);
const canvasSize = calculateVerticalDataBlockSize({
contentHeight,
maxContentWidth: maxW,
withBackground: BG,
withTitle: topLeftText != undefined,
});
//创建Canvas //创建Canvas
const tempCanvas = const tempCanvas = new Canvas(canvasSize.width, canvasSize.height);
topLeftText != undefined && BG
? new Canvas(maxW + 200, allH + topLeftTextHeight)
: new Canvas(maxW + 200, allH);
const ctx = tempCanvas.getContext('2d'); const ctx = tempCanvas.getContext('2d');
//画背景 //画背景
@ -51,58 +59,76 @@ export function drawDataBlock({
drawRoundedRect({ drawRoundedRect({
//画字底,左下角右下角没有圆角 //画字底,左下角右下角没有圆角
opacity: 1, opacity: 1,
color: '#ea4e73', color: BANGDREAM_DATA_BLOCK_SPEC.title.backgroundColor,
width: 380, width: BANGDREAM_DATA_BLOCK_SPEC.title.blockSize,
height: topLeftTextHeight + 5, height:
radius: [25, 25, 0, 0], BANGDREAM_DATA_BLOCK_SPEC.title.height +
strokeColor: '#ffffff', BANGDREAM_DATA_BLOCK_SPEC.title.rectExtraSize,
strokeWidth: 5, radius: [
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
0,
0,
],
strokeColor: BANGDREAM_DATA_BLOCK_SPEC.title.strokeColor,
strokeWidth: BANGDREAM_DATA_BLOCK_SPEC.title.strokeWidth,
}), }),
50, BANGDREAM_DATA_BLOCK_SPEC.vertical.titleRectX,
0, BANGDREAM_DATA_BLOCK_SPEC.vertical.titleRectY,
); );
const textImage = drawText({ const textImage = drawText({
//画字 //画字
color: '#ffffff', color: BANGDREAM_DATA_BLOCK_SPEC.title.textColor,
text: topLeftText, text: topLeftText,
maxWidth: 370, maxWidth:
lineHeight: topLeftTextHeight - 5, BANGDREAM_DATA_BLOCK_SPEC.title.blockSize -
BANGDREAM_DATA_BLOCK_SPEC.title.textInset * 2,
lineHeight: getDataBlockTitleLineHeight(),
}); });
ctx.drawImage(textImage, 240 - textImage.width / 2, 5); ctx.drawImage(
textImage,
BANGDREAM_DATA_BLOCK_SPEC.vertical.titleCenterX - textImage.width / 2,
BANGDREAM_DATA_BLOCK_SPEC.vertical.titleTextY,
);
ctx.drawImage( ctx.drawImage(
drawRoundedRect({ drawRoundedRect({
//画总底,左上角没有圆角 //画总底,左上角没有圆角
opacity, opacity,
width: maxW + 100, width: maxW + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth,
height: allH, height: allH,
radius: [0, 25, 25, 25], radius: [
0,
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
],
}), }),
50, BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetX,
topLeftTextHeight, BANGDREAM_DATA_BLOCK_SPEC.title.height,
); );
} else { } else {
ctx.drawImage( ctx.drawImage(
drawRoundedRect({ drawRoundedRect({
//画总底 //画总底
opacity, opacity,
width: maxW + 100, width: maxW + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth,
height: allH, height: allH,
}), }),
50, BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetX,
0, BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetY,
); );
} }
} }
let allH2 = 0; let allH2 = 0;
if (BG) { if (BG) {
allH2 += 50; allH2 += BANGDREAM_DATA_BLOCK_SPEC.vertical.contentOffsetY;
if (topLeftText != undefined) { if (topLeftText != undefined) {
allH2 += topLeftTextHeight; allH2 += BANGDREAM_DATA_BLOCK_SPEC.title.height;
} }
} }
const xStart = BG ? 100 : 0; const xStart = BG ? BANGDREAM_DATA_BLOCK_SPEC.vertical.contentOffsetX : 0;
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
ctx.drawImage(list[i], xStart, allH2); ctx.drawImage(list[i], xStart, allH2);
@ -123,26 +149,27 @@ export function drawDataBlockHorizontal({
BG = true, BG = true,
topLeftText, topLeftText,
}: DataBlockOptions): Canvas { }: DataBlockOptions): Canvas {
const topLeftTextHeight = 70;
// 计算宽度和高度 // 计算宽度和高度
let allW = 0; let contentWidth = 0;
let maxH = 0; let maxH = 0;
if (BG) {
allW += 200;
}
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
allW += list[i].width; contentWidth += list[i].width;
if (list[i].height > maxH) { if (list[i].height > maxH) {
maxH = list[i].height; maxH = list[i].height;
} }
} }
const allW =
contentWidth +
(BG ? BANGDREAM_DATA_BLOCK_SPEC.background.outerExtraWidth : 0);
const canvasSize = calculateHorizontalDataBlockSize({
contentWidth,
maxContentHeight: maxH,
withBackground: BG,
withTitle: topLeftText !== undefined,
});
// 创建 Canvas // 创建 Canvas
const tempCanvas = const tempCanvas = new Canvas(canvasSize.width, canvasSize.height);
topLeftText !== undefined && BG
? new Canvas(allW + topLeftTextHeight, maxH + 100)
: new Canvas(allW, maxH + 100);
const ctx = tempCanvas.getContext('2d'); const ctx = tempCanvas.getContext('2d');
// 绘制背景 // 绘制背景
@ -152,59 +179,80 @@ export function drawDataBlockHorizontal({
ctx.drawImage( ctx.drawImage(
drawRoundedRect({ drawRoundedRect({
opacity: 1, opacity: 1,
color: '#ea4e73', color: BANGDREAM_DATA_BLOCK_SPEC.title.backgroundColor,
width: topLeftTextHeight + 5, width:
height: 380, BANGDREAM_DATA_BLOCK_SPEC.title.height +
radius: [25, 25, 0, 0], BANGDREAM_DATA_BLOCK_SPEC.title.rectExtraSize,
strokeColor: '#ffffff', height: BANGDREAM_DATA_BLOCK_SPEC.title.blockSize,
strokeWidth: 5, radius: [
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
0,
0,
],
strokeColor: BANGDREAM_DATA_BLOCK_SPEC.title.strokeColor,
strokeWidth: BANGDREAM_DATA_BLOCK_SPEC.title.strokeWidth,
}), }),
0, BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleRectX,
50, BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleRectY,
); );
const textImage = drawText({ const textImage = drawText({
color: '#ffffff', color: BANGDREAM_DATA_BLOCK_SPEC.title.textColor,
text: topLeftText, text: topLeftText,
maxWidth: topLeftTextHeight - 5, maxWidth: getDataBlockTitleLineHeight(),
lineHeight: 370, lineHeight:
BANGDREAM_DATA_BLOCK_SPEC.title.blockSize -
BANGDREAM_DATA_BLOCK_SPEC.title.textInset * 2,
}); });
ctx.save(); ctx.save();
ctx.translate(topLeftTextHeight - 5, 240); ctx.translate(
getDataBlockTitleLineHeight(),
BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleCenterY,
);
ctx.rotate(-Math.PI / 2); ctx.rotate(-Math.PI / 2);
ctx.drawImage(textImage, 0, 0); ctx.drawImage(textImage, 0, 0);
ctx.restore(); ctx.restore();
ctx.drawImage( ctx.drawImage(
drawRoundedRect({ drawRoundedRect({
width: allW - 100, width: allW - BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth,
height: maxH + 100, height: maxH + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight,
radius: [25, 0, 25, 25], radius: [
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
0,
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
],
}), }),
topLeftTextHeight, BANGDREAM_DATA_BLOCK_SPEC.title.height,
50, BANGDREAM_DATA_BLOCK_SPEC.horizontal.contentOffsetY,
); );
} else { } else {
ctx.drawImage( ctx.drawImage(
drawRoundedRect({ drawRoundedRect({
width: allW - 100, width: allW - BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth,
height: maxH + 100, height: maxH + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight,
}), }),
50, BANGDREAM_DATA_BLOCK_SPEC.horizontal.backgroundOffsetX,
0, BANGDREAM_DATA_BLOCK_SPEC.horizontal.backgroundOffsetY,
); );
} }
} }
let allW2 = 0; let allW2 = 0;
if (BG) { if (BG) {
allW2 += 100; allW2 += BANGDREAM_DATA_BLOCK_SPEC.horizontal.contentOffsetX;
if (topLeftText !== undefined) { if (topLeftText !== undefined) {
allW2 += topLeftTextHeight; allW2 += BANGDREAM_DATA_BLOCK_SPEC.title.height;
} }
} }
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
ctx.drawImage(list[i], allW2, 50); ctx.drawImage(
list[i],
allW2,
BANGDREAM_DATA_BLOCK_SPEC.horizontal.contentOffsetY,
);
allW2 += list[i].width; allW2 += list[i].width;
} }
@ -220,6 +268,6 @@ export function drawDataBlockHorizontal({
export function drawBannerImageCanvas(eventBannerImage: Image): Canvas { export function drawBannerImageCanvas(eventBannerImage: Image): Canvas {
return resizeImage({ return resizeImage({
image: eventBannerImage, image: eventBannerImage,
widthMax: 800, widthMax: BANGDREAM_DATA_BLOCK_SPEC.banner.widthMax,
}); });
} }

View File

@ -5,6 +5,11 @@ import {
getServerByPriority, getServerByPriority,
} from '@/qqbot/plugins/bangDream/tsugu/models/server'; } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { Canvas } from 'skia-canvas'; import { Canvas } from 'skia-canvas';
import {
BANGDREAM_DEGREE_LIST_SPEC,
shouldDrawDegreeDecorations,
shouldDrawDegreeIcon,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/degree-list-spec';
/** /**
* *
@ -23,17 +28,22 @@ export async function drawDegree(
if (degree.degreeName[server] == null) { if (degree.degreeName[server] == null) {
server = getServerByPriority(degree.degreeName, displayedServerList); server = getServerByPriority(degree.degreeName, displayedServerList);
} }
const canvas = new Canvas(230, 50); const canvas = new Canvas(
BANGDREAM_DEGREE_LIST_SPEC.badge.width,
BANGDREAM_DEGREE_LIST_SPEC.badge.height,
);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const degreeImage = await degree.getDegreeImage(server); //底图 const degreeImage = await degree.getDegreeImage(server); //底图
ctx.drawImage(degreeImage, 0, 0); ctx.drawImage(degreeImage, 0, 0);
// 画其他部分,normal类型不需要画 // 画其他部分,normal类型不需要画
const degreeType = degree.degreeType[server];
if ( if (
degree.degreeType[server] != 'normal' && shouldDrawDegreeDecorations({
degree.degreeType[server] != null && degreeId: degree.degreeId,
degree.degreeId > 12 degreeType,
})
) { ) {
//画框 //画框
if (degree.rank[server] && degree.rank[server] != 'none') { if (degree.rank[server] && degree.rank[server] != 'none') {
@ -41,7 +51,7 @@ export async function drawDegree(
ctx.drawImage(frame, 0, 0); ctx.drawImage(frame, 0, 0);
} }
//画icon //画icon
if (degree.degreeType[server] != 'try_clear') { if (shouldDrawDegreeIcon(degreeType)) {
// 如果不是EX牌活动 EX牌活动没有icon在左边 // 如果不是EX牌活动 EX牌活动没有icon在左边
const icon = await degree.getDegreeIcon(server); const icon = await degree.getDegreeIcon(server);
ctx.drawImage(icon, 0, 0); ctx.drawImage(icon, 0, 0);

View File

@ -0,0 +1,81 @@
export const BANGDREAM_DEGREE_LIST_SPEC = {
badge: {
decoratedMinDegreeIdExclusive: 12,
height: 50,
normalType: 'normal',
tryClearType: 'try_clear',
width: 230,
},
eventRewards: {
degreeRewardType: 'degree',
liveTryResourceType: 'degree',
maxDegreeCount: 6,
musicRankingEventTypes: ['versus', 'challenge', 'medley'],
stopAfterFirstMusicGroupEventType: 'medley',
},
list: {
textSize: 50,
},
} as const;
/**
*
*
* @param options - ID
*/
export function shouldDrawDegreeDecorations({
degreeId,
degreeType,
}: {
degreeId: number;
degreeType: string | null;
}) {
return (
degreeType != null &&
degreeType !== BANGDREAM_DEGREE_LIST_SPEC.badge.normalType &&
degreeId > BANGDREAM_DEGREE_LIST_SPEC.badge.decoratedMinDegreeIdExclusive
);
}
/**
*
*
* @param degreeType -
*/
export function shouldDrawDegreeIcon(degreeType: string | null) {
return degreeType !== BANGDREAM_DEGREE_LIST_SPEC.badge.tryClearType;
}
/**
*
*
* @param eventType -
*/
export function shouldCollectMusicRankingDegreeRewards(eventType: string) {
return BANGDREAM_DEGREE_LIST_SPEC.eventRewards.musicRankingEventTypes.includes(
eventType as (typeof BANGDREAM_DEGREE_LIST_SPEC.eventRewards.musicRankingEventTypes)[number],
);
}
/**
*
*
* @param eventType -
*/
export function shouldStopAfterFirstMusicRewardGroup(eventType: string) {
return (
eventType ===
BANGDREAM_DEGREE_LIST_SPEC.eventRewards.stopAfterFirstMusicGroupEventType
);
}
/**
*
*
* @param rewardType -
*/
export function isDegreeRewardType(rewardType: string | null | undefined) {
return (
rewardType === BANGDREAM_DEGREE_LIST_SPEC.eventRewards.degreeRewardType
);
}

View File

@ -0,0 +1,36 @@
export const BANGDREAM_GACHA_LIST_SPEC = {
behaviorLabels: {
fixed_4_star_once: '必中★4',
fixed_5_star_once: '必中★5',
normal: '',
once_a_day: '每日一次',
over_the_3_star_once: '必中★3+',
over_the_4_star_once: '必中★4+',
},
label: {
empty: '无',
paymentMethod: '付费方式',
pickup: '卡池PickUp',
rateDistribution: '概率分布',
rateMissing: '未提供概率分布数据',
},
paymentText: {
drawCountSuffix: '次抽卡',
limitPrefix: ' 仅限',
limitSuffix: '次',
unknownItemPrefix: ' ? x',
},
} as const;
/**
*
*
* @param behavior -
*/
export function getGachaPaymentBehaviorLabel(behavior: string) {
return (
BANGDREAM_GACHA_LIST_SPEC.behaviorLabels[
behavior as keyof typeof BANGDREAM_GACHA_LIST_SPEC.behaviorLabels
] ?? behavior
);
}

View File

@ -1,6 +1,10 @@
import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute'; import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute';
import { drawList } from './list-frame'; import { drawList } from './list-frame';
import { Canvas, Image } from 'skia-canvas'; import { Canvas, Image } from 'skia-canvas';
import {
BANGDREAM_ENTITY_LIST_SPEC,
shouldUseSingleEntityLabel,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec';
interface AttributeInListOptions { interface AttributeInListOptions {
key?: string; key?: string;
@ -19,7 +23,7 @@ export async function drawAttributeInList({
text, text,
}: AttributeInListOptions): Promise<Canvas> { }: AttributeInListOptions): Promise<Canvas> {
const list: Array<string | Image | Canvas> = []; const list: Array<string | Image | Canvas> = [];
if (content.length == 1 && text == undefined) { if (shouldUseSingleEntityLabel(content.length, text)) {
list.push(await content[0].getIcon()); list.push(await content[0].getIcon());
list.push(content[0].name.toUpperCase()); list.push(content[0].name.toUpperCase());
const canvas = drawList({ const canvas = drawList({
@ -33,7 +37,7 @@ export async function drawAttributeInList({
const canvas = drawList({ const canvas = drawList({
key: key, key: key,
content: list, content: list,
spacing: 0, spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing,
}); });
return canvas; return canvas;
} }

View File

@ -2,6 +2,10 @@ import { Band } from '@/qqbot/plugins/bangDream/tsugu/models/band';
import { getServerByPriority } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { getServerByPriority } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { drawList } from './list-frame'; import { drawList } from './list-frame';
import { Canvas, Image } from 'skia-canvas'; import { Canvas, Image } from 'skia-canvas';
import {
BANGDREAM_ENTITY_LIST_SPEC,
shouldUseSingleEntityLabel,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec';
interface BandInListOptions { interface BandInListOptions {
key?: string; key?: string;
@ -21,7 +25,7 @@ export async function drawBandInList({
}: BandInListOptions): Promise<Canvas> { }: BandInListOptions): Promise<Canvas> {
const server = getServerByPriority(content[0].bandName); const server = getServerByPriority(content[0].bandName);
const list: Array<string | Image | Canvas> = []; const list: Array<string | Image | Canvas> = [];
if (content.length == 1 && text == undefined) { if (shouldUseSingleEntityLabel(content.length, text)) {
if (content[0].hasIcon) { if (content[0].hasIcon) {
list.push(await content[0].getIcon()); list.push(await content[0].getIcon());
} }
@ -34,7 +38,7 @@ export async function drawBandInList({
} else { } else {
for (let i = 0; i < content.length; i++) { for (let i = 0; i < content.length; i++) {
const band = content[i]; const band = content[i];
if (this.hasIcon) { if (band.hasIcon) {
list.push(await band.getIcon()); list.push(await band.getIcon());
} else { } else {
list.push(band.bandName[server]); list.push(band.bandName[server]);
@ -46,7 +50,7 @@ export async function drawBandInList({
const canvas = drawList({ const canvas = drawList({
key: key, key: key,
content: list, content: list,
spacing: 0, spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing,
}); });
return canvas; return canvas;
} }

View File

@ -6,6 +6,10 @@ import {
} from '@/qqbot/plugins/bangDream/tsugu/models/server'; } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { drawList } from './list-frame'; import { drawList } from './list-frame';
import { Canvas, Image } from 'skia-canvas'; import { Canvas, Image } from 'skia-canvas';
import {
BANGDREAM_ENTITY_LIST_SPEC,
shouldUseSingleEntityLabel,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec';
interface CharacterInListOptions { interface CharacterInListOptions {
key?: string; key?: string;
@ -28,7 +32,7 @@ export async function drawCharacterInList(
displayedServerList, displayedServerList,
); );
const list: Array<string | Image | Canvas> = []; const list: Array<string | Image | Canvas> = [];
if (content.length == 1 && text == undefined) { if (shouldUseSingleEntityLabel(content.length, text)) {
list.push(await content[0].getIcon()); list.push(await content[0].getIcon());
list.push(content[0].getCharacterName()[server]); list.push(content[0].getCharacterName()[server]);
const canvas = drawList({ const canvas = drawList({
@ -47,7 +51,7 @@ export async function drawCharacterInList(
const canvas = drawList({ const canvas = drawList({
key: key, key: key,
content: list, content: list,
spacing: 0, spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing,
}); });
return canvas; return canvas;
} }

View File

@ -9,6 +9,12 @@ import {
import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event'; import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event';
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
import {
BANGDREAM_DEGREE_LIST_SPEC,
isDegreeRewardType,
shouldCollectMusicRankingDegreeRewards,
shouldStopAfterFirstMusicRewardGroup,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/degree-list-spec';
interface DegreeListInListOptions { interface DegreeListInListOptions {
key?: string; key?: string;
@ -37,7 +43,7 @@ export async function drawDegreeListInList({
return drawList({ return drawList({
key: key, key: key,
content: list, content: list,
textSize: 50, textSize: BANGDREAM_DEGREE_LIST_SPEC.list.textSize,
}); });
} }
@ -58,9 +64,13 @@ export async function drawDegreeListOfEvent(
const server = getServerByPriority(event.rankingRewards, displayedServerList); const server = getServerByPriority(event.rankingRewards, displayedServerList);
const rankingRewards = event.rankingRewards[server]; const rankingRewards = event.rankingRewards[server];
for (let i = 0; i < rankingRewards.length; i++) { for (let i = 0; i < rankingRewards.length; i++) {
if (rankingRewards[i].rewardType == 'degree') { if (isDegreeRewardType(rankingRewards[i].rewardType)) {
tempDegreeList.push(new Degree(rankingRewards[i].rewardId)); tempDegreeList.push(new Degree(rankingRewards[i].rewardId));
if (tempDegreeList.length >= 6) break; if (
tempDegreeList.length >=
BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount
)
break;
} }
} }
list.push( list.push(
@ -71,20 +81,22 @@ export async function drawDegreeListOfEvent(
displayedServerList: displayedServerList, displayedServerList: displayedServerList,
}), }),
); );
if ( if (shouldCollectMusicRankingDegreeRewards(event.eventType)) {
event.eventType == 'versus' ||
event.eventType == 'challenge' ||
event.eventType == 'medley'
) {
const rewards = event.musics[server]; const rewards = event.musics[server];
for (let i = 0; i < rewards.length; i++) { for (let i = 0; i < rewards.length; i++) {
const tempDegreeList = []; const tempDegreeList = [];
for (let n = 0; n < rewards[i].musicRankingRewards.length; n++) { for (let n = 0; n < rewards[i].musicRankingRewards.length; n++) {
if (rewards[i].musicRankingRewards[n].resourceType == 'degree') { if (
isDegreeRewardType(rewards[i].musicRankingRewards[n].resourceType)
) {
tempDegreeList.push( tempDegreeList.push(
new Degree(rewards[i].musicRankingRewards[n].resourceId), new Degree(rewards[i].musicRankingRewards[n].resourceId),
); );
if (tempDegreeList.length >= 6) break; if (
tempDegreeList.length >=
BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount
)
break;
} }
} }
list.push( list.push(
@ -94,7 +106,7 @@ export async function drawDegreeListOfEvent(
displayedServerList: displayedServerList, displayedServerList: displayedServerList,
}), }),
); );
if (event.eventType == 'medley') { if (shouldStopAfterFirstMusicRewardGroup(event.eventType)) {
break; break;
} }
} }
@ -106,9 +118,13 @@ export async function drawDegreeListOfEvent(
if (Object.prototype.hasOwnProperty.call(rewards, i)) { if (Object.prototype.hasOwnProperty.call(rewards, i)) {
const rewardsList = rewards[i]['entries']; const rewardsList = rewards[i]['entries'];
for (const j in rewardsList) { for (const j in rewardsList) {
if (rewardsList[j]['resourceType'] == 'degree') { if (isDegreeRewardType(rewardsList[j]['resourceType'])) {
tempDegreeList.push(new Degree(rewardsList[j]['resourceId'])); tempDegreeList.push(new Degree(rewardsList[j]['resourceId']));
if (tempDegreeList.length >= 6) break; if (
tempDegreeList.length >=
BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount
)
break;
} }
} }
} }

View File

@ -0,0 +1,16 @@
export const BANGDREAM_ENTITY_LIST_SPEC = {
multiValueSpacing: 0,
} as const;
/**
*
*
* @param contentLength -
* @param text -
*/
export function shouldUseSingleEntityLabel(
contentLength: number,
text?: string,
) {
return contentLength === 1 && text == null;
}

View File

@ -3,15 +3,10 @@ import { Gacha } from '@/qqbot/plugins/bangDream/tsugu/models/gacha';
import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { Item } from '@/qqbot/plugins/bangDream/tsugu/models/item'; import { Item } from '@/qqbot/plugins/bangDream/tsugu/models/item';
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
import {
const behaviorName = { BANGDREAM_GACHA_LIST_SPEC,
normal: '', getGachaPaymentBehaviorLabel,
over_the_3_star_once: '必中★3+', } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec';
over_the_4_star_once: '必中★4+',
once_a_day: '每日一次',
fixed_4_star_once: '必中★4',
fixed_5_star_once: '必中★5',
};
/** /**
* Gasha支付方式MethodIn列表 * Gasha支付方式MethodIn列表
@ -45,29 +40,34 @@ export async function drawGashaPaymentMethodInList(gacha: Gacha) {
} }
methodDescription.push(`x${costItemQuantity}`); methodDescription.push(`x${costItemQuantity}`);
} else { } else {
methodDescription.push(` ? x${costItemQuantity}`); methodDescription.push(
`${BANGDREAM_GACHA_LIST_SPEC.paymentText.unknownItemPrefix}${costItemQuantity}`,
);
} }
//抽卡次数 //抽卡次数
if (patmentMethod.count != undefined) { if (patmentMethod.count != undefined) {
methodDescription.push(patmentMethod.count + '次抽卡'); methodDescription.push(
`${patmentMethod.count}${BANGDREAM_GACHA_LIST_SPEC.paymentText.drawCountSuffix}`,
);
} }
//更多情况描述 //更多情况描述
if (behaviorName[patmentMethod.behavior] != undefined) { const behaviorLabel = getGachaPaymentBehaviorLabel(patmentMethod.behavior);
if (behaviorName[patmentMethod.behavior] != '') { if (behaviorLabel !== '') {
methodDescription.push(' ' + behaviorName[patmentMethod.behavior]); methodDescription.push(' ' + behaviorLabel);
}
} else {
methodDescription.push(patmentMethod.behavior);
} }
if (patmentMethod['maxSpinLimit'] != undefined) { if (patmentMethod['maxSpinLimit'] != undefined) {
methodDescription.push(' 仅限' + patmentMethod['maxSpinLimit'] + '次'); methodDescription.push(
`${BANGDREAM_GACHA_LIST_SPEC.paymentText.limitPrefix}${patmentMethod['maxSpinLimit']}${BANGDREAM_GACHA_LIST_SPEC.paymentText.limitSuffix}`,
);
} }
const isFirst = i == 0; const isFirst = i == 0;
list.push( list.push(
drawList({ drawList({
key: isFirst ? '付费方式' : undefined, key: isFirst
? BANGDREAM_GACHA_LIST_SPEC.label.paymentMethod
: undefined,
content: methodDescription, content: methodDescription,
}), }),
); );

View File

@ -5,6 +5,7 @@ import { drawCardListInList } from './list-card-icon-list';
import { Card } from '@/qqbot/plugins/bangDream/tsugu/models/card'; import { Card } from '@/qqbot/plugins/bangDream/tsugu/models/card';
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
import { Canvas } from 'skia-canvas'; import { Canvas } from 'skia-canvas';
import { BANGDREAM_GACHA_LIST_SPEC } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec';
/** /**
* PickUpIn列表 * PickUpIn列表
@ -22,7 +23,7 @@ export async function drawGachaPickupInList(
const list = []; const list = [];
list.push( list.push(
drawList({ drawList({
key: key ?? '卡池PickUp', key: key ?? BANGDREAM_GACHA_LIST_SPEC.label.pickup,
}), }),
); );
let pickUpCardIdList = []; let pickUpCardIdList = [];
@ -72,8 +73,8 @@ export async function drawGachaPickupInList(
return stackImage(list); return stackImage(list);
} else { } else {
const result = drawList({ const result = drawList({
key: key ?? '卡池PickUp', key: key ?? BANGDREAM_GACHA_LIST_SPEC.label.pickup,
text: '无', text: BANGDREAM_GACHA_LIST_SPEC.label.empty,
}); });
return result; return result;
} }

View File

@ -4,6 +4,7 @@ import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
import { Canvas } from 'skia-canvas'; import { Canvas } from 'skia-canvas';
import { drawList } from './list-frame'; import { drawList } from './list-frame';
import { BANGDREAM_GACHA_LIST_SPEC } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec';
/** /**
* In列表 * In列表
@ -22,11 +23,11 @@ export async function drawGachaRateInList(
let key = undefined; let key = undefined;
// 如果卡池数据没有提供概率数据,则不返回概率相关数据 // 如果卡池数据没有提供概率数据,则不返回概率相关数据
if (rates == null) { if (rates == null) {
key = '概率分布'; key = BANGDREAM_GACHA_LIST_SPEC.label.rateDistribution;
list.push( list.push(
drawList({ drawList({
key, key,
text: `未提供概率分布数据`, text: BANGDREAM_GACHA_LIST_SPEC.label.rateMissing,
}), }),
); );
} else { } else {
@ -35,7 +36,7 @@ export async function drawGachaRateInList(
continue; continue;
} }
if (times == 0) { if (times == 0) {
key = '概率分布'; key = BANGDREAM_GACHA_LIST_SPEC.label.rateDistribution;
} }
list.push( list.push(
await drawRarityInList({ await drawRarityInList({

View File

@ -0,0 +1,100 @@
export const BANGDREAM_PLAYER_RANKING_SPEC = {
backgroundColor: 'white',
canvas: {
height: 110,
width: 800,
},
degree: {
gap: 10,
scale: 0.5,
startX: 210,
y: 46,
},
headShot: {
height: 90,
width: 90,
x: 85,
y: 10,
},
medalRanking: {
max: 3,
min: 1,
},
ranking: {
fallbackMaxWidth: 100,
imageHeight: 21,
imageWidth: 45,
textSize: 21,
x: 12,
y: 45,
},
rightColumn: {
edgeX: 790,
maxWidth: 150,
rankY: 10,
idY: 45,
pointY: 70,
},
text: {
idSize: 20,
introductionMaxWidth: 450,
introductionSize: 20,
introductionX: 210,
introductionY: 75,
nameMaxWidth: 450,
nameSize: 23,
nameX: 210,
nameY: 10,
rankSize: 23,
pointSize: 23,
},
} as const;
/**
*
*
* @param text -
*/
export function stripPlayerRankingTextTags(text: string) {
return text.replace(/\[[^\]]*\]/g, '');
}
/**
* 使
*
* @param ranking -
*/
export function isMedalRanking(ranking: number | undefined) {
return (
ranking != null &&
ranking >= BANGDREAM_PLAYER_RANKING_SPEC.medalRanking.min &&
ranking <= BANGDREAM_PLAYER_RANKING_SPEC.medalRanking.max
);
}
/**
*
*
* @param index -
* @param width -
* @param height -
*/
export function createRankingDegreeLayout({
height,
index,
width,
}: {
height: number;
index: number;
width: number;
}) {
const scale = BANGDREAM_PLAYER_RANKING_SPEC.degree.scale;
return {
height: height * scale,
width: width * scale,
x:
BANGDREAM_PLAYER_RANKING_SPEC.degree.startX +
(width * scale + BANGDREAM_PLAYER_RANKING_SPEC.degree.gap) * index,
y: BANGDREAM_PLAYER_RANKING_SPEC.degree.y,
};
}

View File

@ -7,6 +7,12 @@ import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { Degree } from '@/qqbot/plugins/bangDream/tsugu/models/degree'; import { Degree } from '@/qqbot/plugins/bangDream/tsugu/models/degree';
import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'; import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text';
import { playerRankingResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/player-ranking-resource-repository'; import { playerRankingResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/player-ranking-resource-repository';
import {
BANGDREAM_PLAYER_RANKING_SPEC,
createRankingDegreeLayout,
isMedalRanking,
stripPlayerRankingTextTags,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking-spec';
interface User { interface User {
uid: number; uid: number;
@ -30,44 +36,51 @@ interface User {
*/ */
export async function drawPlayerRankingInList( export async function drawPlayerRankingInList(
user: User, user: User,
backgroudColor: string = 'white', backgroudColor: string = BANGDREAM_PLAYER_RANKING_SPEC.backgroundColor,
server: Server, server: Server,
): Promise<Canvas> { ): Promise<Canvas> {
const canvas = new Canvas(800, 110); const canvas = new Canvas(
BANGDREAM_PLAYER_RANKING_SPEC.canvas.width,
BANGDREAM_PLAYER_RANKING_SPEC.canvas.height,
);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.fillStyle = backgroudColor; ctx.fillStyle = backgroudColor;
ctx.fillRect(0, 0, 800, 110); ctx.fillRect(
0,
/** 0,
* Braces BANGDREAM_PLAYER_RANKING_SPEC.canvas.width,
* BANGDREAM_PLAYER_RANKING_SPEC.canvas.height,
* @param text - );
* @returns
*/
function removeBraces(text: string): string {
const newText = text.replace(/\[[^\]]*\]/g, '');
return newText;
}
//排名 //排名
let rankingImage; let rankingImage;
if (user.ranking == undefined) { if (user.ranking == undefined) {
return; return;
} else if (user.ranking > 0 && user.ranking <= 3) { } else if (isMedalRanking(user.ranking)) {
const rankIamgeBuffer = const rankIamgeBuffer =
await playerRankingResourceRepository.getRankImageBuffer( await playerRankingResourceRepository.getRankImageBuffer(
server, server,
user.ranking, user.ranking,
); );
rankingImage = await loadImage(rankIamgeBuffer); rankingImage = await loadImage(rankIamgeBuffer);
ctx.drawImage(rankingImage, 12, 45, 45, 21); ctx.drawImage(
rankingImage,
BANGDREAM_PLAYER_RANKING_SPEC.ranking.x,
BANGDREAM_PLAYER_RANKING_SPEC.ranking.y,
BANGDREAM_PLAYER_RANKING_SPEC.ranking.imageWidth,
BANGDREAM_PLAYER_RANKING_SPEC.ranking.imageHeight,
);
} else { } else {
rankingImage = drawText({ rankingImage = drawText({
text: '#' + user.ranking.toString(), text: '#' + user.ranking.toString(),
textSize: 21, textSize: BANGDREAM_PLAYER_RANKING_SPEC.ranking.textSize,
maxWidth: 100, maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.ranking.fallbackMaxWidth,
}); });
ctx.drawImage(rankingImage, 12, 45); ctx.drawImage(
rankingImage,
BANGDREAM_PLAYER_RANKING_SPEC.ranking.x,
BANGDREAM_PLAYER_RANKING_SPEC.ranking.y,
);
} }
//头像 //头像
@ -78,59 +91,90 @@ export async function drawPlayerRankingInList(
skillTypeVisible: false, skillTypeVisible: false,
cardTypeVisible: false, cardTypeVisible: false,
}); });
ctx.drawImage(headShotImage, 85, 10, 90, 90); ctx.drawImage(
headShotImage,
BANGDREAM_PLAYER_RANKING_SPEC.headShot.x,
BANGDREAM_PLAYER_RANKING_SPEC.headShot.y,
BANGDREAM_PLAYER_RANKING_SPEC.headShot.width,
BANGDREAM_PLAYER_RANKING_SPEC.headShot.height,
);
//玩家昵称 //玩家昵称
const playerNameImage = drawText({ const playerNameImage = drawText({
text: removeBraces(user.name), text: stripPlayerRankingTextTags(user.name),
textSize: 23, textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.nameSize,
maxWidth: 450, maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.text.nameMaxWidth,
}); });
ctx.drawImage(playerNameImage, 210, 10); ctx.drawImage(
playerNameImage,
BANGDREAM_PLAYER_RANKING_SPEC.text.nameX,
BANGDREAM_PLAYER_RANKING_SPEC.text.nameY,
);
//牌子 //牌子
for (let i = 0; i < user.degrees.length; i++) { for (let i = 0; i < user.degrees.length; i++) {
const degreeImage = await drawDegree(new Degree(user.degrees[i]), server); const degreeImage = await drawDegree(new Degree(user.degrees[i]), server);
const degreeLayout = createRankingDegreeLayout({
height: degreeImage.height,
index: i,
width: degreeImage.width,
});
ctx.drawImage( ctx.drawImage(
degreeImage, degreeImage,
210 + (degreeImage.width / 2 + 10) * i, degreeLayout.x,
46, degreeLayout.y,
degreeImage.width / 2, degreeLayout.width,
degreeImage.height / 2, degreeLayout.height,
); );
} }
//简介 //简介
const playerIntroductionImage = drawText({ const playerIntroductionImage = drawText({
text: removeBraces(user.introduction), text: stripPlayerRankingTextTags(user.introduction),
textSize: 20, textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.introductionSize,
maxWidth: 450, maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.text.introductionMaxWidth,
}); });
ctx.drawImage(playerIntroductionImage, 210, 75); ctx.drawImage(
playerIntroductionImage,
BANGDREAM_PLAYER_RANKING_SPEC.text.introductionX,
BANGDREAM_PLAYER_RANKING_SPEC.text.introductionY,
);
//等级 //等级
const playerRankImage = drawText({ const playerRankImage = drawText({
text: '等级 ' + user.rank.toString(), text: '等级 ' + user.rank.toString(),
textSize: 23, textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.rankSize,
maxWidth: 150, maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.maxWidth,
}); });
ctx.drawImage(playerRankImage, 790 - playerRankImage.width, 10); ctx.drawImage(
playerRankImage,
BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.edgeX - playerRankImage.width,
BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.rankY,
);
//id //id
const idImage = drawText({ const idImage = drawText({
text: '#' + user.uid, text: '#' + user.uid,
textSize: 20, textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.idSize,
maxWidth: 150, maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.maxWidth,
}); });
ctx.drawImage(idImage, 790 - idImage.width, 45); ctx.drawImage(
idImage,
BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.edgeX - idImage.width,
BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.idY,
);
//pt //pt
const ptImage = drawText({ const ptImage = drawText({
text: user.currentPt.toString() + '分', text: user.currentPt.toString() + '分',
textSize: 23, textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.pointSize,
maxWidth: 150, maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.maxWidth,
}); });
ctx.drawImage(ptImage, 790 - ptImage.width, 70); ctx.drawImage(
ptImage,
BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.edgeX - ptImage.width,
BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.pointY,
);
return canvas; return canvas;
} }

View File

@ -0,0 +1,145 @@
export const BANGDREAM_TIME_FORMAT_SPEC = {
estimatedOpenSuffix: ' (预计开放时间)',
japanUtcOffsetHours: 9,
millisecond: 1000,
minute: 60 * 1000,
hour: 60 * 60 * 1000,
day: 24 * 60 * 60 * 1000,
month: 30 * 24 * 60 * 60 * 1000,
year: 12 * 30 * 24 * 60 * 60 * 1000,
century: 100 * 12 * 30 * 24 * 60 * 60 * 1000,
unknown: '?',
} as const;
/**
*
*
* @param minutes -
*/
export function formatBangDreamMinute(minutes: number) {
return minutes < 10 ? `0${minutes}` : minutes.toString();
}
/**
*
*
* @param timeStamp -
*/
export function formatBangDreamTime(timeStamp: number | null) {
if (timeStamp == null) {
return BANGDREAM_TIME_FORMAT_SPEC.unknown;
}
const date = new Date(
Math.floor(timeStamp / BANGDREAM_TIME_FORMAT_SPEC.millisecond) *
BANGDREAM_TIME_FORMAT_SPEC.millisecond,
);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${date.getHours()}:${formatBangDreamMinute(date.getMinutes())}`;
}
/**
*
*
* @param dateInput -
*/
export function toBangDreamJapanTime(dateInput: number | string | Date) {
const date = new Date(dateInput);
const offset = date.getTimezoneOffset() * BANGDREAM_TIME_FORMAT_SPEC.minute;
return new Date(
date.getTime() +
offset +
BANGDREAM_TIME_FORMAT_SPEC.japanUtcOffsetHours *
BANGDREAM_TIME_FORMAT_SPEC.hour,
);
}
/**
*
*
* @param timeStamp -
*/
export function formatBangDreamMonthDay(timeStamp: number | null) {
if (timeStamp == null) {
return BANGDREAM_TIME_FORMAT_SPEC.unknown;
}
const date = toBangDreamJapanTime(timeStamp);
return `${date.getMonth() + 1}${date.getDate()}`;
}
/**
*
*
* @param period -
*/
export function formatBangDreamPeriod(period: number | null) {
if (period == null) {
return BANGDREAM_TIME_FORMAT_SPEC.unknown;
}
const century = Math.floor(period / BANGDREAM_TIME_FORMAT_SPEC.century);
const years = Math.floor(period / BANGDREAM_TIME_FORMAT_SPEC.year);
const months = Math.floor(period / BANGDREAM_TIME_FORMAT_SPEC.month);
const days = Math.floor(
(period % BANGDREAM_TIME_FORMAT_SPEC.month) /
BANGDREAM_TIME_FORMAT_SPEC.day,
);
const hours = Math.floor(
(period % BANGDREAM_TIME_FORMAT_SPEC.day) / BANGDREAM_TIME_FORMAT_SPEC.hour,
);
const minutes = Math.floor(
(period % BANGDREAM_TIME_FORMAT_SPEC.hour) /
BANGDREAM_TIME_FORMAT_SPEC.minute,
);
const seconds = Math.floor(
(period % BANGDREAM_TIME_FORMAT_SPEC.minute) /
BANGDREAM_TIME_FORMAT_SPEC.millisecond,
);
let text = '';
if (century !== 0) {
text += `${century}世纪`;
}
if (years !== 0) {
text += `${years}`;
}
if (months !== 0) {
text += `${months}`;
}
if (days !== 0) {
text += `${days}`;
}
if (hours !== 0) {
text += `${hours}小时`;
}
if (minutes !== 0) {
text += `${minutes}分钟`;
}
return `${text}${seconds}`;
}
/**
*
*
* @param value -
*/
export function formatBangDreamSeconds(value: number) {
let seconds = value;
let minutes = 0;
let hours = 0;
if (seconds > 60) {
minutes = Math.trunc(seconds / 60);
seconds = Math.trunc(seconds % 60);
if (minutes > 60) {
hours = Math.trunc(minutes / 60);
minutes = Math.trunc(minutes % 60);
}
}
let result = `${Math.trunc(seconds)}`;
if (minutes > 0) {
result = `${Math.trunc(minutes)}${result}`;
}
if (hours > 0) {
result = `${Math.trunc(hours)}小时${result}`;
}
return result;
}

View File

@ -15,6 +15,13 @@ import {
getBangDreamServerUtcOffset, getBangDreamServerUtcOffset,
normalizeBangDreamTimestamp, normalizeBangDreamTimestamp,
} from '@/qqbot/plugins/bangDream/tsugu/models/server-policy'; } from '@/qqbot/plugins/bangDream/tsugu/models/server-policy';
import {
BANGDREAM_TIME_FORMAT_SPEC,
formatBangDreamMonthDay,
formatBangDreamPeriod,
formatBangDreamSeconds,
formatBangDreamTime,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time-spec';
interface TimeInListOptions { interface TimeInListOptions {
key?: string; key?: string;
@ -46,7 +53,8 @@ export async function drawTimeInList(
); );
if (currentEventId != null && eventId > currentEventId) { if (currentEventId != null && eventId > currentEventId) {
formattedTimeList.push( formattedTimeList.push(
formatTime(estimatedStartAt) + ' (预计开放时间)', formatTime(estimatedStartAt) +
BANGDREAM_TIME_FORMAT_SPEC.estimatedOpenSuffix,
); );
} }
} }
@ -84,31 +92,7 @@ export function getProbableTimeDifference(
* @param timeStamp - Stamp参数 * @param timeStamp - Stamp参数
*/ */
export function formatTime(timeStamp: number | null) { export function formatTime(timeStamp: number | null) {
//时间戳到年月日 精确到分钟 return formatBangDreamTime(timeStamp);
if (timeStamp == null) {
return '?';
}
const date = new Date(Math.floor(timeStamp / 1000) * 1000);
let nMinutes: string;
if (date.getMinutes() < 10) {
nMinutes = '0' + date.getMinutes().toString();
if (date.getMinutes() == 0) {
nMinutes = '00';
}
} else {
nMinutes = date.getMinutes().toString();
}
const temp =
date.getFullYear().toString() +
'年' +
(date.getMonth() + 1).toString() +
'月' +
date.getDate().toString() +
'日 ' +
date.getHours().toString() +
':' +
nMinutes;
return temp;
} }
/** /**
@ -117,39 +101,7 @@ export function formatTime(timeStamp: number | null) {
* @param timeStamp - Stamp参数 * @param timeStamp - Stamp参数
*/ */
export function formatMonthDay(timeStamp: number | null) { export function formatMonthDay(timeStamp: number | null) {
//获取生日的月与日 return formatBangDreamMonthDay(timeStamp);
/**
* Japan时间
*
* @param dateString - dateString参数
*/
function toJapanTime(dateString) {
// 创建一个新的Date实例表示当前时间。
const date = new Date(dateString);
// 获取本地时间与UTC的时间差分钟
const offset = date.getTimezoneOffset() * 60000;
// 将本地时间转换为UTC时间。
const utcTime = date.getTime() + offset;
// 日本时区的偏移量是UTC+9。
const japanTimeOffset = 9 * 60 * 60 * 1000;
// 将UTC时间转换为日本时间。
const japanTime = new Date(utcTime + japanTimeOffset);
// 返回日本时间的字符串表示。
return japanTime;
}
if (timeStamp == null) {
return '?';
}
const date = toJapanTime(timeStamp);
const temp =
(date.getMonth() + 1).toString() + '月' + date.getDate().toString() + '日 ';
return temp;
} }
/** /**
@ -159,44 +111,7 @@ export function formatMonthDay(timeStamp: number | null) {
* @returns * @returns
*/ */
export function formatTimePeriod(period: number): string { export function formatTimePeriod(period: number): string {
//时间戳的差值到年月日时分秒 return formatBangDreamPeriod(period);
if (period == null) {
return '?';
}
const century = Math.floor(period / (1000 * 60 * 60 * 24 * 30 * 12 * 100));
const years = Math.floor(period / (1000 * 60 * 60 * 24 * 30 * 12));
const months = Math.floor(period / (1000 * 60 * 60 * 24 * 30));
const days = Math.floor(
(period % (1000 * 60 * 60 * 24 * 30)) / (1000 * 60 * 60 * 24),
);
const hours = Math.floor((period % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((period % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((period % (1000 * 60)) / 1000);
let temp = '';
if (century != 0) {
temp += century.toString() + '世纪';
}
if (years != 0) {
temp += years.toString() + '年';
}
if (months != 0) {
temp += months.toString() + '月';
}
if (days != 0) {
temp += days.toString() + '日';
}
if (hours != 0) {
temp += hours.toString() + '小时';
}
if (minutes != 0) {
temp += minutes.toString() + '分钟';
}
temp += seconds.toString() + '秒';
return temp;
} }
//时间长度转时分秒函数 //时间长度转时分秒函数
@ -206,25 +121,7 @@ export function formatTimePeriod(period: number): string {
* @param value - * @param value -
*/ */
export function formatSeconds(value: number) { export function formatSeconds(value: number) {
let theTime = value; // 秒 return formatBangDreamSeconds(value);
let theTime1 = 0; // 分
let theTime2 = 0; // 小时
if (theTime > 60) {
theTime1 = parseInt((theTime / 60).toString());
theTime = parseInt((theTime % 60).toString());
if (theTime1 > 60) {
theTime2 = parseInt((theTime1 / 60).toString());
theTime1 = parseInt((theTime1 % 60).toString());
}
}
let result = '' + parseInt(theTime.toString()) + '秒';
if (theTime1 > 0) {
result = '' + parseInt(theTime1.toString()) + '分' + result;
}
if (theTime2 > 0) {
result = '' + parseInt(theTime2.toString()) + '小时' + result;
}
return result;
} }
/** /**

View File

@ -0,0 +1,107 @@
export const BANGDREAM_SKILL_TEXT_SPEC = {
iconKeys: ['judge', 'life', 'damage'],
layout: {
baseOffsetY: 0,
canvasHeight: 45,
maxWidth: 250,
spacing: 3,
textBasePaddingRight: 15,
textOffsetX: 5,
textOffsetY: 0,
textSize: 27,
lineHeight: 30,
},
} as const;
export type BangDreamSkillIconKey =
(typeof BANGDREAM_SKILL_TEXT_SPEC.iconKeys)[number];
export type BangDreamSkillTextFragment =
| {
type: 'text';
value: string;
}
| {
type: 'icon';
key: BangDreamSkillIconKey;
};
const BANGDREAM_SKILL_SCORE_SUFFIX_RULES = [
{
suffix: 'G',
matches: (effectTypes: readonly string[]) =>
effectTypes.includes('score_continued_note_judge'),
},
{
suffix: 'L',
matches: (effectTypes: readonly string[]) =>
effectTypes.includes('score_over_life') &&
effectTypes.includes('score_under_life'),
},
{
suffix: '/',
matches: (effectTypes: readonly string[]) =>
effectTypes.includes('score_over_life'),
},
{
suffix: 'P',
matches: (effectTypes: readonly string[]) =>
effectTypes.includes('score_under_great_half') ||
effectTypes.includes('score_perfect'),
},
{
suffix: '+0.5*P',
matches: (effectTypes: readonly string[]) =>
effectTypes.includes('score_rate_up_with_perfect'),
},
] as const;
/**
*
*
* @param effectTypes -
*/
export function getSkillScoreSuffix(effectTypes: readonly string[]) {
return (
BANGDREAM_SKILL_SCORE_SUFFIX_RULES.find(({ matches }) =>
matches(effectTypes),
)?.suffix ?? ''
);
}
/**
*
*
* @param options -
*/
export function createSkillTextFragments({
effectTypes,
scoreUpMaxValue,
}: {
effectTypes: readonly string[];
scoreUpMaxValue: number;
}): BangDreamSkillTextFragment[] {
const fragments: BangDreamSkillTextFragment[] = [];
if (scoreUpMaxValue !== 0) {
fragments.push({
type: 'text',
value: `${scoreUpMaxValue}${getSkillScoreSuffix(effectTypes)}`,
});
}
effectTypes.forEach((effectType) => {
if (
BANGDREAM_SKILL_TEXT_SPEC.iconKeys.includes(
effectType as BangDreamSkillIconKey,
)
) {
fragments.push({
type: 'icon',
key: effectType as BangDreamSkillIconKey,
});
}
});
return fragments;
}

View File

@ -4,8 +4,13 @@ import { drawTextWithImages } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'
import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils';
import { getBangDreamAssetPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest'; import { getBangDreamAssetPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest';
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme'; import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
import {
BANGDREAM_SKILL_TEXT_SPEC,
BangDreamSkillIconKey,
createSkillTextFragments,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text-spec';
const skillIcon: { [skillType: string]: Image } = {}; const skillIcon: Partial<Record<BangDreamSkillIconKey, Image>> = {};
/** /**
* Once * Once
*/ */
@ -28,55 +33,40 @@ loadImageOnce();
* @returns * @returns
*/ */
export async function drawCardIconSkill(skill: Skill): Promise<Canvas> { export async function drawCardIconSkill(skill: Skill): Promise<Canvas> {
const content: Array<Image | string> = []; const content = createSkillTextFragments({
const EffectTypes = skill.getEffectTypes(); effectTypes: skill.getEffectTypes(),
const ScoreUpMaxValue = skill.getScoreUpMaxValue(); scoreUpMaxValue: skill.getScoreUpMaxValue(),
//画数字部分 }).flatMap((fragment): Array<Image | string> => {
if (ScoreUpMaxValue != 0) { if (fragment.type === 'text') {
let skillValue = ScoreUpMaxValue.toString(); return [fragment.value];
if (EffectTypes.includes('score_continued_note_judge')) {
skillValue += 'G';
} else if (EffectTypes.includes('score_over_life')) {
if (EffectTypes.includes('score_under_life')) {
skillValue += 'L';
} else {
skillValue += '/';
}
} else if (
EffectTypes.includes('score_under_great_half') ||
EffectTypes.includes('score_perfect')
) {
skillValue += 'P';
} else if (EffectTypes.includes('score_rate_up_with_perfect')) {
skillValue += '+0.5*P';
}
content.push(skillValue);
}
//图标部分
EffectTypes.forEach((EffectType) => {
if (EffectType == 'judge') {
content.push(skillIcon.judge);
} else if (EffectType == 'life') {
content.push(skillIcon.life);
} else if (EffectType == 'damage') {
content.push(skillIcon.damage);
} }
const icon = skillIcon[fragment.key];
return icon == null ? [] : [icon];
}); });
const spec = BANGDREAM_SKILL_TEXT_SPEC.layout;
const stringWithImage = drawTextWithImages({ const stringWithImage = drawTextWithImages({
content: content, content: content,
maxWidth: 250, maxWidth: spec.maxWidth,
textSize: 27, textSize: spec.textSize,
lineHeight: 30, lineHeight: spec.lineHeight,
spacing: 3, spacing: spec.spacing,
color: BANGDREAM_RENDER_THEME.color.surface, color: BANGDREAM_RENDER_THEME.color.surface,
font: BANGDREAM_RENDER_THEME.font.body, font: BANGDREAM_RENDER_THEME.font.body,
}); });
const textbase = await loadImageFromPath( const textbase = await loadImageFromPath(
getBangDreamAssetPath('cardSkillTextBase'), getBangDreamAssetPath('cardSkillTextBase'),
); );
const canvas = new Canvas(stringWithImage.width + 15, 45); const canvas = new Canvas(
stringWithImage.width + spec.textBasePaddingRight,
spec.canvasHeight,
);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.drawImage(textbase, stringWithImage.width + 15 - textbase.width, 0); ctx.drawImage(
ctx.drawImage(stringWithImage, 5, 0); textbase,
stringWithImage.width + spec.textBasePaddingRight - textbase.width,
spec.baseOffsetY,
);
ctx.drawImage(stringWithImage, spec.textOffsetX, spec.textOffsetY);
return canvas; return canvas;
} }

View File

@ -44,13 +44,39 @@ export interface PreviewLayout {
export const BANGDREAM_SONG_CHART_PREVIEW_SPEC = { export const BANGDREAM_SONG_CHART_PREVIEW_SPEC = {
aspectRatioLimit: 16 / 9, aspectRatioLimit: 16 / 9,
blockDistance: 72, blockDistance: 72,
coverInset: 16,
heightPerSecond: 216, heightPerSecond: 216,
infoAreaWidth: 240, infoAreaWidth: 240,
infoOffset: 8,
laneCount: 7, laneCount: 7,
laneWidth: 32, laneWidth: 32,
minHeight: 500, minHeight: 500,
noteEndPaddingSeconds: 0.25, noteEndPaddingSeconds: 0.25,
panel: {
fontSize: 16,
height: 24,
maxWidth: 128,
width: 128,
},
difficultyPanel: {
textXFromCoverRight: -52,
textYOffsetFromCoverBottom: 0,
xFromCoverRight: -116,
yFromCoverBottom: -12,
},
idPanel: {
textXOffset: 56,
textYOffset: 4,
xOffset: -8,
yOffset: -8,
},
simLineHeight: 2,
splitLineWidth: 2, splitLineWidth: 2,
trackGradientStops: [
{ color: '#2F4E6F', offset: 0 },
{ color: '#3E6F8A', offset: 0.5 },
{ color: '#4D80A4', offset: 1 },
],
} as const; } as const;
export const BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES = [ export const BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES = [

View File

@ -25,7 +25,6 @@ interface BestdoriPreviewPayload {
cover: string | Buffer; cover: string | Buffer;
} }
const OFFSET = 8;
const NOTE_IMAGE_KEYS = [ const NOTE_IMAGE_KEYS = [
'Single', 'Single',
'SingleOff', 'SingleOff',
@ -149,33 +148,67 @@ function drawBaseInfo(
ctx.drawImage( ctx.drawImage(
coverImg, coverImg,
OFFSET, BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset,
OFFSET, BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset,
layout.infoAreaWidth - 16, layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset,
layout.infoAreaWidth - 16, layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset,
); );
ctx.save(); ctx.save();
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartPanel; ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartPanel;
ctx.fillRect(OFFSET - 8, OFFSET - 8, 128, 24); ctx.fillRect(
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.xOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.yOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.width,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.height,
);
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText; ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
ctx.font = `16px "${BANGDREAM_RENDER_THEME.font.chart}"`; ctx.font = `${BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.fontSize}px "${BANGDREAM_RENDER_THEME.font.chart}"`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(`${id}`, OFFSET + 56, OFFSET + 4, 128); ctx.fillText(
`${id}`,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.textXOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.textYOffset,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.maxWidth,
);
ctx.restore(); ctx.restore();
const coverWidth = layout.infoAreaWidth - 16; const coverWidth =
layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset;
ctx.save(); ctx.save();
ctx.fillStyle = ctx.fillStyle =
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS[diff] ?? BANGDREAM_SONG_CHART_DIFFICULTY_COLORS[diff] ??
BANGDREAM_RENDER_THEME.color.chartDifficultyFallback; BANGDREAM_RENDER_THEME.color.chartDifficultyFallback;
ctx.fillRect(8 + coverWidth - 116, 8 + coverWidth - 12, 128, 24); ctx.fillRect(
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
coverWidth +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.xFromCoverRight,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
coverWidth +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.yFromCoverBottom,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.width,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.height,
);
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText; ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
ctx.font = `16px "${BANGDREAM_RENDER_THEME.font.chart}"`; ctx.font = `${BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.fontSize}px "${BANGDREAM_RENDER_THEME.font.chart}"`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(`${diff} ${level}`, 8 + coverWidth - 52, 8 + coverWidth, 128); ctx.fillText(
`${diff} ${level}`,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
coverWidth +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.textXFromCoverRight,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset +
coverWidth +
BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel
.textYOffsetFromCoverBottom,
BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.maxWidth,
);
ctx.restore(); ctx.restore();
} }
@ -190,17 +223,18 @@ function drawTracks(ctx: any, layout: PreviewLayout): void {
ctx.save(); ctx.save();
const x = const x =
layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance; layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance;
const w = const w = layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount;
layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount;
const grd = ctx.createLinearGradient( const grd = ctx.createLinearGradient(
x, x,
0, 0,
x + layout.splitLineWidth * 2, x + layout.splitLineWidth * 2,
0, 0,
); );
grd.addColorStop(0, '#2F4E6F'); BANGDREAM_SONG_CHART_PREVIEW_SPEC.trackGradientStops.forEach(
grd.addColorStop(0.5, '#3E6F8A'); ({ color, offset }) => {
grd.addColorStop(1, '#4D80A4'); grd.addColorStop(offset, color);
},
);
ctx.fillStyle = grd; ctx.fillStyle = grd;
ctx.fillRect( ctx.fillRect(
x - layout.splitLineWidth * 2, x - layout.splitLineWidth * 2,
@ -253,8 +287,7 @@ function drawBeatLines(
} }
const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm); const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm);
const { x, y } = getTimePosition(layout, currentTime); const { x, y } = getTimePosition(layout, currentTime);
const w = const w = BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, y); ctx.moveTo(x, y);
@ -445,7 +478,7 @@ function drawSimNote(
const lane = note.lane as number[]; const lane = note.lane as number[];
lane.sort((a, b) => a - b); lane.sort((a, b) => a - b);
const simW = layout.laneWidth * (lane[1] - lane[0] - 1); const simW = layout.laneWidth * (lane[1] - lane[0] - 1);
const simH = 2; const simH = BANGDREAM_SONG_CHART_PREVIEW_SPEC.simLineHeight;
const simStartX = const simStartX =
layout.infoAreaWidth + layout.infoAreaWidth +
drawCol * layout.originalWidth + drawCol * layout.originalWidth +

View File

@ -0,0 +1,44 @@
export const BANGDREAM_TIMELINE_CHART_SPEC = {
animation: false,
canvas: {
height: 900,
width: 800,
},
legend: {
fontSize: 20,
},
responsive: false,
xAxis: {
unit: 'day',
},
yAxis: {
min: 0,
padding: 1000,
scale: 1.1,
},
} as const;
/**
* 线 Y
*
* @param datasets - Chart.js
*/
export function getTimelineRawYMax(
datasets: Array<{ data: Array<{ y: number }> }>,
) {
return Math.max(
...datasets.map((dataset) => Math.max(...dataset.data.map((pt) => pt.y))),
);
}
/**
* 线 Y
*
* @param rawYMax -
*/
export function getTimelineDisplayYMax(rawYMax: number) {
return (
(rawYMax + BANGDREAM_TIMELINE_CHART_SPEC.yAxis.padding) *
BANGDREAM_TIMELINE_CHART_SPEC.yAxis.scale
);
}

View File

@ -3,6 +3,11 @@ import { Canvas, FontLibrary, loadImage } from 'skia-canvas';
import 'chartjs-adapter-moment'; import 'chartjs-adapter-moment';
import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils';
import {
BANGDREAM_TIMELINE_CHART_SPEC,
getTimelineDisplayYMax,
getTimelineRawYMax,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec';
// 2. 注册 Chart.js 所有组件 // 2. 注册 Chart.js 所有组件
Chart.register(...registerables); Chart.register(...registerables);
@ -34,19 +39,15 @@ export async function drawTimeLineChart(
{ start, end, setStartToZero = false, data }: drawTimeLineChartOptions, { start, end, setStartToZero = false, data }: drawTimeLineChartOptions,
displayLabel = false, displayLabel = false,
) { ) {
const width = 800; const width = BANGDREAM_TIMELINE_CHART_SPEC.canvas.width;
const height = 900; const height = BANGDREAM_TIMELINE_CHART_SPEC.canvas.height;
// 7. 创建 skia-canvas 实例 // 7. 创建 skia-canvas 实例
const canvas = new Canvas(width, height); const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
// 8. 计算 y 轴最大值 // 8. 计算 y 轴最大值
const yMax = Math.max( const yMax = getTimelineRawYMax(data.datasets);
...data.datasets.map((dataset: any) =>
Math.max(...dataset.data.map((pt: any) => pt.y)),
),
);
// 9. 配置 Chart.js 选项 // 9. 配置 Chart.js 选项
const options = { const options = {
@ -54,7 +55,7 @@ export async function drawTimeLineChart(
legend: { legend: {
labels: { labels: {
font: { font: {
size: 20, size: BANGDREAM_TIMELINE_CHART_SPEC.legend.fontSize,
}, },
}, },
display: displayLabel, display: displayLabel,
@ -64,15 +65,15 @@ export async function drawTimeLineChart(
x: { x: {
type: 'time', type: 'time',
time: { time: {
unit: 'day', unit: BANGDREAM_TIMELINE_CHART_SPEC.xAxis.unit,
}, },
min: start, min: start,
max: end, max: end,
display: !setStartToZero, display: !setStartToZero,
}, },
y: { y: {
min: 0, min: BANGDREAM_TIMELINE_CHART_SPEC.yAxis.min,
max: (yMax + 1000) * 1.1, max: getTimelineDisplayYMax(yMax),
}, },
}, },
}; };
@ -83,8 +84,8 @@ export async function drawTimeLineChart(
data, data,
options: { options: {
...options, ...options,
responsive: false, // 重要:关闭 Chart.js 自适应模式 responsive: BANGDREAM_TIMELINE_CHART_SPEC.responsive, // 重要:关闭 Chart.js 自适应模式
animation: false, animation: BANGDREAM_TIMELINE_CHART_SPEC.animation,
}, },
}; };

View File

@ -0,0 +1,36 @@
import {
BANGDREAM_CUTOFF_CHART_SPEC,
stripCutoffChartLabelTags,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart-spec';
import {
BANGDREAM_TIMELINE_CHART_SPEC,
getTimelineDisplayYMax,
getTimelineRawYMax,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec';
describe('BangDream chart specs', () => {
it('keeps timeline chart dimensions and y-axis padding stable', () => {
expect(BANGDREAM_TIMELINE_CHART_SPEC.canvas).toEqual({
height: 900,
width: 800,
});
expect(
getTimelineRawYMax([
{ data: [{ y: 100 }, { y: 300 }] },
{ data: [{ y: 200 }] },
]),
).toBe(300);
expect(getTimelineDisplayYMax(300)).toBeCloseTo(1430);
});
it('keeps cutoff chart visual constants and label cleanup stable', () => {
expect(BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas).toEqual({
height: 1,
width: 1,
});
expect(BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionDash).toEqual([
20, 10,
]);
expect(stripCutoffChartLabelTags('[JP]Player[1]')).toBe('Player');
});
});

View File

@ -0,0 +1,58 @@
import {
BANGDREAM_DATA_BLOCK_SPEC,
calculateHorizontalDataBlockSize,
calculateVerticalDataBlockSize,
getDataBlockTitleLineHeight,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/data-block-spec';
describe('BangDream data block spec', () => {
it('keeps the historical title and banner specs stable', () => {
expect(BANGDREAM_DATA_BLOCK_SPEC.title).toMatchObject({
backgroundColor: '#ea4e73',
blockSize: 380,
height: 70,
strokeColor: '#ffffff',
textColor: '#ffffff',
});
expect(BANGDREAM_DATA_BLOCK_SPEC.banner.widthMax).toBe(800);
expect(getDataBlockTitleLineHeight()).toBe(65);
});
it('calculates vertical canvas size like the original renderer', () => {
expect(
calculateVerticalDataBlockSize({
contentHeight: 300,
maxContentWidth: 500,
withBackground: true,
withTitle: true,
}),
).toEqual({ height: 470, width: 700 });
expect(
calculateVerticalDataBlockSize({
contentHeight: 300,
maxContentWidth: 500,
withBackground: false,
withTitle: false,
}),
).toEqual({ height: 300, width: 700 });
});
it('calculates horizontal canvas size like the original renderer', () => {
expect(
calculateHorizontalDataBlockSize({
contentWidth: 600,
maxContentHeight: 220,
withBackground: true,
withTitle: true,
}),
).toEqual({ height: 320, width: 870 });
expect(
calculateHorizontalDataBlockSize({
contentWidth: 600,
maxContentHeight: 220,
withBackground: false,
withTitle: false,
}),
).toEqual({ height: 320, width: 600 });
});
});

View File

@ -0,0 +1,48 @@
import {
BANGDREAM_DEGREE_LIST_SPEC,
isDegreeRewardType,
shouldCollectMusicRankingDegreeRewards,
shouldDrawDegreeDecorations,
shouldDrawDegreeIcon,
shouldStopAfterFirstMusicRewardGroup,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/degree-list-spec';
describe('BangDream degree list spec', () => {
it('keeps badge and list sizes stable', () => {
expect(BANGDREAM_DEGREE_LIST_SPEC.badge).toMatchObject({
decoratedMinDegreeIdExclusive: 12,
height: 50,
width: 230,
});
expect(BANGDREAM_DEGREE_LIST_SPEC.list.textSize).toBe(50);
expect(BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount).toBe(6);
});
it('keeps degree decoration conditions stable', () => {
expect(
shouldDrawDegreeDecorations({ degreeId: 13, degreeType: 'event' }),
).toBe(true);
expect(
shouldDrawDegreeDecorations({ degreeId: 12, degreeType: 'event' }),
).toBe(false);
expect(
shouldDrawDegreeDecorations({ degreeId: 13, degreeType: 'normal' }),
).toBe(false);
expect(
shouldDrawDegreeDecorations({ degreeId: 13, degreeType: null }),
).toBe(false);
expect(shouldDrawDegreeIcon('try_clear')).toBe(false);
expect(shouldDrawDegreeIcon('event')).toBe(true);
});
it('keeps event reward filters stable', () => {
expect(shouldCollectMusicRankingDegreeRewards('versus')).toBe(true);
expect(shouldCollectMusicRankingDegreeRewards('challenge')).toBe(true);
expect(shouldCollectMusicRankingDegreeRewards('medley')).toBe(true);
expect(shouldCollectMusicRankingDegreeRewards('normal')).toBe(false);
expect(shouldStopAfterFirstMusicRewardGroup('medley')).toBe(true);
expect(shouldStopAfterFirstMusicRewardGroup('versus')).toBe(false);
expect(isDegreeRewardType('degree')).toBe(true);
expect(isDegreeRewardType('item')).toBe(false);
});
});

View File

@ -0,0 +1,27 @@
import {
BANGDREAM_GACHA_LIST_SPEC,
getGachaPaymentBehaviorLabel,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec';
describe('BangDream gacha list spec', () => {
it('keeps gacha list labels stable', () => {
expect(BANGDREAM_GACHA_LIST_SPEC.label).toEqual({
empty: '无',
paymentMethod: '付费方式',
pickup: '卡池PickUp',
rateDistribution: '概率分布',
rateMissing: '未提供概率分布数据',
});
});
it('maps known payment behaviors and keeps unknown behavior unchanged', () => {
expect(getGachaPaymentBehaviorLabel('normal')).toBe('');
expect(getGachaPaymentBehaviorLabel('over_the_3_star_once')).toBe(
'必中★3+',
);
expect(getGachaPaymentBehaviorLabel('fixed_5_star_once')).toBe('必中★5');
expect(getGachaPaymentBehaviorLabel('custom_behavior')).toBe(
'custom_behavior',
);
});
});

View File

@ -0,0 +1,16 @@
import {
BANGDREAM_ENTITY_LIST_SPEC,
shouldUseSingleEntityLabel,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec';
describe('BangDream entity list spec', () => {
it('keeps the historical multi-value spacing stable', () => {
expect(BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing).toBe(0);
});
it('uses single entity label only when there is one entity and no override text', () => {
expect(shouldUseSingleEntityLabel(1)).toBe(true);
expect(shouldUseSingleEntityLabel(1, 'override')).toBe(false);
expect(shouldUseSingleEntityLabel(2)).toBe(false);
});
});

View File

@ -0,0 +1,40 @@
import {
BANGDREAM_PLAYER_RANKING_SPEC,
createRankingDegreeLayout,
isMedalRanking,
stripPlayerRankingTextTags,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking-spec';
describe('BangDream player ranking spec', () => {
it('keeps the historical ranking row dimensions stable', () => {
expect(BANGDREAM_PLAYER_RANKING_SPEC.canvas).toEqual({
height: 110,
width: 800,
});
expect(BANGDREAM_PLAYER_RANKING_SPEC.headShot).toEqual({
height: 90,
width: 90,
x: 85,
y: 10,
});
});
it('strips bracket tags from user-facing ranking text', () => {
expect(stripPlayerRankingTextTags('[JP]Alice[VIP]')).toBe('Alice');
});
it('detects medal ranking and calculates degree positions', () => {
expect(isMedalRanking(1)).toBe(true);
expect(isMedalRanking(3)).toBe(true);
expect(isMedalRanking(4)).toBe(false);
expect(isMedalRanking(undefined)).toBe(false);
expect(
createRankingDegreeLayout({ height: 50, index: 2, width: 230 }),
).toEqual({
height: 25,
width: 115,
x: 460,
y: 46,
});
});
});

View File

@ -0,0 +1,44 @@
import {
BANGDREAM_TIME_FORMAT_SPEC,
formatBangDreamMinute,
formatBangDreamMonthDay,
formatBangDreamPeriod,
formatBangDreamSeconds,
formatBangDreamTime,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time-spec';
describe('BangDream time format spec', () => {
it('keeps common labels and offsets stable', () => {
expect(BANGDREAM_TIME_FORMAT_SPEC.estimatedOpenSuffix).toBe(
' (预计开放时间)',
);
expect(BANGDREAM_TIME_FORMAT_SPEC.japanUtcOffsetHours).toBe(9);
expect(BANGDREAM_TIME_FORMAT_SPEC.unknown).toBe('?');
});
it('formats minutes and full timestamps like the original renderer', () => {
expect(formatBangDreamMinute(0)).toBe('00');
expect(formatBangDreamMinute(9)).toBe('09');
expect(formatBangDreamMinute(10)).toBe('10');
expect(formatBangDreamTime(null)).toBe('?');
expect(
formatBangDreamTime(new Date(2020, 4, 19, 13, 0, 30).getTime()),
).toBe('2020年5月19日 13:00');
});
it('formats Japan month day and durations', () => {
expect(formatBangDreamMonthDay(null)).toBe('?');
expect(formatBangDreamMonthDay(Date.UTC(2020, 4, 18, 15, 0, 0))).toBe(
'5月19日 ',
);
expect(
formatBangDreamPeriod(
BANGDREAM_TIME_FORMAT_SPEC.day +
BANGDREAM_TIME_FORMAT_SPEC.hour +
BANGDREAM_TIME_FORMAT_SPEC.minute +
2 * BANGDREAM_TIME_FORMAT_SPEC.millisecond,
),
).toBe('1日1小时1分钟2秒');
expect(formatBangDreamSeconds(3661)).toBe('1小时1分1秒');
});
});

View File

@ -0,0 +1,74 @@
import {
BANGDREAM_SKILL_TEXT_SPEC,
createSkillTextFragments,
getSkillScoreSuffix,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text-spec';
describe('BangDream skill text spec', () => {
it('keeps the historical card skill text layout stable', () => {
expect(BANGDREAM_SKILL_TEXT_SPEC.layout).toEqual({
baseOffsetY: 0,
canvasHeight: 45,
maxWidth: 250,
spacing: 3,
textBasePaddingRight: 15,
textOffsetX: 5,
textOffsetY: 0,
textSize: 27,
lineHeight: 30,
});
});
it('keeps the score suffix priority stable', () => {
expect(getSkillScoreSuffix(['score_continued_note_judge'])).toBe('G');
expect(getSkillScoreSuffix(['score_over_life', 'score_under_life'])).toBe(
'L',
);
expect(getSkillScoreSuffix(['score_over_life'])).toBe('/');
expect(getSkillScoreSuffix(['score_under_great_half'])).toBe('P');
expect(getSkillScoreSuffix(['score_perfect'])).toBe('P');
expect(getSkillScoreSuffix(['score_rate_up_with_perfect'])).toBe('+0.5*P');
expect(
getSkillScoreSuffix([
'score_continued_note_judge',
'score_rate_up_with_perfect',
]),
).toBe('G');
});
it('creates text and icon fragments in the original effect order', () => {
expect(
createSkillTextFragments({
effectTypes: ['damage', 'score_perfect', 'judge'],
scoreUpMaxValue: 110,
}),
).toEqual([
{
type: 'text',
value: '110P',
},
{
type: 'icon',
key: 'damage',
},
{
type: 'icon',
key: 'judge',
},
]);
});
it('omits score text when the skill has no score up value', () => {
expect(
createSkillTextFragments({
effectTypes: ['life'],
scoreUpMaxValue: 0,
}),
).toEqual([
{
type: 'icon',
key: 'life',
},
]);
});
});

View File

@ -1,6 +1,7 @@
import { import {
assignSongChartTimes, assignSongChartTimes,
BestdoriNote, BestdoriNote,
BANGDREAM_SONG_CHART_PREVIEW_SPEC,
createSongChartPreviewLayout, createSongChartPreviewLayout,
createSongChartPreviewModel, createSongChartPreviewModel,
createSongChartPreviewNotes, createSongChartPreviewNotes,
@ -102,4 +103,21 @@ describe('BangDream song chart preview spec', () => {
expect(model.notes.some((note) => note.type === 'Single')).toBe(true); expect(model.notes.some((note) => note.type === 'Single')).toBe(true);
expect(model.layout.width).toBeGreaterThan(0); expect(model.layout.width).toBeGreaterThan(0);
}); });
it('keeps the drawing-only panel and gradient specs stable', () => {
expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset).toBe(8);
expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset).toBe(16);
expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel).toEqual({
fontSize: 16,
height: 24,
maxWidth: 128,
width: 128,
});
expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.simLineHeight).toBe(2);
expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.trackGradientStops).toEqual([
{ color: '#2F4E6F', offset: 0 },
{ color: '#3E6F8A', offset: 0.5 },
{ color: '#4D80A4', offset: 1 },
]);
});
}); });