refactor: 收口 BangDream 协议类型链路

This commit is contained in:
sunlei 2026-06-07 06:32:45 +08:00
parent 36c76ce959
commit 2c9a3e3e68
16 changed files with 143 additions and 119 deletions

View File

@ -299,6 +299,7 @@ export interface TsuguHook {
- 已新增 `cache-client-policy.spec.ts`,覆盖缓存客户端 retry/status/not-found 策略;`file-cache-client.spec.ts` 补 SVG 缺失资源 404 缓存,避免 SVG 素材缺失仍反复请求。 - 已新增 `cache-client-policy.spec.ts`,覆盖缓存客户端 retry/status/not-found 策略;`file-cache-client.spec.ts` 补 SVG 缺失资源 404 缓存,避免 SVG 素材缺失仍反复请求。
- 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 - 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。
- 已新增 `models/song-resource-repository.ts`,把 `Song` 的歌曲详情、谱面、封面路径、封面完整 URL 和封面缺失时服务器回退下载收口到不依赖 `Song` 类的资源 repository避免 `song.ts` 继续直接拼 Bestdori API/asset 路径;`song-resource-repository.spec.ts` 覆盖 detail/chart provider 调用、13/40 旧封面批次、273 强制 CN 和封面 fallback 行为,本地 `/查曲 136`、`/查谱面 136 expert` 图片 smoke 输出非空。 - 已新增 `models/song-resource-repository.ts`,把 `Song` 的歌曲详情、谱面、封面路径、封面完整 URL 和封面缺失时服务器回退下载收口到不依赖 `Song` 类的资源 repository避免 `song.ts` 继续直接拼 Bestdori API/asset 路径;`song-resource-repository.spec.ts` 覆盖 detail/chart provider 调用、13/40 旧封面批次、273 强制 CN 和封面 fallback 行为,本地 `/查曲 136`、`/查谱面 136 expert` 图片 smoke 输出非空。
- 收尾批量把 `Song.getSongChart`、`SongResourceRepository.getChart` 和 `drawBestdoriPreview` 的谱面协议改为 `BestdoriNote[]`,移除 `drawSongChart``as any``EventStage.getData` 透传 `EventStageDataRows<T>``stageType`/`rotationMusics` 改为 repository 行类型并用一次 `Promise.all` 加载;`readExcelFile` 改为泛型 `Record<string, unknown>` 输出;`asset-cache-client` 和 `server.ts` 清理参数命名与 `Array<any>` 残留,本地 `/查谱面 136 expert`、`/查试炼 310` smoke 输出非空。
- 已新增 `models/card-resource-repository.ts`,把 `Card` 的详情请求、资源批次目录、图标/插画/trim 图片路径和重图缓存策略收口到 provider-backed repository`card-resource-repository.spec.ts` 覆盖 `/api/cards` 缓存参数、`9999` 后资源批次、CN 优先资源路径和非 icon 图片 `memoryCache=false`,本地 `/查卡 472` 图片 smoke 输出非空。 - 已新增 `models/card-resource-repository.ts`,把 `Card` 的详情请求、资源批次目录、图标/插画/trim 图片路径和重图缓存策略收口到 provider-backed repository`card-resource-repository.spec.ts` 覆盖 `/api/cards` 缓存参数、`9999` 后资源批次、CN 优先资源路径和非 icon 图片 `memoryCache=false`,本地 `/查卡 472` 图片 smoke 输出非空。
- 已新增 `models/gacha-resource-repository.ts`,把 `Gacha` 的详情请求、首页横幅、screen 背景、`bg1` fallback 和 Logo 资源路径收口到 provider-backed repository`gacha-resource-repository.spec.ts` 覆盖 `/api/gacha` 缓存参数、CN 优先 screen 路径、横幅缺失回退 Logo 和背景 fallback本地 `/查卡池 259`、`/抽卡模拟 10 259` 图片 smoke 输出非空。 - 已新增 `models/gacha-resource-repository.ts`,把 `Gacha` 的详情请求、首页横幅、screen 背景、`bg1` fallback 和 Logo 资源路径收口到 provider-backed repository`gacha-resource-repository.spec.ts` 覆盖 `/api/gacha` 缓存参数、CN 优先 screen 路径、横幅缺失回退 Logo 和背景 fallback本地 `/查卡池 259`、`/抽卡模拟 10 259` 图片 smoke 输出非空。
- 已新增 `models/event-stage-data-repository.ts`,把 `EventStage` 的 festival stages/rotationMusics 数据请求从 `bestdoriUrl + callAPIAndCacheResponse` 收口到 provider-backed repository`event-stage-data-repository.spec.ts` 覆盖两个 festival API 路径和缓存参数,本地 `/查试炼 310` 保持拆成 5 张图片输出。 - 已新增 `models/event-stage-data-repository.ts`,把 `EventStage` 的 festival stages/rotationMusics 数据请求从 `bestdoriUrl + callAPIAndCacheResponse` 收口到 provider-backed repository`event-stage-data-repository.spec.ts` 覆盖两个 festival API 路径和缓存参数,本地 `/查试炼 310` 保持拆成 5 张图片输出。
@ -399,6 +400,7 @@ export interface TsuguHook {
- 已新增 `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 输出非空。 - 已批量新增 `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 输出非空。 - 已补充 `song-chart-preview-spec.ts` 的封面信息块偏移、ID/难度面板、轨道渐变和双押线高度规格,`song-chart-preview.ts` 不再在绘制函数中散落这些视觉常量;`song-chart-preview-spec.spec.ts` 覆盖新增规格,本地 `/查谱面 136 expert` 图片 smoke 输出非空。
- 收尾批量把谱面预览的绘图上下文改为 `CanvasRenderingContext2D``timeline-chart-spec.ts` 定义 `TimelineChartPoint/TimelineChartDataset``Cutoff.getChartData` 和 `CutoffEventTop.getChartData` 统一输出毫秒时间戳而不是 `Date` 对象;`drawTimeLineChart` 改用 typed `ChartConfiguration`,只在 skia-canvas 与 Chart.js 的 `ChartItem` 兼容边界保留显式 `unknown` 转换;同步修正 `labelName`、`cutoffEventTop`、`accuracy`、`constellationTextImage` 等拼写残留。本地 BangDream Tsugu 55 个 Jest suite / 186 个测试、`pnpm run typecheck`、scoped ESLint、`pnpm run build` 通过,`/查谱面 136 expert`、`/ycx 100 50 cn`、`/lsycx 100 50 cn`、`/查试炼 310` 图片 smoke 输出非空。
- smoke/Jenkins/远程调试卡点继续按已固化规则处理同一卡点第二次尝试前必须改变可验证变量Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录PowerShell 双引号 here-string 中不要写 JS `${...}` 模板字面量,避免被 PowerShell 提前插值smoke 没有真实图片落盘时必须失败。 - smoke/Jenkins/远程调试卡点继续按已固化规则处理同一卡点第二次尝试前必须改变可验证变量Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录PowerShell 双引号 here-string 中不要写 JS `${...}` 模板字面量,避免被 PowerShell 提前插值smoke 没有真实图片落盘时必须失败。
### Phase 6策略 policy 和时间/档线规则 ### Phase 6策略 policy 和时间/档线规则

View File

@ -168,11 +168,11 @@ export async function drawCharacterDetail(
key: '生日', key: '生日',
}); });
//星座 //星座
const constellationTextImafe = drawList({ const constellationTextImage = drawList({
text: constellationList[character.profile.constellation], text: constellationList[character.profile.constellation],
key: '星座', key: '星座',
}); });
list.push(drawListMerge([birthdayTextImage, constellationTextImafe])); list.push(drawListMerge([birthdayTextImage, constellationTextImage]));
list.push(line); list.push(line);
//身高 //身高
const heightTextImage = drawList({ const heightTextImage = drawList({

View File

@ -46,7 +46,7 @@ export async function drawSongChart(
diff: difficultyName[difficultyId], diff: difficultyName[difficultyId],
cover: song.getSongJacketImageURL(displayedServerList), cover: song.getSongJacketImageURL(displayedServerList),
}, },
songChart as any, songChart,
); );
let buffer: Buffer; let buffer: Buffer;

View File

@ -22,14 +22,14 @@ const memoryCache: { [url: string]: Buffer } = {};
* File * File
* *
* @param url - * @param url -
* @param IgnoreErr - IgnoreErr参数使 * @param ignoreError - ignoreError参数使
* @param overwrite - overwrite参数使 * @param overwrite - overwrite参数使
* @param retryCount - retryCount参数使 * @param retryCount - retryCount参数使
* @returns * @returns
*/ */
async function downloadFile( async function downloadFile(
url: string, url: string,
IgnoreErr: boolean = true, ignoreError: boolean = true,
overwrite = false, overwrite = false,
retryCount = 3, retryCount = 3,
): Promise<Buffer> { ): Promise<Buffer> {
@ -84,7 +84,7 @@ async function downloadFile(
errUrl[url] = Date.now(); errUrl[url] = Date.now();
} }
if ((url.includes('.png') || url.includes('.svg')) && IgnoreErr) { if ((url.includes('.png') || url.includes('.svg')) && ignoreError) {
return assetErrorImageBuffer; return assetErrorImageBuffer;
} }
@ -96,17 +96,17 @@ async function downloadFile(
* File缓存 * File缓存
* *
* @param url - * @param url -
* @param IgnoreErr - IgnoreErr参数使 * @param ignoreError - ignoreError参数使
* @returns * @returns
*/ */
async function downloadFileCache( async function downloadFileCache(
url: string, url: string,
IgnoreErr = true, ignoreError = true,
): Promise<Buffer> { ): Promise<Buffer> {
if (memoryCache[url]) { if (memoryCache[url]) {
return memoryCache[url]; return memoryCache[url];
} }
const data = await downloadFile(url, IgnoreErr); const data = await downloadFile(url, ignoreError);
if (process.env.MEMORY_CACHE == 'true') { if (process.env.MEMORY_CACHE == 'true') {
memoryCache[url] = data; memoryCache[url] = data;
} }

View File

@ -87,38 +87,38 @@ export class CutoffEventTop {
* @returns * @returns
*/ */
getChartData(setStartToZero = false): { getChartData(setStartToZero = false): {
[key: number]: { x: Date; y: number }[]; [key: number]: { x: number; y: number }[];
} { } {
if (this.isExist == false) { if (this.isExist == false) {
return; return;
} }
const chartDate: { [key: number]: { x: Date; y: number }[] } = {}; const chartDate: { [key: number]: { x: number; y: number }[] } = {};
for (let i = 0; i < this.points.length; i++) { for (let i = 0; i < this.points.length; i++) {
const element = this.points[i]; const element = this.points[i];
if (!(element.uid in chartDate)) { if (!(element.uid in chartDate)) {
chartDate[element.uid] = []; chartDate[element.uid] = [];
if (setStartToZero) { if (setStartToZero) {
chartDate[element.uid].push({ x: new Date(0), y: 0 }); chartDate[element.uid].push({ x: 0, y: 0 });
chartDate[element.uid].push({ chartDate[element.uid].push({
x: new Date(element.time - this.startAt), x: element.time - this.startAt,
y: element.value, y: element.value,
}); });
} else { } else {
chartDate[element.uid].push({ x: new Date(this.startAt), y: 0 }); chartDate[element.uid].push({ x: this.startAt, y: 0 });
chartDate[element.uid].push({ chartDate[element.uid].push({
x: new Date(element.time), x: element.time,
y: element.value, y: element.value,
}); });
} }
} else { } else {
if (setStartToZero) { if (setStartToZero) {
chartDate[element.uid].push({ chartDate[element.uid].push({
x: new Date(element.time - this.startAt), x: element.time - this.startAt,
y: element.value, y: element.value,
}); });
} else { } else {
chartDate[element.uid].push({ chartDate[element.uid].push({
x: new Date(element.time), x: element.time,
y: element.value, y: element.value,
}); });
} }

View File

@ -634,15 +634,15 @@ export class Cutoff {
* @param setStartToZero - setStartToZero参数使 * @param setStartToZero - setStartToZero参数使
* @returns * @returns
*/ */
getChartData(setStartToZero = false): { x: Date; y: number }[] { getChartData(setStartToZero = false): { x: number; y: number }[] {
if (this.isExist == false) { if (this.isExist == false) {
return []; return [];
} }
const chartData: { x: Date; y: number }[] = []; const chartData: { x: number; y: number }[] = [];
if (setStartToZero) { if (setStartToZero) {
chartData.push({ x: new Date(0), y: 0 }); chartData.push({ x: 0, y: 0 });
} else { } else {
chartData.push({ x: new Date(this.startAt), y: 0 }); chartData.push({ x: this.startAt, y: 0 });
} }
// 在访问 this.cutoffs[0].time 之前检查 this.cutoffs 是否存在且长度大于0 // 在访问 this.cutoffs[0].time 之前检查 this.cutoffs 是否存在且长度大于0
@ -655,11 +655,11 @@ export class Cutoff {
if (setStartToZero) { if (setStartToZero) {
// 确保 tempTime 不为 null 才执行减法操作 // 确保 tempTime 不为 null 才执行减法操作
chartData.push({ chartData.push({
x: tempTime ? new Date(element.time - this.startAt) : new Date(0), x: tempTime ? element.time - this.startAt : 0,
y: element.ep, y: element.ep,
}); });
} else { } else {
chartData.push({ x: new Date(element.time), y: element.ep }); chartData.push({ x: element.time, y: element.ep });
} }
tempTime = element.time; tempTime = element.time;
} }

View File

@ -7,6 +7,9 @@ import {
} from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants';
import { import {
eventStageDataRepository, eventStageDataRepository,
type EventStageDataRows,
type EventStageRotationMusicRow,
type EventStageTypeRow,
type EventStageDataType, type EventStageDataType,
} from '@/qqbot/plugins/bangDream/tsugu/models/event-stage-data-repository'; } from '@/qqbot/plugins/bangDream/tsugu/models/event-stage-data-repository';
@ -28,8 +31,8 @@ export class EventStage {
eventId: number; eventId: number;
isExist: boolean = false; isExist: boolean = false;
isInitFull = false; isInitFull = false;
stageType: Array<{ type: string; startAt: string; endAt: string }>; stageType: EventStageTypeRow[] = [];
rotationMusics: Array<{ musicId: string; startAt: string; endAt: string }>; rotationMusics: EventStageRotationMusicRow[] = [];
/** /**
* EventStage * EventStage
* *
@ -59,10 +62,12 @@ export class EventStage {
return; return;
} }
try { try {
const stageData = await this.getData(true, 'stages'); const [stageData, rotationMusicsData] = await Promise.all([
const rotationMusicsData = await this.getData(true, 'rotationMusics'); this.getData(true, 'stages'),
this.stageType = stageData as any; this.getData(true, 'rotationMusics'),
this.rotationMusics = rotationMusicsData as any; ]);
this.stageType = stageData;
this.rotationMusics = rotationMusicsData;
this.isInitFull = true; this.isInitFull = true;
} catch { } catch {
this.isExist = false; this.isExist = false;
@ -74,7 +79,10 @@ export class EventStage {
* @param update - update参数使 * @param update - update参数使
* @param type - * @param type -
*/ */
async getData(update: boolean = true, type: EventStageDataType) { async getData<T extends EventStageDataType>(
update: boolean = true,
type: T,
): Promise<EventStageDataRows<T>> {
return await eventStageDataRepository.getFestivalData( return await eventStageDataRepository.getFestivalData(
this.eventId, this.eventId,
type, type,
@ -88,25 +96,26 @@ export class EventStage {
* @returns * @returns
*/ */
getStageList(): Stage[] { getStageList(): Stage[] {
//获取所有的stage,并且按照时间排序 [{type,startAt,endAt,songIdList}]
if (!this.isInitFull) { if (!this.isInitFull) {
return; return [];
} }
const temp = {}; const groupedByStartAt: Record<
for (const i in this.rotationMusics) { string,
const tempStartAt = this.rotationMusics[i].startAt; { startAt: string; endAt: string; music: number[] }
if (temp[tempStartAt] == undefined) { > = {};
temp[tempStartAt] = { for (const rotationMusic of this.rotationMusics) {
startAt: this.rotationMusics[i].startAt, const tempStartAt = rotationMusic.startAt;
endAt: this.rotationMusics[i].endAt, if (groupedByStartAt[tempStartAt] == undefined) {
groupedByStartAt[tempStartAt] = {
startAt: rotationMusic.startAt,
endAt: rotationMusic.endAt,
music: [], music: [],
}; };
} }
temp[tempStartAt].music.push(this.rotationMusics[i].musicId); groupedByStartAt[tempStartAt].music.push(Number(rotationMusic.musicId));
} }
const tempStageList: Stage[] = []; const tempStageList: Stage[] = [];
for (const i in temp) { for (const element of Object.values(groupedByStartAt)) {
const element = temp[i];
const tempStartAt = parseInt(element.startAt); const tempStartAt = parseInt(element.startAt);
const tempEndAt = parseInt(element.endAt); const tempEndAt = parseInt(element.endAt);
const tempStageType = this.getStageTypeByTime(tempStartAt, tempEndAt); const tempStageType = this.getStageTypeByTime(tempStartAt, tempEndAt);
@ -114,10 +123,9 @@ export class EventStage {
type: tempStageType, type: tempStageType,
startAt: tempStartAt, startAt: tempStartAt,
endAt: tempEndAt, endAt: tempEndAt,
songIdList: temp[i].music, songIdList: element.music,
}); });
} }
//排序
tempStageList.sort((a, b) => { tempStageList.sort((a, b) => {
return a.startAt - b.startAt; return a.startAt - b.startAt;
}); });
@ -133,9 +141,8 @@ export class EventStage {
* @returns * @returns
*/ */
getStageTypeByTime(startAt: number, endAt: number): string { getStageTypeByTime(startAt: number, endAt: number): string {
//根据时间获取当前stage类型
if (!this.isInitFull) { if (!this.isInitFull) {
return; return BangDreamEventStageType.unknown;
} }
const stage = this.stageType.find((x) => { const stage = this.stageType.find((x) => {
const startTime = parseInt(x.startAt); const startTime = parseInt(x.startAt);

View File

@ -48,20 +48,14 @@ export async function writeJSON(filepath: string, data: object) {
* @param filePath - * @param filePath -
* @returns * @returns
*/ */
export async function readExcelFile(filePath: string): Promise<any[]> { export async function readExcelFile<
// 读取Excel文件 T extends Record<string, unknown> = Record<string, unknown>,
>(filePath: string): Promise<T[]> {
const workbook = XLSX.readFile(filePath); const workbook = XLSX.readFile(filePath);
// 获取工作表的名字
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
// 获取工作表
const worksheet = workbook.Sheets[sheetName]; const worksheet = workbook.Sheets[sheetName];
return XLSX.utils.sheet_to_json<T>(worksheet);
// 将工作表转换为JSON
const json = XLSX.utils.sheet_to_json(worksheet);
return json;
} }
//将string[]转变为number[] //将string[]转变为number[]

View File

@ -93,7 +93,7 @@ export async function getIcon(server: Server): Promise<Image> {
* @param displayedServerList - 使 * @param displayedServerList - 使
*/ */
export function getServerByPriority( export function getServerByPriority(
content: Array<any>, content: Array<unknown>,
displayedServerList: Server[] = globalDefaultServer, displayedServerList: Server[] = globalDefaultServer,
) { ) {
const serverPriority: Server[] = [ const serverPriority: Server[] = [
@ -114,13 +114,8 @@ export function getServerByPriority(
* @param server - * @param server -
* @returns * @returns
*/ */
export function isServer(server: any): boolean { export function isServer(server: unknown): boolean {
if (typeof server == 'number') { return typeof server === 'number' && serverList.includes(server);
server = Server[server];
} else {
return false;
}
return Object.keys(Server).includes(server);
} }
/** /**
@ -129,7 +124,7 @@ export function isServer(server: any): boolean {
* @param serverList - * @param serverList -
* @returns * @returns
*/ */
export function isServerList(serverList: Array<any>): boolean { export function isServerList(serverList: Array<unknown>): boolean {
let result = true; let result = true;
for (let i = 0; i < serverList.length; i++) { for (let i = 0; i < serverList.length; i++) {
const element = serverList[i]; const element = serverList[i];

View File

@ -9,6 +9,7 @@ import {
getServerByPriority, getServerByPriority,
Server, Server,
} from '@/qqbot/plugins/bangDream/tsugu/models/server'; } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import type { BestdoriNote } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec';
export interface SongJacketSource { export interface SongJacketSource {
jacketImage: Array<string>; jacketImage: Array<string>;
@ -50,8 +51,8 @@ export class SongResourceRepository {
* @param songId - ID * @param songId - ID
* @param difficultyId - ID * @param difficultyId - ID
*/ */
async getChart(songId: number, difficultyId: number): Promise<object> { async getChart(songId: number, difficultyId: number): Promise<BestdoriNote[]> {
return await this.provider.getJson<object>( return await this.provider.getJson<BestdoriNote[]>(
`/api/charts/${songId}/${difficultyNameById[difficultyId]}.json`, `/api/charts/${songId}/${difficultyNameById[difficultyId]}.json`,
); );
} }

View File

@ -9,6 +9,7 @@ import {
BANGDREAM_SONG_TAG_NAME, BANGDREAM_SONG_TAG_NAME,
} from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants';
import { songResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/song-resource-repository'; import { songResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/song-resource-repository';
import type { BestdoriNote } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec';
export const difficultyName: Record<number, string> = export const difficultyName: Record<number, string> =
BANGDREAM_DIFFICULTY_NAME_BY_ID; BANGDREAM_DIFFICULTY_NAME_BY_ID;
@ -248,7 +249,7 @@ export class Song {
* @param difficultyId - ID参数 * @param difficultyId - ID参数
* @returns * @returns
*/ */
async getSongChart(difficultyId: number): Promise<object> { async getSongChart(difficultyId: number): Promise<BestdoriNote[]> {
return await songResourceRepository.getChart(this.songId, difficultyId); return await songResourceRepository.getChart(this.songId, difficultyId);
} }
@ -272,7 +273,7 @@ export class Song {
* @param difficultyId - ID参数 * @param difficultyId - ID参数
* @param scoreUpMaxValue - UpMax值参数使 * @param scoreUpMaxValue - UpMax值参数使
* @param skillDuration - Duration参数使 * @param skillDuration - Duration参数使
* @param accruacy - accruacy参数使 * @param accuracy - accuracy参数使
* @returns * @returns
*/ */
calcMeta( calcMeta(
@ -280,7 +281,7 @@ export class Song {
difficultyId: number, difficultyId: number,
scoreUpMaxValue: number = 100, scoreUpMaxValue: number = 100,
skillDuration: number = 7, skillDuration: number = 7,
accruacy: number = 100, accuracy: number = 100,
): number { ): number {
if (this.hasMeta == false) { if (this.hasMeta == false) {
return 0; return 0;
@ -298,7 +299,7 @@ export class Song {
this.meta[difficultyId][skillDuration][1]; this.meta[difficultyId][skillDuration][1];
} }
const scoreParameter = const scoreParameter =
skillParameter * ((1.1 * accruacy) / 100 + 0.8 * (1 - accruacy / 100)); skillParameter * ((1.1 * accuracy) / 100 + 0.8 * (1 - accuracy / 100));
return scoreParameter; return scoreParameter;
} }
} }

View File

@ -12,6 +12,10 @@ import {
BANGDREAM_CUTOFF_CHART_SPEC, BANGDREAM_CUTOFF_CHART_SPEC,
stripCutoffChartLabelTags, stripCutoffChartLabelTags,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart-spec'; } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/cutoff-chart-spec';
import type {
TimelineChartDataset,
TimelineChartPoint,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec';
/** /**
* 线 * 线
@ -26,7 +30,7 @@ export async function drawCutoffChart(
server: Server = Server['jp'], server: Server = Server['jp'],
) { ) {
//setStartToZero:是否将开始时间设置为0 //setStartToZero:是否将开始时间设置为0
const datasets = []; const datasets: TimelineChartDataset[] = [];
const time = new Date().getTime(); const time = new Date().getTime();
if (cutoffList.length == 0) { if (cutoffList.length == 0) {
return new Canvas( return new Canvas(
@ -44,9 +48,9 @@ export async function drawCutoffChart(
const cutoff = cutoffList[i]; const cutoff = cutoffList[i];
const tempEvent = new Event(cutoff.eventId); const tempEvent = new Event(cutoff.eventId);
let lableName: string; let labelName: string;
if (setStartToZero) { if (setStartToZero) {
lableName = `[${tempEvent.eventId}] ${tempEvent.eventName[server]} T${cutoff.tier}`; labelName = `[${tempEvent.eventId}] ${tempEvent.eventName[server]} T${cutoff.tier}`;
list.push( list.push(
drawList({ drawList({
content: [ content: [
@ -59,7 +63,7 @@ export async function drawCutoffChart(
}), }),
); );
} else { } else {
lableName = `T${cutoff.tier}`; labelName = `T${cutoff.tier}`;
list.push( list.push(
drawList({ drawList({
content: [ content: [
@ -73,7 +77,7 @@ export async function drawCutoffChart(
); );
} }
datasets.push({ datasets.push({
label: lableName, label: labelName,
data: cutoff.getChartData(setStartToZero), data: cutoff.getChartData(setStartToZero),
borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth, borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth,
borderColor: [tempColor.getRGBA(1)], borderColor: [tempColor.getRGBA(1)],
@ -89,39 +93,39 @@ export async function drawCutoffChart(
if (cutoff.status == BangDreamEventStatus.inProgress) { if (cutoff.status == BangDreamEventStatus.inProgress) {
if (cutoff.predictEP != null && cutoff.predictEP != 0) { if (cutoff.predictEP != null && cutoff.predictEP != 0) {
let data = []; let data: TimelineChartPoint[] = [];
const history = cutoff.getPredictionHistory(); const history = cutoff.getPredictionHistory();
if (history.length > 0) { if (history.length > 0) {
if (setStartToZero) { if (setStartToZero) {
for (const p of history) { for (const p of history) {
data.push({ x: new Date(p.time - cutoff.startAt), y: p.ep }); data.push({ x: p.time - cutoff.startAt, y: p.ep });
} }
data.push({ data.push({
x: new Date(cutoff.endAt - cutoff.startAt), x: cutoff.endAt - cutoff.startAt,
y: history[history.length - 1].ep, y: history[history.length - 1].ep,
}); });
} else { } else {
for (const p of history) { for (const p of history) {
data.push({ x: new Date(p.time), y: p.ep }); data.push({ x: p.time, y: p.ep });
} }
data.push({ data.push({
x: new Date(cutoff.endAt), x: cutoff.endAt,
y: history[history.length - 1].ep, y: history[history.length - 1].ep,
}); });
} }
} else { } else {
if (setStartToZero) { if (setStartToZero) {
data = [ data = [
{ x: new Date(0), y: cutoff.predictEP }, { x: 0, y: cutoff.predictEP },
{ {
x: new Date(cutoff.endAt - cutoff.startAt), x: cutoff.endAt - cutoff.startAt,
y: cutoff.predictEP, y: cutoff.predictEP,
}, },
]; ];
} else { } else {
data = [ data = [
{ x: new Date(cutoff.startAt), y: cutoff.predictEP }, { x: cutoff.startAt, y: cutoff.predictEP },
{ x: new Date(cutoff.endAt), y: cutoff.predictEP }, { x: cutoff.endAt, y: cutoff.predictEP },
]; ];
} }
} }
@ -140,7 +144,9 @@ export async function drawCutoffChart(
], ],
data: data, data: data,
borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth, borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.borderWidth,
borderDash: BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionDash, borderDash: [
...BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionDash,
],
fill: false, fill: false,
pointRadius: pointRadius:
BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionPointRadius, BANGDREAM_CUTOFF_CHART_SPEC.lineDataset.predictionPointRadius,
@ -157,7 +163,7 @@ export async function drawCutoffChart(
label: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.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: time, y: 0 }],
fill: false, fill: false,
pointRadius: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.pointRadius, pointRadius: BANGDREAM_CUTOFF_CHART_SPEC.currentTimeDataset.pointRadius,
pointHoverRadius: pointHoverRadius:
@ -204,27 +210,27 @@ export async function drawCutoffChart(
/** /**
* 线 * 线
* *
* @param CutoffEventTop - 线 * @param cutoffEventTop - 线
* @param setStartToZero - setStartToZero参数使 * @param setStartToZero - setStartToZero参数使
*/ */
export async function drawCutoffEventTopChart( export async function drawCutoffEventTopChart(
CutoffEventTop: CutoffEventTop, cutoffEventTop: CutoffEventTop,
setStartToZero = false, setStartToZero = false,
) { ) {
const datasets = []; const datasets: TimelineChartDataset[] = [];
if (CutoffEventTop == undefined) { if (cutoffEventTop == undefined) {
return new Canvas( return new Canvas(
BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.width, BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.width,
BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.height, BANGDREAM_CUTOFF_CHART_SPEC.emptyCanvas.height,
); );
} }
const allData = CutoffEventTop.getChartData(); const allData = cutoffEventTop.getChartData();
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: stripCutoffChartLabelTags( label: stripCutoffChartLabelTags(
CutoffEventTop.getUserNameById(Number(key)), cutoffEventTop.getUserNameById(Number(key)),
), ),
data: allData[key], data: allData[key],
borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.borderWidth, borderWidth: BANGDREAM_CUTOFF_CHART_SPEC.eventTopDataset.borderWidth,
@ -249,8 +255,8 @@ export async function drawCutoffEventTopChart(
return await drawTimeLineChart( return await drawTimeLineChart(
{ {
data, data,
start: new Date(CutoffEventTop.startAt), start: new Date(cutoffEventTop.startAt),
end: new Date(CutoffEventTop.endAt), end: new Date(cutoffEventTop.endAt),
setStartToZero, setStartToZero,
}, },
true, true,

View File

@ -1,4 +1,5 @@
import { Canvas, Image, loadImage } from 'skia-canvas'; import { Canvas, Image, loadImage } from 'skia-canvas';
import type { CanvasRenderingContext2D } from 'skia-canvas';
import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { loadImageFromPath } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils';
import { import {
BangDreamLocalAssetKey, BangDreamLocalAssetKey,
@ -96,7 +97,7 @@ async function loadCoverImage(cover: string | Buffer): Promise<Image> {
* @param y - * @param y -
*/ */
function setAdaptiveTextBaseline( function setAdaptiveTextBaseline(
ctx: any, ctx: CanvasRenderingContext2D,
layout: PreviewLayout, layout: PreviewLayout,
fontSize: number, fontSize: number,
y: number, y: number,
@ -135,7 +136,7 @@ function getTimePosition(layout: PreviewLayout, time: number) {
* @param coverImg - coverImg参数 * @param coverImg - coverImg参数
*/ */
function drawBaseInfo( function drawBaseInfo(
ctx: any, ctx: CanvasRenderingContext2D,
layout: PreviewLayout, layout: PreviewLayout,
payload: BestdoriPreviewPayload, payload: BestdoriPreviewPayload,
coverImg: Image, coverImg: Image,
@ -218,7 +219,7 @@ function drawBaseInfo(
* @param ctx - * @param ctx -
* @param layout - * @param layout -
*/ */
function drawTracks(ctx: any, layout: PreviewLayout): void { function drawTracks(ctx: CanvasRenderingContext2D, layout: PreviewLayout): void {
for (let i = 0; i < layout.colCount; i++) { for (let i = 0; i < layout.colCount; i++) {
ctx.save(); ctx.save();
const x = const x =
@ -259,7 +260,7 @@ function drawTracks(ctx: any, layout: PreviewLayout): void {
* @param notes - * @param notes -
*/ */
function drawBeatLines( function drawBeatLines(
ctx: any, ctx: CanvasRenderingContext2D,
layout: PreviewLayout, layout: PreviewLayout,
notes: PreviewNote[], notes: PreviewNote[],
): void { ): void {
@ -309,7 +310,10 @@ function drawBeatLines(
* @param ctx - * @param ctx -
* @param layout - * @param layout -
*/ */
function drawTimeline(ctx: any, layout: PreviewLayout): void { function drawTimeline(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
): void {
ctx.save(); ctx.save();
ctx.font = `18px "${BANGDREAM_RENDER_THEME.font.chart}"`; ctx.font = `18px "${BANGDREAM_RENDER_THEME.font.chart}"`;
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText; ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
@ -330,7 +334,7 @@ function drawTimeline(ctx: any, layout: PreviewLayout): void {
* @param notes - * @param notes -
*/ */
function drawCountAndBpmLines( function drawCountAndBpmLines(
ctx: any, ctx: CanvasRenderingContext2D,
layout: PreviewLayout, layout: PreviewLayout,
notes: PreviewNote[], notes: PreviewNote[],
): void { ): void {
@ -376,7 +380,7 @@ function drawCountAndBpmLines(
* @param note - * @param note -
*/ */
function drawTapNote( function drawTapNote(
ctx: any, ctx: CanvasRenderingContext2D,
layout: PreviewLayout, layout: PreviewLayout,
noteImages: Record<string, Image>, noteImages: Record<string, Image>,
note: PreviewNote, note: PreviewNote,
@ -417,7 +421,7 @@ function drawTapNote(
* @param note - * @param note -
*/ */
function drawDirectionalNote( function drawDirectionalNote(
ctx: any, ctx: CanvasRenderingContext2D,
layout: PreviewLayout, layout: PreviewLayout,
noteImages: Record<string, Image>, noteImages: Record<string, Image>,
note: PreviewNote, note: PreviewNote,
@ -469,7 +473,7 @@ function drawDirectionalNote(
* @param note - * @param note -
*/ */
function drawSimNote( function drawSimNote(
ctx: any, ctx: CanvasRenderingContext2D,
layout: PreviewLayout, layout: PreviewLayout,
noteImages: Record<string, Image>, noteImages: Record<string, Image>,
note: PreviewNote, note: PreviewNote,
@ -498,7 +502,11 @@ function drawSimNote(
* @param layout - * @param layout -
* @param note - * @param note -
*/ */
function drawBarNote(ctx: any, layout: PreviewLayout, note: PreviewNote): void { function drawBarNote(
ctx: CanvasRenderingContext2D,
layout: PreviewLayout,
note: PreviewNote,
): void {
const time = note.time as number[]; const time = note.time as number[];
const lane = note.lane as number[]; const lane = note.lane as number[];
const startCol = Math.floor(time[0] / layout.secondsPerCol); const startCol = Math.floor(time[0] / layout.secondsPerCol);
@ -543,7 +551,7 @@ function drawBarNote(ctx: any, layout: PreviewLayout, note: PreviewNote): void {
* @param notes - * @param notes -
*/ */
function drawNotes( function drawNotes(
ctx: any, ctx: CanvasRenderingContext2D,
layout: PreviewLayout, layout: PreviewLayout,
noteImages: Record<string, Image>, noteImages: Record<string, Image>,
notes: PreviewNote[], notes: PreviewNote[],

View File

@ -1,3 +1,5 @@
import type { ChartDataset } from 'chart.js';
export const BANGDREAM_TIMELINE_CHART_SPEC = { export const BANGDREAM_TIMELINE_CHART_SPEC = {
animation: false, animation: false,
canvas: { canvas: {
@ -18,14 +20,19 @@ export const BANGDREAM_TIMELINE_CHART_SPEC = {
}, },
} as const; } as const;
export interface TimelineChartPoint {
x: number;
y: number;
}
export type TimelineChartDataset = ChartDataset<'line', TimelineChartPoint[]>;
/** /**
* 线 Y * 线 Y
* *
* @param datasets - Chart.js * @param datasets - Chart.js
*/ */
export function getTimelineRawYMax( export function getTimelineRawYMax(datasets: TimelineChartDataset[]) {
datasets: Array<{ data: Array<{ y: number }> }>,
) {
return Math.max( return Math.max(
...datasets.map((dataset) => Math.max(...dataset.data.map((pt) => pt.y))), ...datasets.map((dataset) => Math.max(...dataset.data.map((pt) => pt.y))),
); );

View File

@ -1,4 +1,5 @@
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import type { ChartConfiguration, ChartItem } from 'chart.js';
import { Canvas, FontLibrary, loadImage } from 'skia-canvas'; 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';
@ -7,6 +8,8 @@ import {
BANGDREAM_TIMELINE_CHART_SPEC, BANGDREAM_TIMELINE_CHART_SPEC,
getTimelineDisplayYMax, getTimelineDisplayYMax,
getTimelineRawYMax, getTimelineRawYMax,
type TimelineChartDataset,
type TimelineChartPoint,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec'; } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/timeline-chart-spec';
// 2. 注册 Chart.js 所有组件 // 2. 注册 Chart.js 所有组件
@ -19,12 +22,12 @@ Chart.register(...registerables);
FontLibrary.use('old', [`${assetsRootPath}/Fonts/old.ttf`]); FontLibrary.use('old', [`${assetsRootPath}/Fonts/old.ttf`]);
// 5. 定义参数接口 // 5. 定义参数接口
interface drawTimeLineChartOptions { interface DrawTimeLineChartOptions {
start: Date; start: Date;
end: Date; end: Date;
setStartToZero?: boolean; setStartToZero?: boolean;
data: { data: {
datasets: any[]; datasets: TimelineChartDataset[];
}; };
} }
@ -36,7 +39,7 @@ interface drawTimeLineChartOptions {
* @param displayLabel - Label参数使 * @param displayLabel - Label参数使
*/ */
export async function drawTimeLineChart( export async function drawTimeLineChart(
{ start, end, setStartToZero = false, data }: drawTimeLineChartOptions, { start, end, setStartToZero = false, data }: DrawTimeLineChartOptions,
displayLabel = false, displayLabel = false,
) { ) {
const width = BANGDREAM_TIMELINE_CHART_SPEC.canvas.width; const width = BANGDREAM_TIMELINE_CHART_SPEC.canvas.width;
@ -63,12 +66,12 @@ export async function drawTimeLineChart(
}, },
scales: { scales: {
x: { x: {
type: 'time', type: 'time' as const,
time: { time: {
unit: BANGDREAM_TIMELINE_CHART_SPEC.xAxis.unit, unit: BANGDREAM_TIMELINE_CHART_SPEC.xAxis.unit,
}, },
min: start, min: start.getTime(),
max: end, max: end.getTime(),
display: !setStartToZero, display: !setStartToZero,
}, },
y: { y: {
@ -79,7 +82,7 @@ export async function drawTimeLineChart(
}; };
// 10. Chart.js 配置 // 10. Chart.js 配置
const config = { const config: ChartConfiguration<'line', TimelineChartPoint[]> = {
type: 'line' as const, type: 'line' as const,
data, data,
options: { options: {
@ -91,7 +94,7 @@ export async function drawTimeLineChart(
try { try {
// 11. 生成 Chart.js 图表 // 11. 生成 Chart.js 图表
new Chart(ctx as any, config as any); new Chart(ctx as unknown as ChartItem, config);
// 12. 返回 skia-canvas 的 Image 对象 // 12. 返回 skia-canvas 的 Image 对象
return canvas; return canvas;

View File

@ -16,8 +16,8 @@ describe('BangDream chart specs', () => {
}); });
expect( expect(
getTimelineRawYMax([ getTimelineRawYMax([
{ data: [{ y: 100 }, { y: 300 }] }, { data: [{ x: 0, y: 100 }, { x: 1, y: 300 }] },
{ data: [{ y: 200 }] }, { data: [{ x: 0, y: 200 }] },
]), ]),
).toBe(300); ).toBe(300);
expect(getTimelineDisplayYMax(300)).toBeCloseTo(1430); expect(getTimelineDisplayYMax(300)).toBeCloseTo(1430);