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/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 和时间/档线规则
|
||||||
|
|||||||
@ -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 { 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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 { 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { 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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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 +
|
||||||
|
|||||||
@ -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 '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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 {
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user