diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 72777d1..dd5afc0 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -397,6 +397,8 @@ export interface TsuguHook { - 已新增 `render-blocks/list-stat-spec.ts`,收口卡牌/玩家综合力列表的间隔、数值行画布、文字规格、进度条布局和条宽算法;`list-stat.ts` 改为消费 spec,综合力计算、突破加成和绘制顺序保持不变;`list-stat-spec.spec.ts` 覆盖间隔/画布/文字/进度条规格、文本生成和条宽布局,本地 `/查卡 472` 图片 smoke 输出非空。 - 已新增 `render-blocks/title-spec.ts`,收口通用标题条的背景偏移、两行文字字号/行高/颜色/字体/坐标;`title.ts` 改为消费 spec,标题底图加载、标题文案和绘制顺序保持不变;`title-spec.spec.ts` 覆盖背景偏移、两行文字布局、绘制参数和坐标,本地 `/查卡 472` 图片 smoke 输出非空。 - 已新增 `render-blocks/list-rarity-spec.ts`,收口星级列表的文字大小、间距和特训星图使用阈值;`list-rarity.ts` 改为消费 spec,星图资源加载、星级数量和列表绘制顺序保持不变;`list-rarity-spec.spec.ts` 覆盖列表规格和 3/4/5 星及未特训边界,本地 `/查卡 472` 图片 smoke 输出非空。 +- 已批量新增 `render-blocks/skill-text-spec.ts`、`list-entity-spec.ts`、`degree-list-spec.ts`、`data-block-spec.ts`、`list-player-ranking-spec.ts`、`list-time-spec.ts`、`timeline-chart-spec.ts`、`cutoff-chart-spec.ts`、`gacha-list-spec.ts`,收口技能角标、基础实体列表、称号/活动奖励称号、数据块、玩家排名行、时间格式、时间线/档线图表和卡池列表文案/布局规格;`skill-text.ts`、`list-attribute.ts`、`list-band.ts`、`list-character.ts`、`degree-badge.ts`、`list-degree-list.ts`、`data-block.ts`、`list-player-ranking.ts`、`list-time.ts`、`timeline-chart.ts`、`cutoff-chart.ts`、`list-gacha-payment-method.ts`、`list-gacha-pick-up.ts`、`list-gacha-rate.ts` 已改为消费 spec;同步修复 `list-band.ts` 多乐队分支误用 `this.hasIcon` 导致图标判断失效的问题;新增对应 8 组 spec 单测,本地 `/查卡 472`、`/查卡池 259`、`/查活动 50`、`/ycx 100 50 cn`、`/查谱面 136 expert` 图片 smoke 输出非空。 +- 已补充 `song-chart-preview-spec.ts` 的封面信息块偏移、ID/难度面板、轨道渐变和双押线高度规格,`song-chart-preview.ts` 不再在绘制函数中散落这些视觉常量;`song-chart-preview-spec.spec.ts` 覆盖新增规格,本地 `/查谱面 136 expert` 图片 smoke 输出非空。 - smoke/Jenkins/远程调试卡点继续按已固化规则处理:同一卡点第二次尝试前必须改变可验证变量;Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home;线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录;PowerShell 双引号 here-string 中不要写 JS `${...}` 模板字面量,避免被 PowerShell 提前插值;smoke 没有真实图片落盘时必须失败。 ### Phase 6:策略 policy 和时间/档线规则 diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart-spec.ts new file mode 100644 index 0000000..c113a6f --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart-spec.ts @@ -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, ''); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart.ts index 56297ba..e85c407 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart.ts @@ -8,6 +8,10 @@ import { getPresetColor } from '@/qqbot/plugins/bangDream/tsugu/models/color'; import { drawList } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-frame'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { BangDreamEventStatus } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; +import { + BANGDREAM_CUTOFF_CHART_SPEC, + stripCutoffChartLabelTags, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart-spec'; /** * 在图片布局层中绘制档线谱面。 @@ -25,7 +29,10 @@ export async function drawCutoffChart( const datasets = []; const time = new Date().getTime(); if (cutoffList.length == 0) { - return new Canvas(1, 1); + return new Canvas( + BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.width, + BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.height, + ); } const list = []; @@ -43,27 +50,38 @@ export async function drawCutoffChart( list.push( drawList({ content: [ - tempColor.generateColorBlock(0.8), + tempColor.generateColorBlock( + BANGDREAM_CUTOFF_CHART_SPEC.legend.colorBlockOpacity, + ), `[${tempEvent.eventId}] ${tempEvent.eventName[server]} T${cutoff.tier}`, ], - textSize: 20, + textSize: BANGDREAM_CUTOFF_CHART_SPEC.legend.textSize, }), ); } else { lableName = `T${cutoff.tier}`; list.push( drawList({ - content: [tempColor.generateColorBlock(0.8), `T${cutoff.tier}`], - textSize: 20, + content: [ + tempColor.generateColorBlock( + BANGDREAM_CUTOFF_CHART_SPEC.legend.colorBlockOpacity, + ), + `T${cutoff.tier}`, + ], + textSize: BANGDREAM_CUTOFF_CHART_SPEC.legend.textSize, }), ); } datasets.push({ label: lableName, data: cutoff.getChartData(setStartToZero), - borderWidth: 5, + borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth, borderColor: [tempColor.getRGBA(1)], - backgroundColor: [tempColor.getRGBA(0.2)], + backgroundColor: [ + tempColor.getRGBA( + BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.singleLineFillBackgroundAlpha, + ), + ], pointBackgroundColor: tempColor.getRGBA(1), pointBorderColor: tempColor.getRGBA(1), fill: onlyOne, @@ -109,15 +127,25 @@ export async function drawCutoffChart( } const tempColor = getPresetColor(i); datasets.push({ - label: `T${cutoff.tier} 预测线`, - borderColor: [tempColor.getRGBA(1)], - backgroundColor: [tempColor.getRGBA(1)], + label: `T${cutoff.tier} ${BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionSuffix}`, + borderColor: [ + tempColor.getRGBA( + BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionBorderAlpha, + ), + ], + backgroundColor: [ + tempColor.getRGBA( + BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionBackgroundAlpha, + ), + ], data: data, - borderWidth: 5, - borderDash: [20, 10], + borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth, + borderDash: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionDash, fill: false, - pointRadius: 0, - pointHoverRadius: 0, + pointRadius: + BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionPointRadius, + pointHoverRadius: + BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionPointHoverRadius, }); } } @@ -126,13 +154,14 @@ export async function drawCutoffChart( if (time < cutoffList[0].endAt) { const tempColor = getPresetColor(0); datasets.push({ - label: '当前时间', + label: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.label, borderColor: [tempColor.getRGBA(1)], backgroundColor: [tempColor.getRGBA(1)], data: [{ x: new Date(time), y: 0 }], fill: false, - pointRadius: 10, - pointHoverRadius: 15, + pointRadius: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.pointRadius, + pointHoverRadius: + BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.pointHoverRadius, showLine: false, }); } @@ -184,30 +213,33 @@ export async function drawCutoffEventTopChart( ) { const datasets = []; if (CutoffEventTop == undefined) { - return new Canvas(1, 1); + return new Canvas( + BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.width, + BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.height, + ); } const allData = CutoffEventTop.getChartData(); - /** - * 在图片布局层中移除Braces。 - * - * @param text - 待绘制文本。 - * @returns 格式化后的文本。 - */ - function removeBraces(text: string): string { - const newText = text.replace(/\[[^\]]*\]/g, ''); - return newText; - } let colorNumber = 0; for (const key in allData) { const tempColor = getPresetColor(colorNumber); datasets.push({ - label: removeBraces(CutoffEventTop.getUserNameById(Number(key))), + label: stripCutoffChartLabelTags( + CutoffEventTop.getUserNameById(Number(key)), + ), data: allData[key], - borderWidth: 4, + borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.borderWidth, borderColor: [tempColor.getRGBA(1)], - backgroundColor: [tempColor.getRGBA(0.2)], - pointBackgroundColor: tempColor.getRGBA(0), - pointBorderColor: tempColor.getRGBA(0), + backgroundColor: [ + tempColor.getRGBA( + BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.singleLineFillBackgroundAlpha, + ), + ], + pointBackgroundColor: tempColor.getRGBA( + BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.pointAlpha, + ), + pointBorderColor: tempColor.getRGBA( + BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.pointAlpha, + ), pointStyle: false, fill: false, }); diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/data-block-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/data-block-spec.ts new file mode 100644 index 0000000..6bdb9d4 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/data-block-spec.ts @@ -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 + ); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/data-block.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/data-block.ts index ca62127..1b4f8c5 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/data-block.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/data-block.ts @@ -2,6 +2,12 @@ import { Canvas, Image } from 'skia-canvas'; import { drawRoundedRect } from '@/qqbot/plugins/bangDream/tsugu/canvas/rect'; import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'; import { resizeImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; +import { + BANGDREAM_DATA_BLOCK_SPEC, + calculateHorizontalDataBlockSize, + calculateVerticalDataBlockSize, + getDataBlockTitleLineHeight, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/data-block-spec'; interface DataBlockOptions { list: Array; @@ -20,27 +26,29 @@ export function drawDataBlock({ list, BG = true, topLeftText, - opacity = 0.9, + opacity = BANGDREAM_DATA_BLOCK_SPEC.background.defaultOpacity, }: DataBlockOptions): Canvas { - const topLeftTextHeight = 70; //计算高度 - let allH = 0; + let contentHeight = 0; let maxW = 0; - if (BG) { - allH += 100; - } for (let i = 0; i < list.length; i++) { - allH = allH + list[i].height; + contentHeight += list[i].height; if (list[i].width > maxW) { maxW = list[i].width; } } + const allH = + contentHeight + + (BG ? BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight : 0); + const canvasSize = calculateVerticalDataBlockSize({ + contentHeight, + maxContentWidth: maxW, + withBackground: BG, + withTitle: topLeftText != undefined, + }); //创建Canvas - const tempCanvas = - topLeftText != undefined && BG - ? new Canvas(maxW + 200, allH + topLeftTextHeight) - : new Canvas(maxW + 200, allH); + const tempCanvas = new Canvas(canvasSize.width, canvasSize.height); const ctx = tempCanvas.getContext('2d'); //画背景 @@ -51,58 +59,76 @@ export function drawDataBlock({ drawRoundedRect({ //画字底,左下角右下角没有圆角 opacity: 1, - color: '#ea4e73', - width: 380, - height: topLeftTextHeight + 5, - radius: [25, 25, 0, 0], - strokeColor: '#ffffff', - strokeWidth: 5, + color: BANGDREAM_DATA_BLOCK_SPEC.title.backgroundColor, + width: BANGDREAM_DATA_BLOCK_SPEC.title.blockSize, + height: + BANGDREAM_DATA_BLOCK_SPEC.title.height + + BANGDREAM_DATA_BLOCK_SPEC.title.rectExtraSize, + radius: [ + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + 0, + 0, + ], + strokeColor: BANGDREAM_DATA_BLOCK_SPEC.title.strokeColor, + strokeWidth: BANGDREAM_DATA_BLOCK_SPEC.title.strokeWidth, }), - 50, - 0, + BANGDREAM_DATA_BLOCK_SPEC.vertical.titleRectX, + BANGDREAM_DATA_BLOCK_SPEC.vertical.titleRectY, ); const textImage = drawText({ //画字 - color: '#ffffff', + color: BANGDREAM_DATA_BLOCK_SPEC.title.textColor, text: topLeftText, - maxWidth: 370, - lineHeight: topLeftTextHeight - 5, + maxWidth: + BANGDREAM_DATA_BLOCK_SPEC.title.blockSize - + BANGDREAM_DATA_BLOCK_SPEC.title.textInset * 2, + lineHeight: getDataBlockTitleLineHeight(), }); - ctx.drawImage(textImage, 240 - textImage.width / 2, 5); + ctx.drawImage( + textImage, + BANGDREAM_DATA_BLOCK_SPEC.vertical.titleCenterX - textImage.width / 2, + BANGDREAM_DATA_BLOCK_SPEC.vertical.titleTextY, + ); ctx.drawImage( drawRoundedRect({ //画总底,左上角没有圆角 opacity, - width: maxW + 100, + width: maxW + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth, height: allH, - radius: [0, 25, 25, 25], + radius: [ + 0, + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + ], }), - 50, - topLeftTextHeight, + BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetX, + BANGDREAM_DATA_BLOCK_SPEC.title.height, ); } else { ctx.drawImage( drawRoundedRect({ //画总底 opacity, - width: maxW + 100, + width: maxW + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth, height: allH, }), - 50, - 0, + BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetX, + BANGDREAM_DATA_BLOCK_SPEC.vertical.backgroundOffsetY, ); } } let allH2 = 0; if (BG) { - allH2 += 50; + allH2 += BANGDREAM_DATA_BLOCK_SPEC.vertical.contentOffsetY; if (topLeftText != undefined) { - allH2 += topLeftTextHeight; + allH2 += BANGDREAM_DATA_BLOCK_SPEC.title.height; } } - const xStart = BG ? 100 : 0; + const xStart = BG ? BANGDREAM_DATA_BLOCK_SPEC.vertical.contentOffsetX : 0; for (let i = 0; i < list.length; i++) { ctx.drawImage(list[i], xStart, allH2); @@ -123,26 +149,27 @@ export function drawDataBlockHorizontal({ BG = true, topLeftText, }: DataBlockOptions): Canvas { - const topLeftTextHeight = 70; - // 计算宽度和高度 - let allW = 0; + let contentWidth = 0; let maxH = 0; - if (BG) { - allW += 200; - } for (let i = 0; i < list.length; i++) { - allW += list[i].width; + contentWidth += list[i].width; if (list[i].height > maxH) { maxH = list[i].height; } } + const allW = + contentWidth + + (BG ? BANGDREAM_DATA_BLOCK_SPEC.background.outerExtraWidth : 0); + const canvasSize = calculateHorizontalDataBlockSize({ + contentWidth, + maxContentHeight: maxH, + withBackground: BG, + withTitle: topLeftText !== undefined, + }); // 创建 Canvas - const tempCanvas = - topLeftText !== undefined && BG - ? new Canvas(allW + topLeftTextHeight, maxH + 100) - : new Canvas(allW, maxH + 100); + const tempCanvas = new Canvas(canvasSize.width, canvasSize.height); const ctx = tempCanvas.getContext('2d'); // 绘制背景 @@ -152,59 +179,80 @@ export function drawDataBlockHorizontal({ ctx.drawImage( drawRoundedRect({ opacity: 1, - color: '#ea4e73', - width: topLeftTextHeight + 5, - height: 380, - radius: [25, 25, 0, 0], - strokeColor: '#ffffff', - strokeWidth: 5, + color: BANGDREAM_DATA_BLOCK_SPEC.title.backgroundColor, + width: + BANGDREAM_DATA_BLOCK_SPEC.title.height + + BANGDREAM_DATA_BLOCK_SPEC.title.rectExtraSize, + height: BANGDREAM_DATA_BLOCK_SPEC.title.blockSize, + radius: [ + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + 0, + 0, + ], + strokeColor: BANGDREAM_DATA_BLOCK_SPEC.title.strokeColor, + strokeWidth: BANGDREAM_DATA_BLOCK_SPEC.title.strokeWidth, }), - 0, - 50, + BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleRectX, + BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleRectY, ); const textImage = drawText({ - color: '#ffffff', + color: BANGDREAM_DATA_BLOCK_SPEC.title.textColor, text: topLeftText, - maxWidth: topLeftTextHeight - 5, - lineHeight: 370, + maxWidth: getDataBlockTitleLineHeight(), + lineHeight: + BANGDREAM_DATA_BLOCK_SPEC.title.blockSize - + BANGDREAM_DATA_BLOCK_SPEC.title.textInset * 2, }); ctx.save(); - ctx.translate(topLeftTextHeight - 5, 240); + ctx.translate( + getDataBlockTitleLineHeight(), + BANGDREAM_DATA_BLOCK_SPEC.horizontal.titleCenterY, + ); ctx.rotate(-Math.PI / 2); ctx.drawImage(textImage, 0, 0); ctx.restore(); ctx.drawImage( drawRoundedRect({ - width: allW - 100, - height: maxH + 100, - radius: [25, 0, 25, 25], + width: allW - BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth, + height: maxH + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight, + radius: [ + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + 0, + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + BANGDREAM_DATA_BLOCK_SPEC.background.radius, + ], }), - topLeftTextHeight, - 50, + BANGDREAM_DATA_BLOCK_SPEC.title.height, + BANGDREAM_DATA_BLOCK_SPEC.horizontal.contentOffsetY, ); } else { ctx.drawImage( drawRoundedRect({ - width: allW - 100, - height: maxH + 100, + width: allW - BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraWidth, + height: maxH + BANGDREAM_DATA_BLOCK_SPEC.background.fillExtraHeight, }), - 50, - 0, + BANGDREAM_DATA_BLOCK_SPEC.horizontal.backgroundOffsetX, + BANGDREAM_DATA_BLOCK_SPEC.horizontal.backgroundOffsetY, ); } } let allW2 = 0; if (BG) { - allW2 += 100; + allW2 += BANGDREAM_DATA_BLOCK_SPEC.horizontal.contentOffsetX; if (topLeftText !== undefined) { - allW2 += topLeftTextHeight; + allW2 += BANGDREAM_DATA_BLOCK_SPEC.title.height; } } for (let i = 0; i < list.length; i++) { - ctx.drawImage(list[i], allW2, 50); + ctx.drawImage( + list[i], + allW2, + BANGDREAM_DATA_BLOCK_SPEC.horizontal.contentOffsetY, + ); allW2 += list[i].width; } @@ -220,6 +268,6 @@ export function drawDataBlockHorizontal({ export function drawBannerImageCanvas(eventBannerImage: Image): Canvas { return resizeImage({ image: eventBannerImage, - widthMax: 800, + widthMax: BANGDREAM_DATA_BLOCK_SPEC.banner.widthMax, }); } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/degree-badge.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/degree-badge.ts index 4d5d4e9..70488be 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/degree-badge.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/degree-badge.ts @@ -5,6 +5,11 @@ import { getServerByPriority, } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { Canvas } from 'skia-canvas'; +import { + BANGDREAM_DEGREE_LIST_SPEC, + shouldDrawDegreeDecorations, + shouldDrawDegreeIcon, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/degree-list-spec'; /** * 在图片布局层中绘制称号。 @@ -23,17 +28,22 @@ export async function drawDegree( if (degree.degreeName[server] == null) { server = getServerByPriority(degree.degreeName, displayedServerList); } - const canvas = new Canvas(230, 50); + const canvas = new Canvas( + BANGDREAM_DEGREE_LIST_SPEC.badge.width, + BANGDREAM_DEGREE_LIST_SPEC.badge.height, + ); const ctx = canvas.getContext('2d'); const degreeImage = await degree.getDegreeImage(server); //底图 ctx.drawImage(degreeImage, 0, 0); // 画其他部分,normal类型不需要画 + const degreeType = degree.degreeType[server]; if ( - degree.degreeType[server] != 'normal' && - degree.degreeType[server] != null && - degree.degreeId > 12 + shouldDrawDegreeDecorations({ + degreeId: degree.degreeId, + degreeType, + }) ) { //画框 if (degree.rank[server] && degree.rank[server] != 'none') { @@ -41,7 +51,7 @@ export async function drawDegree( ctx.drawImage(frame, 0, 0); } //画icon - if (degree.degreeType[server] != 'try_clear') { + if (shouldDrawDegreeIcon(degreeType)) { // 如果不是EX牌活动 EX牌活动没有icon在左边 const icon = await degree.getDegreeIcon(server); ctx.drawImage(icon, 0, 0); diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/degree-list-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/degree-list-spec.ts new file mode 100644 index 0000000..37c50ed --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/degree-list-spec.ts @@ -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 + ); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec.ts new file mode 100644 index 0000000..0be75f1 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec.ts @@ -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 + ); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-attribute.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-attribute.ts index a598602..ebbd2dc 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-attribute.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-attribute.ts @@ -1,6 +1,10 @@ import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute'; import { drawList } from './list-frame'; import { Canvas, Image } from 'skia-canvas'; +import { + BANGDREAM_ENTITY_LIST_SPEC, + shouldUseSingleEntityLabel, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec'; interface AttributeInListOptions { key?: string; @@ -19,7 +23,7 @@ export async function drawAttributeInList({ text, }: AttributeInListOptions): Promise { const list: Array = []; - if (content.length == 1 && text == undefined) { + if (shouldUseSingleEntityLabel(content.length, text)) { list.push(await content[0].getIcon()); list.push(content[0].name.toUpperCase()); const canvas = drawList({ @@ -33,7 +37,7 @@ export async function drawAttributeInList({ const canvas = drawList({ key: key, content: list, - spacing: 0, + spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing, }); return canvas; } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band.ts index 6c2ca18..3aa59d6 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-band.ts @@ -2,6 +2,10 @@ import { Band } from '@/qqbot/plugins/bangDream/tsugu/models/band'; import { getServerByPriority } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { drawList } from './list-frame'; import { Canvas, Image } from 'skia-canvas'; +import { + BANGDREAM_ENTITY_LIST_SPEC, + shouldUseSingleEntityLabel, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec'; interface BandInListOptions { key?: string; @@ -21,7 +25,7 @@ export async function drawBandInList({ }: BandInListOptions): Promise { const server = getServerByPriority(content[0].bandName); const list: Array = []; - if (content.length == 1 && text == undefined) { + if (shouldUseSingleEntityLabel(content.length, text)) { if (content[0].hasIcon) { list.push(await content[0].getIcon()); } @@ -34,7 +38,7 @@ export async function drawBandInList({ } else { for (let i = 0; i < content.length; i++) { const band = content[i]; - if (this.hasIcon) { + if (band.hasIcon) { list.push(await band.getIcon()); } else { list.push(band.bandName[server]); @@ -46,7 +50,7 @@ export async function drawBandInList({ const canvas = drawList({ key: key, content: list, - spacing: 0, + spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing, }); return canvas; } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-character.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-character.ts index 6a53577..ecabbe8 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-character.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-character.ts @@ -6,6 +6,10 @@ import { } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { drawList } from './list-frame'; import { Canvas, Image } from 'skia-canvas'; +import { + BANGDREAM_ENTITY_LIST_SPEC, + shouldUseSingleEntityLabel, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec'; interface CharacterInListOptions { key?: string; @@ -28,7 +32,7 @@ export async function drawCharacterInList( displayedServerList, ); const list: Array = []; - if (content.length == 1 && text == undefined) { + if (shouldUseSingleEntityLabel(content.length, text)) { list.push(await content[0].getIcon()); list.push(content[0].getCharacterName()[server]); const canvas = drawList({ @@ -47,7 +51,7 @@ export async function drawCharacterInList( const canvas = drawList({ key: key, content: list, - spacing: 0, + spacing: BANGDREAM_ENTITY_LIST_SPEC.multiValueSpacing, }); return canvas; } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-degree-list.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-degree-list.ts index 8fabfdb..cefa76a 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-degree-list.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-degree-list.ts @@ -9,6 +9,12 @@ import { import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; +import { + BANGDREAM_DEGREE_LIST_SPEC, + isDegreeRewardType, + shouldCollectMusicRankingDegreeRewards, + shouldStopAfterFirstMusicRewardGroup, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/degree-list-spec'; interface DegreeListInListOptions { key?: string; @@ -37,7 +43,7 @@ export async function drawDegreeListInList({ return drawList({ key: key, content: list, - textSize: 50, + textSize: BANGDREAM_DEGREE_LIST_SPEC.list.textSize, }); } @@ -58,9 +64,13 @@ export async function drawDegreeListOfEvent( const server = getServerByPriority(event.rankingRewards, displayedServerList); const rankingRewards = event.rankingRewards[server]; for (let i = 0; i < rankingRewards.length; i++) { - if (rankingRewards[i].rewardType == 'degree') { + if (isDegreeRewardType(rankingRewards[i].rewardType)) { tempDegreeList.push(new Degree(rankingRewards[i].rewardId)); - if (tempDegreeList.length >= 6) break; + if ( + tempDegreeList.length >= + BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount + ) + break; } } list.push( @@ -71,20 +81,22 @@ export async function drawDegreeListOfEvent( displayedServerList: displayedServerList, }), ); - if ( - event.eventType == 'versus' || - event.eventType == 'challenge' || - event.eventType == 'medley' - ) { + if (shouldCollectMusicRankingDegreeRewards(event.eventType)) { const rewards = event.musics[server]; for (let i = 0; i < rewards.length; i++) { const tempDegreeList = []; for (let n = 0; n < rewards[i].musicRankingRewards.length; n++) { - if (rewards[i].musicRankingRewards[n].resourceType == 'degree') { + if ( + isDegreeRewardType(rewards[i].musicRankingRewards[n].resourceType) + ) { tempDegreeList.push( new Degree(rewards[i].musicRankingRewards[n].resourceId), ); - if (tempDegreeList.length >= 6) break; + if ( + tempDegreeList.length >= + BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount + ) + break; } } list.push( @@ -94,7 +106,7 @@ export async function drawDegreeListOfEvent( displayedServerList: displayedServerList, }), ); - if (event.eventType == 'medley') { + if (shouldStopAfterFirstMusicRewardGroup(event.eventType)) { break; } } @@ -106,9 +118,13 @@ export async function drawDegreeListOfEvent( if (Object.prototype.hasOwnProperty.call(rewards, i)) { const rewardsList = rewards[i]['entries']; for (const j in rewardsList) { - if (rewardsList[j]['resourceType'] == 'degree') { + if (isDegreeRewardType(rewardsList[j]['resourceType'])) { tempDegreeList.push(new Degree(rewardsList[j]['resourceId'])); - if (tempDegreeList.length >= 6) break; + if ( + tempDegreeList.length >= + BANGDREAM_DEGREE_LIST_SPEC.eventRewards.maxDegreeCount + ) + break; } } } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec.ts new file mode 100644 index 0000000..0b491f5 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-entity-spec.ts @@ -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; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-payment-method.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-payment-method.ts index 17626d7..f404124 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-payment-method.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-payment-method.ts @@ -3,15 +3,10 @@ import { Gacha } from '@/qqbot/plugins/bangDream/tsugu/models/gacha'; import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { Item } from '@/qqbot/plugins/bangDream/tsugu/models/item'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; - -const behaviorName = { - normal: '', - over_the_3_star_once: '必中★3+', - over_the_4_star_once: '必中★4+', - once_a_day: '每日一次', - fixed_4_star_once: '必中★4', - fixed_5_star_once: '必中★5', -}; +import { + BANGDREAM_GACHA_LIST_SPEC, + getGachaPaymentBehaviorLabel, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec'; /** * 在图片布局层中绘制Gasha支付方式MethodIn列表。 @@ -45,29 +40,34 @@ export async function drawGashaPaymentMethodInList(gacha: Gacha) { } methodDescription.push(`x${costItemQuantity}`); } else { - methodDescription.push(` ? x${costItemQuantity}`); + methodDescription.push( + `${BANGDREAM_GACHA_LIST_SPEC.paymentText.unknownItemPrefix}${costItemQuantity}`, + ); } //抽卡次数 if (patmentMethod.count != undefined) { - methodDescription.push(patmentMethod.count + '次抽卡'); + methodDescription.push( + `${patmentMethod.count}${BANGDREAM_GACHA_LIST_SPEC.paymentText.drawCountSuffix}`, + ); } //更多情况描述 - if (behaviorName[patmentMethod.behavior] != undefined) { - if (behaviorName[patmentMethod.behavior] != '') { - methodDescription.push(' ' + behaviorName[patmentMethod.behavior]); - } - } else { - methodDescription.push(patmentMethod.behavior); + const behaviorLabel = getGachaPaymentBehaviorLabel(patmentMethod.behavior); + if (behaviorLabel !== '') { + methodDescription.push(' ' + behaviorLabel); } if (patmentMethod['maxSpinLimit'] != undefined) { - methodDescription.push(' 仅限' + patmentMethod['maxSpinLimit'] + '次'); + methodDescription.push( + `${BANGDREAM_GACHA_LIST_SPEC.paymentText.limitPrefix}${patmentMethod['maxSpinLimit']}${BANGDREAM_GACHA_LIST_SPEC.paymentText.limitSuffix}`, + ); } const isFirst = i == 0; list.push( drawList({ - key: isFirst ? '付费方式' : undefined, + key: isFirst + ? BANGDREAM_GACHA_LIST_SPEC.label.paymentMethod + : undefined, content: methodDescription, }), ); diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-pick-up.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-pick-up.ts index fee4973..32f1bed 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-pick-up.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-pick-up.ts @@ -5,6 +5,7 @@ import { drawCardListInList } from './list-card-icon-list'; import { Card } from '@/qqbot/plugins/bangDream/tsugu/models/card'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { Canvas } from 'skia-canvas'; +import { BANGDREAM_GACHA_LIST_SPEC } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec'; /** * 在图片布局层中绘制卡池PickUpIn列表。 @@ -22,7 +23,7 @@ export async function drawGachaPickupInList( const list = []; list.push( drawList({ - key: key ?? '卡池PickUp', + key: key ?? BANGDREAM_GACHA_LIST_SPEC.label.pickup, }), ); let pickUpCardIdList = []; @@ -72,8 +73,8 @@ export async function drawGachaPickupInList( return stackImage(list); } else { const result = drawList({ - key: key ?? '卡池PickUp', - text: '无', + key: key ?? BANGDREAM_GACHA_LIST_SPEC.label.pickup, + text: BANGDREAM_GACHA_LIST_SPEC.label.empty, }); return result; } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-rate.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-rate.ts index 9c05da6..ba20d2e 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-rate.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-gacha-rate.ts @@ -4,6 +4,7 @@ import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack'; import { Canvas } from 'skia-canvas'; import { drawList } from './list-frame'; +import { BANGDREAM_GACHA_LIST_SPEC } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/gacha-list-spec'; /** * 在图片布局层中绘制卡池概率In列表。 @@ -22,11 +23,11 @@ export async function drawGachaRateInList( let key = undefined; // 如果卡池数据没有提供概率数据,则不返回概率相关数据 if (rates == null) { - key = '概率分布'; + key = BANGDREAM_GACHA_LIST_SPEC.label.rateDistribution; list.push( drawList({ key, - text: `未提供概率分布数据`, + text: BANGDREAM_GACHA_LIST_SPEC.label.rateMissing, }), ); } else { @@ -35,7 +36,7 @@ export async function drawGachaRateInList( continue; } if (times == 0) { - key = '概率分布'; + key = BANGDREAM_GACHA_LIST_SPEC.label.rateDistribution; } list.push( await drawRarityInList({ diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking-spec.ts new file mode 100644 index 0000000..a8e88c0 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking-spec.ts @@ -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, + }; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking.ts index 2eb0673..973fc20 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking.ts @@ -7,6 +7,12 @@ import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { Degree } from '@/qqbot/plugins/bangDream/tsugu/models/degree'; import { drawText } from '@/qqbot/plugins/bangDream/tsugu/canvas/text'; import { playerRankingResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/player-ranking-resource-repository'; +import { + BANGDREAM_PLAYER_RANKING_SPEC, + createRankingDegreeLayout, + isMedalRanking, + stripPlayerRankingTextTags, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-player-ranking-spec'; interface User { uid: number; @@ -30,44 +36,51 @@ interface User { */ export async function drawPlayerRankingInList( user: User, - backgroudColor: string = 'white', + backgroudColor: string = BANGDREAM_PLAYER_RANKING_SPEC.backgroundColor, server: Server, ): Promise { - const canvas = new Canvas(800, 110); + const canvas = new Canvas( + BANGDREAM_PLAYER_RANKING_SPEC.canvas.width, + BANGDREAM_PLAYER_RANKING_SPEC.canvas.height, + ); const ctx = canvas.getContext('2d'); ctx.fillStyle = backgroudColor; - ctx.fillRect(0, 0, 800, 110); - - /** - * 在图片布局层中移除Braces。 - * - * @param text - 待绘制文本。 - * @returns 格式化后的文本。 - */ - function removeBraces(text: string): string { - const newText = text.replace(/\[[^\]]*\]/g, ''); - return newText; - } + ctx.fillRect( + 0, + 0, + BANGDREAM_PLAYER_RANKING_SPEC.canvas.width, + BANGDREAM_PLAYER_RANKING_SPEC.canvas.height, + ); //排名 let rankingImage; if (user.ranking == undefined) { return; - } else if (user.ranking > 0 && user.ranking <= 3) { + } else if (isMedalRanking(user.ranking)) { const rankIamgeBuffer = await playerRankingResourceRepository.getRankImageBuffer( server, user.ranking, ); rankingImage = await loadImage(rankIamgeBuffer); - ctx.drawImage(rankingImage, 12, 45, 45, 21); + ctx.drawImage( + rankingImage, + BANGDREAM_PLAYER_RANKING_SPEC.ranking.x, + BANGDREAM_PLAYER_RANKING_SPEC.ranking.y, + BANGDREAM_PLAYER_RANKING_SPEC.ranking.imageWidth, + BANGDREAM_PLAYER_RANKING_SPEC.ranking.imageHeight, + ); } else { rankingImage = drawText({ text: '#' + user.ranking.toString(), - textSize: 21, - maxWidth: 100, + textSize: BANGDREAM_PLAYER_RANKING_SPEC.ranking.textSize, + maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.ranking.fallbackMaxWidth, }); - ctx.drawImage(rankingImage, 12, 45); + ctx.drawImage( + rankingImage, + BANGDREAM_PLAYER_RANKING_SPEC.ranking.x, + BANGDREAM_PLAYER_RANKING_SPEC.ranking.y, + ); } //头像 @@ -78,59 +91,90 @@ export async function drawPlayerRankingInList( skillTypeVisible: false, cardTypeVisible: false, }); - ctx.drawImage(headShotImage, 85, 10, 90, 90); + ctx.drawImage( + headShotImage, + BANGDREAM_PLAYER_RANKING_SPEC.headShot.x, + BANGDREAM_PLAYER_RANKING_SPEC.headShot.y, + BANGDREAM_PLAYER_RANKING_SPEC.headShot.width, + BANGDREAM_PLAYER_RANKING_SPEC.headShot.height, + ); //玩家昵称 const playerNameImage = drawText({ - text: removeBraces(user.name), - textSize: 23, - maxWidth: 450, + text: stripPlayerRankingTextTags(user.name), + textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.nameSize, + maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.text.nameMaxWidth, }); - ctx.drawImage(playerNameImage, 210, 10); + ctx.drawImage( + playerNameImage, + BANGDREAM_PLAYER_RANKING_SPEC.text.nameX, + BANGDREAM_PLAYER_RANKING_SPEC.text.nameY, + ); //牌子 for (let i = 0; i < user.degrees.length; i++) { const degreeImage = await drawDegree(new Degree(user.degrees[i]), server); + const degreeLayout = createRankingDegreeLayout({ + height: degreeImage.height, + index: i, + width: degreeImage.width, + }); ctx.drawImage( degreeImage, - 210 + (degreeImage.width / 2 + 10) * i, - 46, - degreeImage.width / 2, - degreeImage.height / 2, + degreeLayout.x, + degreeLayout.y, + degreeLayout.width, + degreeLayout.height, ); } //简介 const playerIntroductionImage = drawText({ - text: removeBraces(user.introduction), - textSize: 20, - maxWidth: 450, + text: stripPlayerRankingTextTags(user.introduction), + textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.introductionSize, + maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.text.introductionMaxWidth, }); - ctx.drawImage(playerIntroductionImage, 210, 75); + ctx.drawImage( + playerIntroductionImage, + BANGDREAM_PLAYER_RANKING_SPEC.text.introductionX, + BANGDREAM_PLAYER_RANKING_SPEC.text.introductionY, + ); //等级 const playerRankImage = drawText({ text: '等级 ' + user.rank.toString(), - textSize: 23, - maxWidth: 150, + textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.rankSize, + maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.maxWidth, }); - ctx.drawImage(playerRankImage, 790 - playerRankImage.width, 10); + ctx.drawImage( + playerRankImage, + BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.edgeX - playerRankImage.width, + BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.rankY, + ); //id const idImage = drawText({ text: '#' + user.uid, - textSize: 20, - maxWidth: 150, + textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.idSize, + maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.maxWidth, }); - ctx.drawImage(idImage, 790 - idImage.width, 45); + ctx.drawImage( + idImage, + BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.edgeX - idImage.width, + BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.idY, + ); //pt const ptImage = drawText({ text: user.currentPt.toString() + '分', - textSize: 23, - maxWidth: 150, + textSize: BANGDREAM_PLAYER_RANKING_SPEC.text.pointSize, + maxWidth: BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.maxWidth, }); - ctx.drawImage(ptImage, 790 - ptImage.width, 70); + ctx.drawImage( + ptImage, + BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.edgeX - ptImage.width, + BANGDREAM_PLAYER_RANKING_SPEC.rightColumn.pointY, + ); return canvas; } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time-spec.ts new file mode 100644 index 0000000..d6db57e --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time-spec.ts @@ -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; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time.ts index 3975a6d..082cbda 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/list-time.ts @@ -15,6 +15,13 @@ import { getBangDreamServerUtcOffset, normalizeBangDreamTimestamp, } from '@/qqbot/plugins/bangDream/tsugu/models/server-policy'; +import { + BANGDREAM_TIME_FORMAT_SPEC, + formatBangDreamMonthDay, + formatBangDreamPeriod, + formatBangDreamSeconds, + formatBangDreamTime, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time-spec'; interface TimeInListOptions { key?: string; @@ -46,7 +53,8 @@ export async function drawTimeInList( ); if (currentEventId != null && eventId > currentEventId) { formattedTimeList.push( - formatTime(estimatedStartAt) + ' (预计开放时间)', + formatTime(estimatedStartAt) + + BANGDREAM_TIME_FORMAT_SPEC.estimatedOpenSuffix, ); } } @@ -84,31 +92,7 @@ export function getProbableTimeDifference( * @param timeStamp - 时间Stamp参数。 */ export function formatTime(timeStamp: number | null) { - //时间戳到年月日 精确到分钟 - if (timeStamp == null) { - return '?'; - } - const date = new Date(Math.floor(timeStamp / 1000) * 1000); - let nMinutes: string; - if (date.getMinutes() < 10) { - nMinutes = '0' + date.getMinutes().toString(); - if (date.getMinutes() == 0) { - nMinutes = '00'; - } - } else { - nMinutes = date.getMinutes().toString(); - } - const temp = - date.getFullYear().toString() + - '年' + - (date.getMonth() + 1).toString() + - '月' + - date.getDate().toString() + - '日 ' + - date.getHours().toString() + - ':' + - nMinutes; - return temp; + return formatBangDreamTime(timeStamp); } /** @@ -117,39 +101,7 @@ export function formatTime(timeStamp: number | null) { * @param timeStamp - 时间Stamp参数。 */ export function formatMonthDay(timeStamp: number | null) { - //获取生日的月与日 - /** - * 在图片布局层中转换为Japan时间。 - * - * @param dateString - dateString参数。 - */ - function toJapanTime(dateString) { - // 创建一个新的Date实例,表示当前时间。 - const date = new Date(dateString); - - // 获取本地时间与UTC的时间差(分钟)。 - const offset = date.getTimezoneOffset() * 60000; - - // 将本地时间转换为UTC时间。 - const utcTime = date.getTime() + offset; - - // 日本时区的偏移量是UTC+9。 - const japanTimeOffset = 9 * 60 * 60 * 1000; - - // 将UTC时间转换为日本时间。 - const japanTime = new Date(utcTime + japanTimeOffset); - - // 返回日本时间的字符串表示。 - return japanTime; - } - - if (timeStamp == null) { - return '?'; - } - const date = toJapanTime(timeStamp); - const temp = - (date.getMonth() + 1).toString() + '月' + date.getDate().toString() + '日 '; - return temp; + return formatBangDreamMonthDay(timeStamp); } /** @@ -159,44 +111,7 @@ export function formatMonthDay(timeStamp: number | null) { * @returns 格式化后的文本。 */ export function formatTimePeriod(period: number): string { - //时间戳的差值到年月日时分秒 - if (period == null) { - return '?'; - } - - const century = Math.floor(period / (1000 * 60 * 60 * 24 * 30 * 12 * 100)); - const years = Math.floor(period / (1000 * 60 * 60 * 24 * 30 * 12)); - const months = Math.floor(period / (1000 * 60 * 60 * 24 * 30)); - const days = Math.floor( - (period % (1000 * 60 * 60 * 24 * 30)) / (1000 * 60 * 60 * 24), - ); - const hours = Math.floor((period % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((period % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((period % (1000 * 60)) / 1000); - - let temp = ''; - - if (century != 0) { - temp += century.toString() + '世纪'; - } - if (years != 0) { - temp += years.toString() + '年'; - } - if (months != 0) { - temp += months.toString() + '月'; - } - if (days != 0) { - temp += days.toString() + '日'; - } - if (hours != 0) { - temp += hours.toString() + '小时'; - } - if (minutes != 0) { - temp += minutes.toString() + '分钟'; - } - temp += seconds.toString() + '秒'; - - return temp; + return formatBangDreamPeriod(period); } //时间长度转时分秒函数 @@ -206,25 +121,7 @@ export function formatTimePeriod(period: number): string { * @param value - 当前处理的值。 */ export function formatSeconds(value: number) { - let theTime = value; // 秒 - let theTime1 = 0; // 分 - let theTime2 = 0; // 小时 - if (theTime > 60) { - theTime1 = parseInt((theTime / 60).toString()); - theTime = parseInt((theTime % 60).toString()); - if (theTime1 > 60) { - theTime2 = parseInt((theTime1 / 60).toString()); - theTime1 = parseInt((theTime1 % 60).toString()); - } - } - let result = '' + parseInt(theTime.toString()) + '秒'; - if (theTime1 > 0) { - result = '' + parseInt(theTime1.toString()) + '分' + result; - } - if (theTime2 > 0) { - result = '' + parseInt(theTime2.toString()) + '小时' + result; - } - return result; + return formatBangDreamSeconds(value); } /** diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text-spec.ts new file mode 100644 index 0000000..0865a2c --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text-spec.ts @@ -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; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text.ts index 99e67c5..c99c314 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text.ts @@ -4,8 +4,13 @@ import { drawTextWithImages } from '@/qqbot/plugins/bangDream/tsugu/canvas/text' import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { getBangDreamAssetPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest'; import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme'; +import { + BANGDREAM_SKILL_TEXT_SPEC, + BangDreamSkillIconKey, + createSkillTextFragments, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/skill-text-spec'; -const skillIcon: { [skillType: string]: Image } = {}; +const skillIcon: Partial> = {}; /** * 在图片布局层中加载图片Once。 */ @@ -28,55 +33,40 @@ loadImageOnce(); * @returns 异步处理结果。 */ export async function drawCardIconSkill(skill: Skill): Promise { - const content: Array = []; - const EffectTypes = skill.getEffectTypes(); - const ScoreUpMaxValue = skill.getScoreUpMaxValue(); - //画数字部分 - if (ScoreUpMaxValue != 0) { - let skillValue = ScoreUpMaxValue.toString(); - if (EffectTypes.includes('score_continued_note_judge')) { - skillValue += 'G'; - } else if (EffectTypes.includes('score_over_life')) { - if (EffectTypes.includes('score_under_life')) { - skillValue += 'L'; - } else { - skillValue += '/'; - } - } else if ( - EffectTypes.includes('score_under_great_half') || - EffectTypes.includes('score_perfect') - ) { - skillValue += 'P'; - } else if (EffectTypes.includes('score_rate_up_with_perfect')) { - skillValue += '+0.5*P'; - } - content.push(skillValue); - } - //图标部分 - EffectTypes.forEach((EffectType) => { - if (EffectType == 'judge') { - content.push(skillIcon.judge); - } else if (EffectType == 'life') { - content.push(skillIcon.life); - } else if (EffectType == 'damage') { - content.push(skillIcon.damage); + const content = createSkillTextFragments({ + effectTypes: skill.getEffectTypes(), + scoreUpMaxValue: skill.getScoreUpMaxValue(), + }).flatMap((fragment): Array => { + if (fragment.type === 'text') { + return [fragment.value]; } + + const icon = skillIcon[fragment.key]; + return icon == null ? [] : [icon]; }); + const spec = BANGDREAM_SKILL_TEXT_SPEC.layout; const stringWithImage = drawTextWithImages({ content: content, - maxWidth: 250, - textSize: 27, - lineHeight: 30, - spacing: 3, + maxWidth: spec.maxWidth, + textSize: spec.textSize, + lineHeight: spec.lineHeight, + spacing: spec.spacing, color: BANGDREAM_RENDER_THEME.color.surface, font: BANGDREAM_RENDER_THEME.font.body, }); const textbase = await loadImageFromPath( getBangDreamAssetPath('cardSkillTextBase'), ); - const canvas = new Canvas(stringWithImage.width + 15, 45); + const canvas = new Canvas( + stringWithImage.width + spec.textBasePaddingRight, + spec.canvasHeight, + ); const ctx = canvas.getContext('2d'); - ctx.drawImage(textbase, stringWithImage.width + 15 - textbase.width, 0); - ctx.drawImage(stringWithImage, 5, 0); + ctx.drawImage( + textbase, + stringWithImage.width + spec.textBasePaddingRight - textbase.width, + spec.baseOffsetY, + ); + ctx.drawImage(stringWithImage, spec.textOffsetX, spec.textOffsetY); return canvas; } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec.ts index 168da87..538bc81 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec.ts @@ -44,13 +44,39 @@ export interface PreviewLayout { export const BANGDREAM_SONG_CHART_PREVIEW_SPEC = { aspectRatioLimit: 16 / 9, blockDistance: 72, + coverInset: 16, heightPerSecond: 216, infoAreaWidth: 240, + infoOffset: 8, laneCount: 7, laneWidth: 32, minHeight: 500, noteEndPaddingSeconds: 0.25, + panel: { + fontSize: 16, + height: 24, + maxWidth: 128, + width: 128, + }, + difficultyPanel: { + textXFromCoverRight: -52, + textYOffsetFromCoverBottom: 0, + xFromCoverRight: -116, + yFromCoverBottom: -12, + }, + idPanel: { + textXOffset: 56, + textYOffset: 4, + xOffset: -8, + yOffset: -8, + }, + simLineHeight: 2, splitLineWidth: 2, + trackGradientStops: [ + { color: '#2F4E6F', offset: 0 }, + { color: '#3E6F8A', offset: 0.5 }, + { color: '#4D80A4', offset: 1 }, + ], } as const; export const BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES = [ diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts index 38749af..47d0b1f 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview.ts @@ -25,7 +25,6 @@ interface BestdoriPreviewPayload { cover: string | Buffer; } -const OFFSET = 8; const NOTE_IMAGE_KEYS = [ 'Single', 'SingleOff', @@ -149,33 +148,67 @@ function drawBaseInfo( ctx.drawImage( coverImg, - OFFSET, - OFFSET, - layout.infoAreaWidth - 16, - layout.infoAreaWidth - 16, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset, + layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset, + layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset, ); ctx.save(); ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartPanel; - ctx.fillRect(OFFSET - 8, OFFSET - 8, 128, 24); + ctx.fillRect( + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset + + BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.xOffset, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset + + BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.yOffset, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.width, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.height, + ); ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText; - ctx.font = `16px "${BANGDREAM_RENDER_THEME.font.chart}"`; + ctx.font = `${BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.fontSize}px "${BANGDREAM_RENDER_THEME.font.chart}"`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(`${id}`, OFFSET + 56, OFFSET + 4, 128); + ctx.fillText( + `${id}`, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset + + BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.textXOffset, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset + + BANGDREAM_SONG_CHART_PREVIEW_SPEC.idPanel.textYOffset, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.maxWidth, + ); ctx.restore(); - const coverWidth = layout.infoAreaWidth - 16; + const coverWidth = + layout.infoAreaWidth - BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset; ctx.save(); ctx.fillStyle = BANGDREAM_SONG_CHART_DIFFICULTY_COLORS[diff] ?? BANGDREAM_RENDER_THEME.color.chartDifficultyFallback; - ctx.fillRect(8 + coverWidth - 116, 8 + coverWidth - 12, 128, 24); + ctx.fillRect( + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset + + coverWidth + + BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.xFromCoverRight, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset + + coverWidth + + BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.yFromCoverBottom, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.width, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.height, + ); ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText; - ctx.font = `16px "${BANGDREAM_RENDER_THEME.font.chart}"`; + ctx.font = `${BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.fontSize}px "${BANGDREAM_RENDER_THEME.font.chart}"`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(`${diff} ${level}`, 8 + coverWidth - 52, 8 + coverWidth, 128); + ctx.fillText( + `${diff} ${level}`, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset + + coverWidth + + BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel.textXFromCoverRight, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset + + coverWidth + + BANGDREAM_SONG_CHART_PREVIEW_SPEC.difficultyPanel + .textYOffsetFromCoverBottom, + BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel.maxWidth, + ); ctx.restore(); } @@ -190,17 +223,18 @@ function drawTracks(ctx: any, layout: PreviewLayout): void { ctx.save(); const x = layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance; - const w = - layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount; + const w = layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount; const grd = ctx.createLinearGradient( x, 0, x + layout.splitLineWidth * 2, 0, ); - grd.addColorStop(0, '#2F4E6F'); - grd.addColorStop(0.5, '#3E6F8A'); - grd.addColorStop(1, '#4D80A4'); + BANGDREAM_SONG_CHART_PREVIEW_SPEC.trackGradientStops.forEach( + ({ color, offset }) => { + grd.addColorStop(offset, color); + }, + ); ctx.fillStyle = grd; ctx.fillRect( x - layout.splitLineWidth * 2, @@ -253,8 +287,7 @@ function drawBeatLines( } const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm); const { x, y } = getTimePosition(layout, currentTime); - const w = - BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth; + const w = BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, y); @@ -445,7 +478,7 @@ function drawSimNote( const lane = note.lane as number[]; lane.sort((a, b) => a - b); const simW = layout.laneWidth * (lane[1] - lane[0] - 1); - const simH = 2; + const simH = BANGDREAM_SONG_CHART_PREVIEW_SPEC.simLineHeight; const simStartX = layout.infoAreaWidth + drawCol * layout.originalWidth + diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec.ts new file mode 100644 index 0000000..6568172 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec.ts @@ -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 + ); +} diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart.ts index 6a17be2..b03e1c4 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart.ts @@ -3,6 +3,11 @@ import { Canvas, FontLibrary, loadImage } from 'skia-canvas'; import 'chartjs-adapter-moment'; import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; +import { + BANGDREAM_TIMELINE_CHART_SPEC, + getTimelineDisplayYMax, + getTimelineRawYMax, +} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec'; // 2. 注册 Chart.js 所有组件 Chart.register(...registerables); @@ -34,19 +39,15 @@ export async function drawTimeLineChart( { start, end, setStartToZero = false, data }: drawTimeLineChartOptions, displayLabel = false, ) { - const width = 800; - const height = 900; + const width = BANGDREAM_TIMELINE_CHART_SPEC.canvas.width; + const height = BANGDREAM_TIMELINE_CHART_SPEC.canvas.height; // 7. 创建 skia-canvas 实例 const canvas = new Canvas(width, height); const ctx = canvas.getContext('2d'); // 8. 计算 y 轴最大值 - const yMax = Math.max( - ...data.datasets.map((dataset: any) => - Math.max(...dataset.data.map((pt: any) => pt.y)), - ), - ); + const yMax = getTimelineRawYMax(data.datasets); // 9. 配置 Chart.js 选项 const options = { @@ -54,7 +55,7 @@ export async function drawTimeLineChart( legend: { labels: { font: { - size: 20, + size: BANGDREAM_TIMELINE_CHART_SPEC.legend.fontSize, }, }, display: displayLabel, @@ -64,15 +65,15 @@ export async function drawTimeLineChart( x: { type: 'time', time: { - unit: 'day', + unit: BANGDREAM_TIMELINE_CHART_SPEC.xAxis.unit, }, min: start, max: end, display: !setStartToZero, }, y: { - min: 0, - max: (yMax + 1000) * 1.1, + min: BANGDREAM_TIMELINE_CHART_SPEC.yAxis.min, + max: getTimelineDisplayYMax(yMax), }, }, }; @@ -83,8 +84,8 @@ export async function drawTimeLineChart( data, options: { ...options, - responsive: false, // 重要:关闭 Chart.js 自适应模式 - animation: false, + responsive: BANGDREAM_TIMELINE_CHART_SPEC.responsive, // 重要:关闭 Chart.js 自适应模式 + animation: BANGDREAM_TIMELINE_CHART_SPEC.animation, }, }; diff --git a/test/qqbot/plugins/bangDream/tsugu/chart-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/chart-spec.spec.ts new file mode 100644 index 0000000..94f73ce --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/chart-spec.spec.ts @@ -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'); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/data-block-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/data-block-spec.spec.ts new file mode 100644 index 0000000..5a2a1eb --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/data-block-spec.spec.ts @@ -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 }); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/degree-list-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/degree-list-spec.spec.ts new file mode 100644 index 0000000..0a4bf02 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/degree-list-spec.spec.ts @@ -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); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/gacha-list-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/gacha-list-spec.spec.ts new file mode 100644 index 0000000..9b7b414 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/gacha-list-spec.spec.ts @@ -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', + ); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/list-entity-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/list-entity-spec.spec.ts new file mode 100644 index 0000000..997d6ba --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/list-entity-spec.spec.ts @@ -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); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/list-player-ranking-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/list-player-ranking-spec.spec.ts new file mode 100644 index 0000000..50ec1f5 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/list-player-ranking-spec.spec.ts @@ -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, + }); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/list-time-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/list-time-spec.spec.ts new file mode 100644 index 0000000..2eeba90 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/list-time-spec.spec.ts @@ -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秒'); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/skill-text-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/skill-text-spec.spec.ts new file mode 100644 index 0000000..db45f7b --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/skill-text-spec.spec.ts @@ -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', + }, + ]); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/song-chart-preview-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/song-chart-preview-spec.spec.ts index f7ff224..06a0f9c 100644 --- a/test/qqbot/plugins/bangDream/tsugu/song-chart-preview-spec.spec.ts +++ b/test/qqbot/plugins/bangDream/tsugu/song-chart-preview-spec.spec.ts @@ -1,6 +1,7 @@ import { assignSongChartTimes, BestdoriNote, + BANGDREAM_SONG_CHART_PREVIEW_SPEC, createSongChartPreviewLayout, createSongChartPreviewModel, createSongChartPreviewNotes, @@ -102,4 +103,21 @@ describe('BangDream song chart preview spec', () => { expect(model.notes.some((note) => note.type === 'Single')).toBe(true); expect(model.layout.width).toBeGreaterThan(0); }); + + it('keeps the drawing-only panel and gradient specs stable', () => { + expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.infoOffset).toBe(8); + expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.coverInset).toBe(16); + expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.panel).toEqual({ + fontSize: 16, + height: 24, + maxWidth: 128, + width: 128, + }); + expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.simLineHeight).toBe(2); + expect(BANGDREAM_SONG_CHART_PREVIEW_SPEC.trackGradientStops).toEqual([ + { color: '#2F4E6F', offset: 0 }, + { color: '#3E6F8A', offset: 0.5 }, + { color: '#4D80A4', offset: 1 }, + ]); + }); });