refactor: 批量收口 BangDream 渲染规格
This commit is contained in:
parent
bef1750022
commit
36c76ce959
@ -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/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/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 没有真实图片落盘时必须失败。
|
||||
|
||||
### Phase 6:策略 policy 和时间/档线规则
|
||||
|
||||
@ -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, '');
|
||||
}
|
||||
@ -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 { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
|
||||
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 time = new Date().getTime();
|
||||
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 = [];
|
||||
@ -43,27 +50,38 @@ export async function drawCutoffChart(
|
||||
list.push(
|
||||
drawList({
|
||||
content: [
|
||||
tempColor.generateColorBlock(0.8),
|
||||
tempColor.generateColorBlock(
|
||||
BANGDREAM_CUTOFF_CHART_SPEC.legend.colorBlockOpacity,
|
||||
),
|
||||
`[${tempEvent.eventId}] ${tempEvent.eventName[server]} T${cutoff.tier}`,
|
||||
],
|
||||
textSize: 20,
|
||||
textSize: BANGDREAM_CUTOFF_CHART_SPEC.legend.textSize,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
lableName = `T${cutoff.tier}`;
|
||||
list.push(
|
||||
drawList({
|
||||
content: [tempColor.generateColorBlock(0.8), `T${cutoff.tier}`],
|
||||
textSize: 20,
|
||||
content: [
|
||||
tempColor.generateColorBlock(
|
||||
BANGDREAM_CUTOFF_CHART_SPEC.legend.colorBlockOpacity,
|
||||
),
|
||||
`T${cutoff.tier}`,
|
||||
],
|
||||
textSize: BANGDREAM_CUTOFF_CHART_SPEC.legend.textSize,
|
||||
}),
|
||||
);
|
||||
}
|
||||
datasets.push({
|
||||
label: lableName,
|
||||
data: cutoff.getChartData(setStartToZero),
|
||||
borderWidth: 5,
|
||||
borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth,
|
||||
borderColor: [tempColor.getRGBA(1)],
|
||||
backgroundColor: [tempColor.getRGBA(0.2)],
|
||||
backgroundColor: [
|
||||
tempColor.getRGBA(
|
||||
BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.singleLineFillBackgroundAlpha,
|
||||
),
|
||||
],
|
||||
pointBackgroundColor: tempColor.getRGBA(1),
|
||||
pointBorderColor: tempColor.getRGBA(1),
|
||||
fill: onlyOne,
|
||||
@ -109,15 +127,25 @@ export async function drawCutoffChart(
|
||||
}
|
||||
const tempColor = getPresetColor(i);
|
||||
datasets.push({
|
||||
label: `T${cutoff.tier} 预测线`,
|
||||
borderColor: [tempColor.getRGBA(1)],
|
||||
backgroundColor: [tempColor.getRGBA(1)],
|
||||
label: `T${cutoff.tier} ${BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionSuffix}`,
|
||||
borderColor: [
|
||||
tempColor.getRGBA(
|
||||
BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionBorderAlpha,
|
||||
),
|
||||
],
|
||||
backgroundColor: [
|
||||
tempColor.getRGBA(
|
||||
BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionBackgroundAlpha,
|
||||
),
|
||||
],
|
||||
data: data,
|
||||
borderWidth: 5,
|
||||
borderDash: [20, 10],
|
||||
borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth,
|
||||
borderDash: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionDash,
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
pointRadius:
|
||||
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) {
|
||||
const tempColor = getPresetColor(0);
|
||||
datasets.push({
|
||||
label: '当前时间',
|
||||
label: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.label,
|
||||
borderColor: [tempColor.getRGBA(1)],
|
||||
backgroundColor: [tempColor.getRGBA(1)],
|
||||
data: [{ x: new Date(time), y: 0 }],
|
||||
fill: false,
|
||||
pointRadius: 10,
|
||||
pointHoverRadius: 15,
|
||||
pointRadius: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.pointRadius,
|
||||
pointHoverRadius:
|
||||
BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.pointHoverRadius,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
@ -184,30 +213,33 @@ export async function drawCutoffEventTopChart(
|
||||
) {
|
||||
const datasets = [];
|
||||
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();
|
||||
/**
|
||||
* 在图片布局层中移除Braces。
|
||||
*
|
||||
* @param text - 待绘制文本。
|
||||
* @returns 格式化后的文本。
|
||||
*/
|
||||
function removeBraces(text: string): string {
|
||||
const newText = text.replace(/\[[^\]]*\]/g, '');
|
||||
return newText;
|
||||
}
|
||||
let colorNumber = 0;
|
||||
for (const key in allData) {
|
||||
const tempColor = getPresetColor(colorNumber);
|
||||
datasets.push({
|
||||
label: removeBraces(CutoffEventTop.getUserNameById(Number(key))),
|
||||
label: stripCutoffChartLabelTags(
|
||||
CutoffEventTop.getUserNameById(Number(key)),
|
||||
),
|
||||
data: allData[key],
|
||||
borderWidth: 4,
|
||||
borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.borderWidth,
|
||||
borderColor: [tempColor.getRGBA(1)],
|
||||
backgroundColor: [tempColor.getRGBA(0.2)],
|
||||
pointBackgroundColor: tempColor.getRGBA(0),
|
||||
pointBorderColor: tempColor.getRGBA(0),
|
||||
backgroundColor: [
|
||||
tempColor.getRGBA(
|
||||
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,
|
||||
fill: false,
|
||||
});
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,12 @@ import { Canvas, Image } from 'skia-canvas';
|
||||
import { drawRoundedRect } from '@/qqbot/plugins/bangDream/tsugu/canvas/rect';
|
||||
import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text';
|
||||
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 {
|
||||
list: Array<Canvas | Image>;
|
||||
@ -20,27 +26,29 @@ export function drawDataBlock({
|
||||
list,
|
||||
BG = true,
|
||||
topLeftText,
|
||||
opacity = 0.9,
|
||||
opacity = BANGDREAM_DATA_BLOCK_SPEC.background.defaultOpacity,
|
||||
}: DataBlockOptions): Canvas {
|
||||
const topLeftTextHeight = 70;
|
||||
//计算高度
|
||||
let allH = 0;
|
||||
let contentHeight = 0;
|
||||
let maxW = 0;
|
||||
if (BG) {
|
||||
allH += 100;
|
||||
}
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
allH = allH + list[i].height;
|
||||
contentHeight += list[i].height;
|
||||
if (list[i].width > maxW) {
|
||||
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
|
||||
const tempCanvas =
|
||||
topLeftText != undefined && BG
|
||||
? new Canvas(maxW + 200, allH + topLeftTextHeight)
|
||||
: new Canvas(maxW + 200, allH);
|
||||
const tempCanvas = new Canvas(canvasSize.width, canvasSize.height);
|
||||
const ctx = tempCanvas.getContext('2d');
|
||||
|
||||
//画背景
|
||||
@ -51,58 +59,76 @@ export function drawDataBlock({
|
||||
drawRoundedRect({
|
||||
//画字底,左下角右下角没有圆角
|
||||
opacity: 1,
|
||||
color: '#ea4e73',
|
||||
width: 380,
|
||||
height: topLeftTextHeight + 5,
|
||||
radius: [25, 25, 0, 0],
|
||||
strokeColor: '#ffffff',
|
||||
strokeWidth: 5,
|
||||
color: BANGDREAM_DATA_BLOCK_SPEC.title.backgroundColor,
|
||||
width: BANGDREAM_DATA_BLOCK_SPEC.title.blockSize,
|
||||
height:
|
||||
BANGDREAM_DATA_BLOCK_SPEC.title.height +
|
||||
BANGDREAM_DATA_BLOCK_SPEC.title.rectExtraSize,
|
||||
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,
|
||||
0,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.vertical.titleRectX,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.vertical.titleRectY,
|
||||
);
|
||||
|
||||
const textImage = drawText({
|
||||
//画字
|
||||
color: '#ffffff',
|
||||
color: BANGDREAM_DATA_BLOCK_SPEC.title.textColor,
|
||||
text: topLeftText,
|
||||
maxWidth: 370,
|
||||
lineHeight: topLeftTextHeight - 5,
|
||||
maxWidth:
|
||||
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(
|
||||
drawRoundedRect({
|
||||
//画总底,左上角没有圆角
|
||||
opacity,
|
||||
width: maxW + 100,
|
||||
width: maxW + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth,
|
||||
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,
|
||||
topLeftTextHeight,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetX,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.title.height,
|
||||
);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
drawRoundedRect({
|
||||
//画总底
|
||||
opacity,
|
||||
width: maxW + 100,
|
||||
width: maxW + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth,
|
||||
height: allH,
|
||||
}),
|
||||
50,
|
||||
0,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetX,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetY,
|
||||
);
|
||||
}
|
||||
}
|
||||
let allH2 = 0;
|
||||
if (BG) {
|
||||
allH2 += 50;
|
||||
allH2 += BANGDREAM_DATA_BLOCK_SPEC.vertical.contentOffsetY;
|
||||
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++) {
|
||||
ctx.drawImage(list[i], xStart, allH2);
|
||||
@ -123,26 +149,27 @@ export function drawDataBlockHorizontal({
|
||||
BG = true,
|
||||
topLeftText,
|
||||
}: DataBlockOptions): Canvas {
|
||||
const topLeftTextHeight = 70;
|
||||
|
||||
// 计算宽度和高度
|
||||
let allW = 0;
|
||||
let contentWidth = 0;
|
||||
let maxH = 0;
|
||||
if (BG) {
|
||||
allW += 200;
|
||||
}
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
allW += list[i].width;
|
||||
contentWidth += list[i].width;
|
||||
if (list[i].height > maxH) {
|
||||
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
|
||||
const tempCanvas =
|
||||
topLeftText !== undefined && BG
|
||||
? new Canvas(allW + topLeftTextHeight, maxH + 100)
|
||||
: new Canvas(allW, maxH + 100);
|
||||
const tempCanvas = new Canvas(canvasSize.width, canvasSize.height);
|
||||
const ctx = tempCanvas.getContext('2d');
|
||||
|
||||
// 绘制背景
|
||||
@ -152,59 +179,80 @@ export function drawDataBlockHorizontal({
|
||||
ctx.drawImage(
|
||||
drawRoundedRect({
|
||||
opacity: 1,
|
||||
color: '#ea4e73',
|
||||
width: topLeftTextHeight + 5,
|
||||
height: 380,
|
||||
radius: [25, 25, 0, 0],
|
||||
strokeColor: '#ffffff',
|
||||
strokeWidth: 5,
|
||||
color: BANGDREAM_DATA_BLOCK_SPEC.title.backgroundColor,
|
||||
width:
|
||||
BANGDREAM_DATA_BLOCK_SPEC.title.height +
|
||||
BANGDREAM_DATA_BLOCK_SPEC.title.rectExtraSize,
|
||||
height: BANGDREAM_DATA_BLOCK_SPEC.title.blockSize,
|
||||
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,
|
||||
50,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleRectX,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleRectY,
|
||||
);
|
||||
|
||||
const textImage = drawText({
|
||||
color: '#ffffff',
|
||||
color: BANGDREAM_DATA_BLOCK_SPEC.title.textColor,
|
||||
text: topLeftText,
|
||||
maxWidth: topLeftTextHeight - 5,
|
||||
lineHeight: 370,
|
||||
maxWidth: getDataBlockTitleLineHeight(),
|
||||
lineHeight:
|
||||
BANGDREAM_DATA_BLOCK_SPEC.title.blockSize -
|
||||
BANGDREAM_DATA_BLOCK_SPEC.title.textInset * 2,
|
||||
});
|
||||
ctx.save();
|
||||
ctx.translate(topLeftTextHeight - 5, 240);
|
||||
ctx.translate(
|
||||
getDataBlockTitleLineHeight(),
|
||||
BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleCenterY,
|
||||
);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.drawImage(textImage, 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
ctx.drawImage(
|
||||
drawRoundedRect({
|
||||
width: allW - 100,
|
||||
height: maxH + 100,
|
||||
radius: [25, 0, 25, 25],
|
||||
width: allW - BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth,
|
||||
height: maxH + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight,
|
||||
radius: [
|
||||
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
|
||||
0,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.background.radius,
|
||||
],
|
||||
}),
|
||||
topLeftTextHeight,
|
||||
50,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.title.height,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.horizontal.contentOffsetY,
|
||||
);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
drawRoundedRect({
|
||||
width: allW - 100,
|
||||
height: maxH + 100,
|
||||
width: allW - BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth,
|
||||
height: maxH + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight,
|
||||
}),
|
||||
50,
|
||||
0,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.horizontal.backgroundOffsetX,
|
||||
BANGDREAM_DATA_BLOCK_SPEC.horizontal.backgroundOffsetY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let allW2 = 0;
|
||||
if (BG) {
|
||||
allW2 += 100;
|
||||
allW2 += BANGDREAM_DATA_BLOCK_SPEC.horizontal.contentOffsetX;
|
||||
if (topLeftText !== undefined) {
|
||||
allW2 += topLeftTextHeight;
|
||||
allW2 += BANGDREAM_DATA_BLOCK_SPEC.title.height;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@ -220,6 +268,6 @@ export function drawDataBlockHorizontal({
|
||||
export function drawBannerImageCanvas(eventBannerImage: Image): Canvas {
|
||||
return resizeImage({
|
||||
image: eventBannerImage,
|
||||
widthMax: 800,
|
||||
widthMax: BANGDREAM_DATA_BLOCK_SPEC.banner.widthMax,
|
||||
});
|
||||
}
|
||||
|
||||
@ -5,6 +5,11 @@ import {
|
||||
getServerByPriority,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/models/server';
|
||||
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) {
|
||||
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 degreeImage = await degree.getDegreeImage(server); //底图
|
||||
ctx.drawImage(degreeImage, 0, 0);
|
||||
|
||||
// 画其他部分,normal类型不需要画
|
||||
const degreeType = degree.degreeType[server];
|
||||
if (
|
||||
degree.degreeType[server] != 'normal' &&
|
||||
degree.degreeType[server] != null &&
|
||||
degree.degreeId > 12
|
||||
shouldDrawDegreeDecorations({
|
||||
degreeId: degree.degreeId,
|
||||
degreeType,
|
||||
})
|
||||
) {
|
||||
//画框
|
||||
if (degree.rank[server] && degree.rank[server] != 'none') {
|
||||
@ -41,7 +51,7 @@ export async function drawDegree(
|
||||
ctx.drawImage(frame, 0, 0);
|
||||
}
|
||||
//画icon
|
||||
if (degree.degreeType[server] != 'try_clear') {
|
||||
if (shouldDrawDegreeIcon(degreeType)) {
|
||||
// 如果不是EX牌活动 EX牌活动没有icon在左边
|
||||
const icon = await degree.getDegreeIcon(server);
|
||||
ctx.drawImage(icon, 0, 0);
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute';
|
||||
import { drawList } from './list-frame';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
import {
|
||||
BANGDREAM_ENTITY_LIST_SPEC,
|
||||
shouldUseSingleEntityLabel,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec';
|
||||
|
||||
interface AttributeInListOptions {
|
||||
key?: string;
|
||||
@ -19,7 +23,7 @@ export async function drawAttributeInList({
|
||||
text,
|
||||
}: AttributeInListOptions): Promise<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(content[0].name.toUpperCase());
|
||||
const canvas = drawList({
|
||||
@ -33,7 +37,7 @@ export async function drawAttributeInList({
|
||||
const canvas = drawList({
|
||||
key: key,
|
||||
content: list,
|
||||
spacing: 0,
|
||||
spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing,
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@ -2,6 +2,10 @@ import { Band } from '@/qqbot/plugins/bangDream/tsugu/models/band';
|
||||
import { getServerByPriority } from '@/qqbot/plugins/bangDream/tsugu/models/server';
|
||||
import { drawList } from './list-frame';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
import {
|
||||
BANGDREAM_ENTITY_LIST_SPEC,
|
||||
shouldUseSingleEntityLabel,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec';
|
||||
|
||||
interface BandInListOptions {
|
||||
key?: string;
|
||||
@ -21,7 +25,7 @@ export async function drawBandInList({
|
||||
}: BandInListOptions): Promise<Canvas> {
|
||||
const server = getServerByPriority(content[0].bandName);
|
||||
const list: Array<string | Image | Canvas> = [];
|
||||
if (content.length == 1 && text == undefined) {
|
||||
if (shouldUseSingleEntityLabel(content.length, text)) {
|
||||
if (content[0].hasIcon) {
|
||||
list.push(await content[0].getIcon());
|
||||
}
|
||||
@ -34,7 +38,7 @@ export async function drawBandInList({
|
||||
} else {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const band = content[i];
|
||||
if (this.hasIcon) {
|
||||
if (band.hasIcon) {
|
||||
list.push(await band.getIcon());
|
||||
} else {
|
||||
list.push(band.bandName[server]);
|
||||
@ -46,7 +50,7 @@ export async function drawBandInList({
|
||||
const canvas = drawList({
|
||||
key: key,
|
||||
content: list,
|
||||
spacing: 0,
|
||||
spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing,
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@ -6,6 +6,10 @@ import {
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/models/server';
|
||||
import { drawList } from './list-frame';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
import {
|
||||
BANGDREAM_ENTITY_LIST_SPEC,
|
||||
shouldUseSingleEntityLabel,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec';
|
||||
|
||||
interface CharacterInListOptions {
|
||||
key?: string;
|
||||
@ -28,7 +32,7 @@ export async function drawCharacterInList(
|
||||
displayedServerList,
|
||||
);
|
||||
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(content[0].getCharacterName()[server]);
|
||||
const canvas = drawList({
|
||||
@ -47,7 +51,7 @@ export async function drawCharacterInList(
|
||||
const canvas = drawList({
|
||||
key: key,
|
||||
content: list,
|
||||
spacing: 0,
|
||||
spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing,
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@ -9,6 +9,12 @@ import {
|
||||
import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event';
|
||||
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
|
||||
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 {
|
||||
key?: string;
|
||||
@ -37,7 +43,7 @@ export async function drawDegreeListInList({
|
||||
return drawList({
|
||||
key: key,
|
||||
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 rankingRewards = event.rankingRewards[server];
|
||||
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));
|
||||
if (tempDegreeList.length >= 6) break;
|
||||
if (
|
||||
tempDegreeList.length >=
|
||||
BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
list.push(
|
||||
@ -71,20 +81,22 @@ export async function drawDegreeListOfEvent(
|
||||
displayedServerList: displayedServerList,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
event.eventType == 'versus' ||
|
||||
event.eventType == 'challenge' ||
|
||||
event.eventType == 'medley'
|
||||
) {
|
||||
if (shouldCollectMusicRankingDegreeRewards(event.eventType)) {
|
||||
const rewards = event.musics[server];
|
||||
for (let i = 0; i < rewards.length; i++) {
|
||||
const tempDegreeList = [];
|
||||
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(
|
||||
new Degree(rewards[i].musicRankingRewards[n].resourceId),
|
||||
);
|
||||
if (tempDegreeList.length >= 6) break;
|
||||
if (
|
||||
tempDegreeList.length >=
|
||||
BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
list.push(
|
||||
@ -94,7 +106,7 @@ export async function drawDegreeListOfEvent(
|
||||
displayedServerList: displayedServerList,
|
||||
}),
|
||||
);
|
||||
if (event.eventType == 'medley') {
|
||||
if (shouldStopAfterFirstMusicRewardGroup(event.eventType)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -106,9 +118,13 @@ export async function drawDegreeListOfEvent(
|
||||
if (Object.prototype.hasOwnProperty.call(rewards, i)) {
|
||||
const rewardsList = rewards[i]['entries'];
|
||||
for (const j in rewardsList) {
|
||||
if (rewardsList[j]['resourceType'] == 'degree') {
|
||||
if (isDegreeRewardType(rewardsList[j]['resourceType'])) {
|
||||
tempDegreeList.push(new Degree(rewardsList[j]['resourceId']));
|
||||
if (tempDegreeList.length >= 6) break;
|
||||
if (
|
||||
tempDegreeList.length >=
|
||||
BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -3,15 +3,10 @@ import { Gacha } from '@/qqbot/plugins/bangDream/tsugu/models/gacha';
|
||||
import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
|
||||
import { Item } from '@/qqbot/plugins/bangDream/tsugu/models/item';
|
||||
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
|
||||
|
||||
const behaviorName = {
|
||||
normal: '',
|
||||
over_the_3_star_once: '必中★3+',
|
||||
over_the_4_star_once: '必中★4+',
|
||||
once_a_day: '每日一次',
|
||||
fixed_4_star_once: '必中★4',
|
||||
fixed_5_star_once: '必中★5',
|
||||
};
|
||||
import {
|
||||
BANGDREAM_GACHA_LIST_SPEC,
|
||||
getGachaPaymentBehaviorLabel,
|
||||
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec';
|
||||
|
||||
/**
|
||||
* 在图片布局层中绘制Gasha支付方式MethodIn列表。
|
||||
@ -45,29 +40,34 @@ export async function drawGashaPaymentMethodInList(gacha: Gacha) {
|
||||
}
|
||||
methodDescription.push(`x${costItemQuantity}`);
|
||||
} else {
|
||||
methodDescription.push(` ? x${costItemQuantity}`);
|
||||
methodDescription.push(
|
||||
`${BANGDREAM_GACHA_LIST_SPEC.paymentText.unknownItemPrefix}${costItemQuantity}`,
|
||||
);
|
||||
}
|
||||
|
||||
//抽卡次数
|
||||
if (patmentMethod.count != undefined) {
|
||||
methodDescription.push(patmentMethod.count + '次抽卡');
|
||||
methodDescription.push(
|
||||
`${patmentMethod.count}${BANGDREAM_GACHA_LIST_SPEC.paymentText.drawCountSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
//更多情况描述
|
||||
if (behaviorName[patmentMethod.behavior] != undefined) {
|
||||
if (behaviorName[patmentMethod.behavior] != '') {
|
||||
methodDescription.push(' ' + behaviorName[patmentMethod.behavior]);
|
||||
}
|
||||
} else {
|
||||
methodDescription.push(patmentMethod.behavior);
|
||||
const behaviorLabel = getGachaPaymentBehaviorLabel(patmentMethod.behavior);
|
||||
if (behaviorLabel !== '') {
|
||||
methodDescription.push(' ' + behaviorLabel);
|
||||
}
|
||||
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;
|
||||
list.push(
|
||||
drawList({
|
||||
key: isFirst ? '付费方式' : undefined,
|
||||
key: isFirst
|
||||
? BANGDREAM_GACHA_LIST_SPEC.label.paymentMethod
|
||||
: undefined,
|
||||
content: methodDescription,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -5,6 +5,7 @@ import { drawCardListInList } from './list-card-icon-list';
|
||||
import { Card } from '@/qqbot/plugins/bangDream/tsugu/models/card';
|
||||
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
|
||||
import { Canvas } from 'skia-canvas';
|
||||
import { BANGDREAM_GACHA_LIST_SPEC } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec';
|
||||
|
||||
/**
|
||||
* 在图片布局层中绘制卡池PickUpIn列表。
|
||||
@ -22,7 +23,7 @@ export async function drawGachaPickupInList(
|
||||
const list = [];
|
||||
list.push(
|
||||
drawList({
|
||||
key: key ?? '卡池PickUp',
|
||||
key: key ?? BANGDREAM_GACHA_LIST_SPEC.label.pickup,
|
||||
}),
|
||||
);
|
||||
let pickUpCardIdList = [];
|
||||
@ -72,8 +73,8 @@ export async function drawGachaPickupInList(
|
||||
return stackImage(list);
|
||||
} else {
|
||||
const result = drawList({
|
||||
key: key ?? '卡池PickUp',
|
||||
text: '无',
|
||||
key: key ?? BANGDREAM_GACHA_LIST_SPEC.label.pickup,
|
||||
text: BANGDREAM_GACHA_LIST_SPEC.label.empty,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -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 { Canvas } from 'skia-canvas';
|
||||
import { drawList } from './list-frame';
|
||||
import { BANGDREAM_GACHA_LIST_SPEC } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec';
|
||||
|
||||
/**
|
||||
* 在图片布局层中绘制卡池概率In列表。
|
||||
@ -22,11 +23,11 @@ export async function drawGachaRateInList(
|
||||
let key = undefined;
|
||||
// 如果卡池数据没有提供概率数据,则不返回概率相关数据
|
||||
if (rates == null) {
|
||||
key = '概率分布';
|
||||
key = BANGDREAM_GACHA_LIST_SPEC.label.rateDistribution;
|
||||
list.push(
|
||||
drawList({
|
||||
key,
|
||||
text: `未提供概率分布数据`,
|
||||
text: BANGDREAM_GACHA_LIST_SPEC.label.rateMissing,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
@ -35,7 +36,7 @@ export async function drawGachaRateInList(
|
||||
continue;
|
||||
}
|
||||
if (times == 0) {
|
||||
key = '概率分布';
|
||||
key = BANGDREAM_GACHA_LIST_SPEC.label.rateDistribution;
|
||||
}
|
||||
list.push(
|
||||
await drawRarityInList({
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -7,6 +7,12 @@ import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
|
||||
import { Degree } from '@/qqbot/plugins/bangDream/tsugu/models/degree';
|
||||
import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text';
|
||||
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 {
|
||||
uid: number;
|
||||
@ -30,44 +36,51 @@ interface User {
|
||||
*/
|
||||
export async function drawPlayerRankingInList(
|
||||
user: User,
|
||||
backgroudColor: string = 'white',
|
||||
backgroudColor: string = BANGDREAM_PLAYER_RANKING_SPEC.backgroundColor,
|
||||
server: Server,
|
||||
): 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');
|
||||
ctx.fillStyle = backgroudColor;
|
||||
ctx.fillRect(0, 0, 800, 110);
|
||||
|
||||
/**
|
||||
* 在图片布局层中移除Braces。
|
||||
*
|
||||
* @param text - 待绘制文本。
|
||||
* @returns 格式化后的文本。
|
||||
*/
|
||||
function removeBraces(text: string): string {
|
||||
const newText = text.replace(/\[[^\]]*\]/g, '');
|
||||
return newText;
|
||||
}
|
||||
ctx.fillRect(
|
||||
0,
|
||||
0,
|
||||
BANGDREAM_PLAYER_RANKING_SPEC.canvas.width,
|
||||
BANGDREAM_PLAYER_RANKING_SPEC.canvas.height,
|
||||
);
|
||||
|
||||
//排名
|
||||
let rankingImage;
|
||||
if (user.ranking == undefined) {
|
||||
return;
|
||||
} else if (user.ranking > 0 && user.ranking <= 3) {
|
||||
} else if (isMedalRanking(user.ranking)) {
|
||||
const rankIamgeBuffer =
|
||||
await playerRankingResourceRepository.getRankImageBuffer(
|
||||
server,
|
||||
user.ranking,
|
||||
);
|
||||
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 {
|
||||
rankingImage = drawText({
|
||||
text: '#' + user.ranking.toString(),
|
||||
textSize: 21,
|
||||
maxWidth: 100,
|
||||
textSize: BANGDREAM_PLAYER_RANKING_SPEC.ranking.textSize,
|
||||
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,
|
||||
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({
|
||||
text: removeBraces(user.name),
|
||||
textSize: 23,
|
||||
maxWidth: 450,
|
||||
text: stripPlayerRankingTextTags(user.name),
|
||||
textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.nameSize,
|
||||
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++) {
|
||||
const degreeImage = await drawDegree(new Degree(user.degrees[i]), server);
|
||||
const degreeLayout = createRankingDegreeLayout({
|
||||
height: degreeImage.height,
|
||||
index: i,
|
||||
width: degreeImage.width,
|
||||
});
|
||||
ctx.drawImage(
|
||||
degreeImage,
|
||||
210 + (degreeImage.width / 2 + 10) * i,
|
||||
46,
|
||||
degreeImage.width / 2,
|
||||
degreeImage.height / 2,
|
||||
degreeLayout.x,
|
||||
degreeLayout.y,
|
||||
degreeLayout.width,
|
||||
degreeLayout.height,
|
||||
);
|
||||
}
|
||||
|
||||
//简介
|
||||
const playerIntroductionImage = drawText({
|
||||
text: removeBraces(user.introduction),
|
||||
textSize: 20,
|
||||
maxWidth: 450,
|
||||
text: stripPlayerRankingTextTags(user.introduction),
|
||||
textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.introductionSize,
|
||||
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({
|
||||
text: '等级 ' + user.rank.toString(),
|
||||
textSize: 23,
|
||||
maxWidth: 150,
|
||||
textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.rankSize,
|
||||
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
|
||||
const idImage = drawText({
|
||||
text: '#' + user.uid,
|
||||
textSize: 20,
|
||||
maxWidth: 150,
|
||||
textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.idSize,
|
||||
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
|
||||
const ptImage = drawText({
|
||||
text: user.currentPt.toString() + '分',
|
||||
textSize: 23,
|
||||
maxWidth: 150,
|
||||
textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.pointSize,
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -15,6 +15,13 @@ import {
|
||||
getBangDreamServerUtcOffset,
|
||||
normalizeBangDreamTimestamp,
|
||||
} 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 {
|
||||
key?: string;
|
||||
@ -46,7 +53,8 @@ export async function drawTimeInList(
|
||||
);
|
||||
if (currentEventId != null && eventId > currentEventId) {
|
||||
formattedTimeList.push(
|
||||
formatTime(estimatedStartAt) + ' (预计开放时间)',
|
||||
formatTime(estimatedStartAt) +
|
||||
BANGDREAM_TIME_FORMAT_SPEC.estimatedOpenSuffix,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -84,31 +92,7 @@ export function getProbableTimeDifference(
|
||||
* @param timeStamp - 时间Stamp参数。
|
||||
*/
|
||||
export function formatTime(timeStamp: number | null) {
|
||||
//时间戳到年月日 精确到分钟
|
||||
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;
|
||||
return formatBangDreamTime(timeStamp);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -117,39 +101,7 @@ export function formatTime(timeStamp: number | null) {
|
||||
* @param timeStamp - 时间Stamp参数。
|
||||
*/
|
||||
export function formatMonthDay(timeStamp: number | null) {
|
||||
//获取生日的月与日
|
||||
/**
|
||||
* 在图片布局层中转换为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;
|
||||
return formatBangDreamMonthDay(timeStamp);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -159,44 +111,7 @@ export function formatMonthDay(timeStamp: number | null) {
|
||||
* @returns 格式化后的文本。
|
||||
*/
|
||||
export function formatTimePeriod(period: number): string {
|
||||
//时间戳的差值到年月日时分秒
|
||||
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;
|
||||
return formatBangDreamPeriod(period);
|
||||
}
|
||||
|
||||
//时间长度转时分秒函数
|
||||
@ -206,25 +121,7 @@ export function formatTimePeriod(period: number): string {
|
||||
* @param value - 当前处理的值。
|
||||
*/
|
||||
export function formatSeconds(value: number) {
|
||||
let theTime = 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;
|
||||
return formatBangDreamSeconds(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -4,8 +4,13 @@ import { drawTextWithImages } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'
|
||||
import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils';
|
||||
import { getBangDreamAssetPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest';
|
||||
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
|
||||
import {
|
||||
BANGDREAM_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。
|
||||
*/
|
||||
@ -28,55 +33,40 @@ loadImageOnce();
|
||||
* @returns 异步处理结果。
|
||||
*/
|
||||
export async function drawCardIconSkill(skill: Skill): Promise<Canvas> {
|
||||
const content: Array<Image | string> = [];
|
||||
const EffectTypes = skill.getEffectTypes();
|
||||
const ScoreUpMaxValue = skill.getScoreUpMaxValue();
|
||||
//画数字部分
|
||||
if (ScoreUpMaxValue != 0) {
|
||||
let skillValue = ScoreUpMaxValue.toString();
|
||||
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 content = createSkillTextFragments({
|
||||
effectTypes: skill.getEffectTypes(),
|
||||
scoreUpMaxValue: skill.getScoreUpMaxValue(),
|
||||
}).flatMap((fragment): Array<Image | string> => {
|
||||
if (fragment.type === 'text') {
|
||||
return [fragment.value];
|
||||
}
|
||||
|
||||
const icon = skillIcon[fragment.key];
|
||||
return icon == null ? [] : [icon];
|
||||
});
|
||||
const spec = BANGDREAM_SKILL_TEXT_SPEC.layout;
|
||||
const stringWithImage = drawTextWithImages({
|
||||
content: content,
|
||||
maxWidth: 250,
|
||||
textSize: 27,
|
||||
lineHeight: 30,
|
||||
spacing: 3,
|
||||
maxWidth: spec.maxWidth,
|
||||
textSize: spec.textSize,
|
||||
lineHeight: spec.lineHeight,
|
||||
spacing: spec.spacing,
|
||||
color: BANGDREAM_RENDER_THEME.color.surface,
|
||||
font: BANGDREAM_RENDER_THEME.font.body,
|
||||
});
|
||||
const textbase = await loadImageFromPath(
|
||||
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');
|
||||
ctx.drawImage(textbase, stringWithImage.width + 15 - textbase.width, 0);
|
||||
ctx.drawImage(stringWithImage, 5, 0);
|
||||
ctx.drawImage(
|
||||
textbase,
|
||||
stringWithImage.width + spec.textBasePaddingRight - textbase.width,
|
||||
spec.baseOffsetY,
|
||||
);
|
||||
ctx.drawImage(stringWithImage, spec.textOffsetX, spec.textOffsetY);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@ -44,13 +44,39 @@ export interface PreviewLayout {
|
||||
export const BANGDREAM_SONG_CHART_PREVIEW_SPEC = {
|
||||
aspectRatioLimit: 16 / 9,
|
||||
blockDistance: 72,
|
||||
coverInset: 16,
|
||||
heightPerSecond: 216,
|
||||
infoAreaWidth: 240,
|
||||
infoOffset: 8,
|
||||
laneCount: 7,
|
||||
laneWidth: 32,
|
||||
minHeight: 500,
|
||||
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,
|
||||
trackGradientStops: [
|
||||
{ color: '#2F4E6F', offset: 0 },
|
||||
{ color: '#3E6F8A', offset: 0.5 },
|
||||
{ color: '#4D80A4', offset: 1 },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES = [
|
||||
|
||||
@ -25,7 +25,6 @@ interface BestdoriPreviewPayload {
|
||||
cover: string | Buffer;
|
||||
}
|
||||
|
||||
const OFFSET = 8;
|
||||
const NOTE_IMAGE_KEYS = [
|
||||
'Single',
|
||||
'SingleOff',
|
||||
@ -149,33 +148,67 @@ function drawBaseInfo(
|
||||
|
||||
ctx.drawImage(
|
||||
coverImg,
|
||||
OFFSET,
|
||||
OFFSET,
|
||||
layout.infoAreaWidth - 16,
|
||||
layout.infoAreaWidth - 16,
|
||||
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset,
|
||||
BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset,
|
||||
layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset,
|
||||
layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset,
|
||||
);
|
||||
|
||||
ctx.save();
|
||||
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.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.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();
|
||||
|
||||
const coverWidth = layout.infoAreaWidth - 16;
|
||||
const coverWidth =
|
||||
layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset;
|
||||
ctx.save();
|
||||
ctx.fillStyle =
|
||||
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS[diff] ??
|
||||
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.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.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();
|
||||
}
|
||||
|
||||
@ -190,17 +223,18 @@ function drawTracks(ctx: any, layout: PreviewLayout): void {
|
||||
ctx.save();
|
||||
const x =
|
||||
layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance;
|
||||
const w =
|
||||
layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount;
|
||||
const w = layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount;
|
||||
const grd = ctx.createLinearGradient(
|
||||
x,
|
||||
0,
|
||||
x + layout.splitLineWidth * 2,
|
||||
0,
|
||||
);
|
||||
grd.addColorStop(0, '#2F4E6F');
|
||||
grd.addColorStop(0.5, '#3E6F8A');
|
||||
grd.addColorStop(1, '#4D80A4');
|
||||
BANGDREAM_SONG_CHART_PREVIEW_SPEC.trackGradientStops.forEach(
|
||||
({ color, offset }) => {
|
||||
grd.addColorStop(offset, color);
|
||||
},
|
||||
);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(
|
||||
x - layout.splitLineWidth * 2,
|
||||
@ -253,8 +287,7 @@ function drawBeatLines(
|
||||
}
|
||||
const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm);
|
||||
const { x, y } = getTimePosition(layout, currentTime);
|
||||
const w =
|
||||
BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
|
||||
const w = BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
@ -445,7 +478,7 @@ function drawSimNote(
|
||||
const lane = note.lane as number[];
|
||||
lane.sort((a, b) => a - b);
|
||||
const simW = layout.laneWidth * (lane[1] - lane[0] - 1);
|
||||
const simH = 2;
|
||||
const simH = BANGDREAM_SONG_CHART_PREVIEW_SPEC.simLineHeight;
|
||||
const simStartX =
|
||||
layout.infoAreaWidth +
|
||||
drawCol * layout.originalWidth +
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,11 @@ import { Canvas, FontLibrary, loadImage } from 'skia-canvas';
|
||||
import 'chartjs-adapter-moment';
|
||||
import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
|
||||
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 所有组件
|
||||
Chart.register(...registerables);
|
||||
@ -34,19 +39,15 @@ export async function drawTimeLineChart(
|
||||
{ start, end, setStartToZero = false, data }: drawTimeLineChartOptions,
|
||||
displayLabel = false,
|
||||
) {
|
||||
const width = 800;
|
||||
const height = 900;
|
||||
const width = BANGDREAM_TIMELINE_CHART_SPEC.canvas.width;
|
||||
const height = BANGDREAM_TIMELINE_CHART_SPEC.canvas.height;
|
||||
|
||||
// 7. 创建 skia-canvas 实例
|
||||
const canvas = new Canvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 8. 计算 y 轴最大值
|
||||
const yMax = Math.max(
|
||||
...data.datasets.map((dataset: any) =>
|
||||
Math.max(...dataset.data.map((pt: any) => pt.y)),
|
||||
),
|
||||
);
|
||||
const yMax = getTimelineRawYMax(data.datasets);
|
||||
|
||||
// 9. 配置 Chart.js 选项
|
||||
const options = {
|
||||
@ -54,7 +55,7 @@ export async function drawTimeLineChart(
|
||||
legend: {
|
||||
labels: {
|
||||
font: {
|
||||
size: 20,
|
||||
size: BANGDREAM_TIMELINE_CHART_SPEC.legend.fontSize,
|
||||
},
|
||||
},
|
||||
display: displayLabel,
|
||||
@ -64,15 +65,15 @@ export async function drawTimeLineChart(
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'day',
|
||||
unit: BANGDREAM_TIMELINE_CHART_SPEC.xAxis.unit,
|
||||
},
|
||||
min: start,
|
||||
max: end,
|
||||
display: !setStartToZero,
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: (yMax + 1000) * 1.1,
|
||||
min: BANGDREAM_TIMELINE_CHART_SPEC.yAxis.min,
|
||||
max: getTimelineDisplayYMax(yMax),
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -83,8 +84,8 @@ export async function drawTimeLineChart(
|
||||
data,
|
||||
options: {
|
||||
...options,
|
||||
responsive: false, // 重要:关闭 Chart.js 自适应模式
|
||||
animation: false,
|
||||
responsive: BANGDREAM_TIMELINE_CHART_SPEC.responsive, // 重要:关闭 Chart.js 自适应模式
|
||||
animation: BANGDREAM_TIMELINE_CHART_SPEC.animation,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
36
test/qqbot/plugins/bangDream/tsugu/chart-spec.spec.ts
Normal file
36
test/qqbot/plugins/bangDream/tsugu/chart-spec.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
58
test/qqbot/plugins/bangDream/tsugu/data-block-spec.spec.ts
Normal file
58
test/qqbot/plugins/bangDream/tsugu/data-block-spec.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
48
test/qqbot/plugins/bangDream/tsugu/degree-list-spec.spec.ts
Normal file
48
test/qqbot/plugins/bangDream/tsugu/degree-list-spec.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
test/qqbot/plugins/bangDream/tsugu/gacha-list-spec.spec.ts
Normal file
27
test/qqbot/plugins/bangDream/tsugu/gacha-list-spec.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
16
test/qqbot/plugins/bangDream/tsugu/list-entity-spec.spec.ts
Normal file
16
test/qqbot/plugins/bangDream/tsugu/list-entity-spec.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
44
test/qqbot/plugins/bangDream/tsugu/list-time-spec.spec.ts
Normal file
44
test/qqbot/plugins/bangDream/tsugu/list-time-spec.spec.ts
Normal 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秒');
|
||||
});
|
||||
});
|
||||
74
test/qqbot/plugins/bangDream/tsugu/skill-text-spec.spec.ts
Normal file
74
test/qqbot/plugins/bangDream/tsugu/skill-text-spec.spec.ts
Normal 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
assignSongChartTimes,
|
||||
BestdoriNote,
|
||||
BANGDREAM_SONG_CHART_PREVIEW_SPEC,
|
||||
createSongChartPreviewLayout,
|
||||
createSongChartPreviewModel,
|
||||
createSongChartPreviewNotes,
|
||||
@ -102,4 +103,21 @@ describe('BangDream song chart preview spec', () => {
|
||||
expect(model.notes.some((note) => note.type === 'Single')).toBe(true);
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user