refactor: 收口 BangDream 谱面预览策略
This commit is contained in:
parent
46f6a2465c
commit
c726cad320
@ -14,7 +14,7 @@
|
|||||||
## 当前事实
|
## 当前事实
|
||||||
|
|
||||||
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
|
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
|
||||||
- TS 文件:92
|
- TS 文件:初始基线 92;当前 `tsugu` 源码 124
|
||||||
- 函数节点:481,其中稳定函数 410,匿名/内联回调 71
|
- 函数节点:481,其中稳定函数 410,匿名/内联回调 71
|
||||||
- 源码 JSDoc:稳定函数 410/410 已覆盖
|
- 源码 JSDoc:稳定函数 410/410 已覆盖
|
||||||
- 变量声明:1896
|
- 变量声明:1896
|
||||||
@ -350,6 +350,8 @@ export interface TsuguHook {
|
|||||||
- 卡牌详情页已接入 `DetailBlockBuilder`,卡牌标题、插画、基础字段、缩略图和演出缩略图不再手写数组与分割线;`/查卡 472` 本地 smoke 输出非空长图。
|
- 卡牌详情页已接入 `DetailBlockBuilder`,卡牌标题、插画、基础字段、缩略图和演出缩略图不再手写数组与分割线;`/查卡 472` 本地 smoke 输出非空长图。
|
||||||
- `scripts/bangdream-render-smoke.ps1` 已固化图片 smoke 完成判定:先删除旧目标图,轮询 stdout 成功 JSON 和新图片文件;如果 Tsugu 后台 handle 导致 Node 未自然退出,则在图片落盘后清理本次进程并返回成功,避免已成功出图仍卡到超时。
|
- `scripts/bangdream-render-smoke.ps1` 已固化图片 smoke 完成判定:先删除旧目标图,轮询 stdout 成功 JSON 和新图片文件;如果 Tsugu 后台 handle 导致 Node 未自然退出,则在图片落盘后清理本次进程并返回成功,避免已成功出图仍卡到超时。
|
||||||
- 线上 `/qqbot/command/test` smoke 已固化为先按 `operationKey` 查询启用命令、传 `commandId`,并保留完整命令文本;避免默认 `preview` selfId 未绑定命令时误报“未匹配到命令”。
|
- 线上 `/qqbot/command/test` smoke 已固化为先按 `operationKey` 查询启用命令、传 `commandId`,并保留完整命令文本;避免默认 `preview` selfId 未绑定命令时误报“未匹配到命令”。
|
||||||
|
- 已新增 `render-blocks/song-chart-preview-spec.ts`,把谱面 BPM 时间计算、双押识别、滑条拆分、音符排序、展示/计数音符分类、难度颜色和布局规格从 `song-chart-preview.ts` 拆成可测纯策略;绘制文件改为消费 `createSongChartPreviewModel`,只保留资源加载和 canvas 绘制。
|
||||||
|
- 已新增 `song-chart-preview-spec.spec.ts`,覆盖 BPM 变速时间、十六分单点、双押、滑条 bar/tick/end、布局列数和音符分类;本地生成 `song-chart-preview-spec-136-expert.jpg`,验证谱面预览重构后图片输出非空且视觉结构正常。
|
||||||
|
|
||||||
### Phase 6:策略 policy 和时间/档线规则
|
### Phase 6:策略 policy 和时间/档线规则
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,471 @@
|
|||||||
|
export interface BestdoriConnection {
|
||||||
|
beat: number;
|
||||||
|
lane: number;
|
||||||
|
time?: number;
|
||||||
|
skill?: boolean;
|
||||||
|
flick?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestdoriNote {
|
||||||
|
type: string;
|
||||||
|
beat: number;
|
||||||
|
lane?: number;
|
||||||
|
time?: number | number[];
|
||||||
|
bpm?: number;
|
||||||
|
connections?: BestdoriConnection[];
|
||||||
|
skill?: boolean;
|
||||||
|
flick?: boolean;
|
||||||
|
direction?: 'Left' | 'Right';
|
||||||
|
width?: number;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreviewNote = Omit<BestdoriNote, 'time' | 'lane'> & {
|
||||||
|
type: string;
|
||||||
|
time: number | number[];
|
||||||
|
lane: number | number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PreviewLayout {
|
||||||
|
infoAreaWidth: number;
|
||||||
|
laneWidth: number;
|
||||||
|
splitLineWidth: number;
|
||||||
|
blockDistance: number;
|
||||||
|
heightPerSecond: number;
|
||||||
|
originalWidth: number;
|
||||||
|
chartLength: number;
|
||||||
|
secondsPerCol: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
colCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BANGDREAM_SONG_CHART_PREVIEW_SPEC = {
|
||||||
|
aspectRatioLimit: 16 / 9,
|
||||||
|
blockDistance: 72,
|
||||||
|
heightPerSecond: 216,
|
||||||
|
infoAreaWidth: 240,
|
||||||
|
laneCount: 7,
|
||||||
|
laneWidth: 32,
|
||||||
|
minHeight: 500,
|
||||||
|
noteEndPaddingSeconds: 0.25,
|
||||||
|
splitLineWidth: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES = [
|
||||||
|
'Single',
|
||||||
|
'SingleOff',
|
||||||
|
'Skill',
|
||||||
|
'Flick',
|
||||||
|
'Directional',
|
||||||
|
'Long',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BANGDREAM_SONG_CHART_COUNT_LINE_NOTE_TYPES = [
|
||||||
|
'Single',
|
||||||
|
'SingleOff',
|
||||||
|
'Flick',
|
||||||
|
'Long',
|
||||||
|
'Skill',
|
||||||
|
'Tick',
|
||||||
|
'Directional',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BANGDREAM_SONG_CHART_DIFFICULTY_COLORS: Record<string, string> = {
|
||||||
|
easy: 'rgb(87, 192, 201)',
|
||||||
|
expert: 'rgb(199, 96, 96)',
|
||||||
|
hard: 'rgb(239, 161, 25)',
|
||||||
|
normal: 'rgb(138, 201, 87)',
|
||||||
|
special: 'rgb(195, 96, 199)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREVIEW_NOTE_TYPE_SORT: Record<string, number> = {
|
||||||
|
Bar: -2,
|
||||||
|
Sim: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断谱面音符是否参与谱面长度计算。
|
||||||
|
*
|
||||||
|
* @param type - 预览音符类型。
|
||||||
|
*/
|
||||||
|
export function isSongChartDisplayNoteType(type: string): boolean {
|
||||||
|
return BANGDREAM_SONG_CHART_DISPLAY_NOTE_TYPES.includes(type as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断谱面音符是否参与计数线绘制。
|
||||||
|
*
|
||||||
|
* @param type - 预览音符类型。
|
||||||
|
*/
|
||||||
|
export function isSongChartCountLineNoteType(type: string): boolean {
|
||||||
|
return BANGDREAM_SONG_CHART_COUNT_LINE_NOTE_TYPES.includes(type as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中按节拍排序 BPM 时间点并写入累计时间。
|
||||||
|
*
|
||||||
|
* @param timepoints - BPM 时间点列表。
|
||||||
|
* @returns 处理后的列表。
|
||||||
|
*/
|
||||||
|
function sortTimepoints(timepoints: BestdoriNote[]): BestdoriNote[] {
|
||||||
|
timepoints.sort((a, b) => a.beat - b.beat);
|
||||||
|
for (let i = 0; i < timepoints.length; i++) {
|
||||||
|
const current = timepoints[i];
|
||||||
|
if (i === 0) {
|
||||||
|
current.time = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = timepoints[i - 1];
|
||||||
|
current.time =
|
||||||
|
(previous.time as number) +
|
||||||
|
(current.beat - previous.beat) * (60 / previous.bpm);
|
||||||
|
}
|
||||||
|
return timepoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中用二分查找定位节拍所在的 BPM 时间点。
|
||||||
|
*
|
||||||
|
* @param timepoints - BPM 时间点列表。
|
||||||
|
* @param beat - 谱面节拍位置。
|
||||||
|
* @returns 处理结果。
|
||||||
|
*/
|
||||||
|
function findTimepointAtBeat(
|
||||||
|
timepoints: BestdoriNote[],
|
||||||
|
beat: number,
|
||||||
|
): BestdoriNote {
|
||||||
|
let left = 0;
|
||||||
|
let right = timepoints.length - 1;
|
||||||
|
let result = timepoints[0];
|
||||||
|
|
||||||
|
while (left <= right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
if (timepoints[mid].beat > beat) {
|
||||||
|
right = mid - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result = timepoints[mid];
|
||||||
|
left = mid + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中根据 BPM 时间点计算谱面音符时间。
|
||||||
|
*
|
||||||
|
* @param timepoints - BPM 时间点列表。
|
||||||
|
* @param beat - 谱面节拍位置。
|
||||||
|
* @returns 计算后的数值。
|
||||||
|
*/
|
||||||
|
function getNoteTime(timepoints: BestdoriNote[], beat: number): number {
|
||||||
|
const timepoint = findTimepointAtBeat(timepoints, beat);
|
||||||
|
return (
|
||||||
|
(timepoint.time as number) + (60 / timepoint.bpm) * (beat - timepoint.beat)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中为谱面音符写入实际时间。
|
||||||
|
*
|
||||||
|
* @param chart - 谱面音符数据。
|
||||||
|
* @returns 处理后的 BPM 时间点列表。
|
||||||
|
*/
|
||||||
|
export function assignSongChartTimes(chart: BestdoriNote[]): BestdoriNote[] {
|
||||||
|
const timepoints = sortTimepoints(
|
||||||
|
chart.filter((note) => note.type === 'BPM'),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const note of chart) {
|
||||||
|
if (note.type === 'Long' || note.type === 'Slide') {
|
||||||
|
for (const connection of note.connections ?? []) {
|
||||||
|
connection.time = getNoteTime(timepoints, connection.beat);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (note.type !== 'BPM') {
|
||||||
|
note.time = getNoteTime(timepoints, note.beat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return timepoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中为同拍音符补充双押标记。
|
||||||
|
*
|
||||||
|
* @param notes - 谱面音符列表。
|
||||||
|
* @param beat - 谱面节拍位置。
|
||||||
|
* @param time - 谱面时间点。
|
||||||
|
* @param lane - 轨道位置。
|
||||||
|
*/
|
||||||
|
function addSimNote(
|
||||||
|
notes: PreviewNote[],
|
||||||
|
beat: number,
|
||||||
|
time: number,
|
||||||
|
lane: number,
|
||||||
|
): void {
|
||||||
|
for (const note of notes) {
|
||||||
|
if (note.beat === beat && note.lane === lane) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
['Single', 'Flick', 'Skill', 'Long', 'Directional'].includes(note.type) &&
|
||||||
|
note.beat === beat
|
||||||
|
) {
|
||||||
|
notes.push({
|
||||||
|
beat,
|
||||||
|
lane: [note.lane as number, lane].sort((a, b) => a - b),
|
||||||
|
time,
|
||||||
|
type: 'Sim',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中识别单点音符的展示类型。
|
||||||
|
*
|
||||||
|
* @param note - 谱面音符。
|
||||||
|
* @returns 预览音符类型。
|
||||||
|
*/
|
||||||
|
function getSingleNoteType(note: BestdoriNote): string {
|
||||||
|
if (note.flick) {
|
||||||
|
return 'Flick';
|
||||||
|
}
|
||||||
|
if (note.skill) {
|
||||||
|
return 'Skill';
|
||||||
|
}
|
||||||
|
if (note.beat % 0.5 !== 0) {
|
||||||
|
return 'SingleOff';
|
||||||
|
}
|
||||||
|
return 'Single';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中把滑条连接点拆成可绘制音符。
|
||||||
|
*
|
||||||
|
* @param notes - 谱面音符列表。
|
||||||
|
* @param note - 谱面音符。
|
||||||
|
*/
|
||||||
|
function pushSlideNotes(notes: PreviewNote[], note: BestdoriNote): void {
|
||||||
|
const barTime: number[] = [];
|
||||||
|
const lane: number[] = [];
|
||||||
|
const connections = note.connections ?? [];
|
||||||
|
|
||||||
|
for (let i = 0; i < connections.length; i++) {
|
||||||
|
const tick = connections[i];
|
||||||
|
const time = tick.time;
|
||||||
|
const firstTick = i === 0;
|
||||||
|
const lastTick = i === connections.length - 1;
|
||||||
|
|
||||||
|
barTime.push(time);
|
||||||
|
lane.push(tick.lane);
|
||||||
|
|
||||||
|
if (!firstTick) {
|
||||||
|
notes.push({
|
||||||
|
beat: tick.beat,
|
||||||
|
lane: [lane[0], lane[1]],
|
||||||
|
time: [barTime[0], barTime[1]],
|
||||||
|
type: 'Bar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstTick || lastTick) {
|
||||||
|
notes.push({
|
||||||
|
...tick,
|
||||||
|
lane: tick.lane,
|
||||||
|
time,
|
||||||
|
type: firstTick
|
||||||
|
? tick.skill
|
||||||
|
? 'Skill'
|
||||||
|
: 'Long'
|
||||||
|
: tick.flick
|
||||||
|
? 'Flick'
|
||||||
|
: tick.skill
|
||||||
|
? 'Skill'
|
||||||
|
: 'Long',
|
||||||
|
});
|
||||||
|
addSimNote(notes, tick.beat, time, tick.lane);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lane.shift();
|
||||||
|
barTime.shift();
|
||||||
|
if (!tick.hidden) {
|
||||||
|
notes.push({ ...tick, lane: tick.lane, time, type: 'Tick' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中把可游玩音符转换为预览音符。
|
||||||
|
*
|
||||||
|
* @param notes - 谱面音符列表。
|
||||||
|
* @param note - 谱面音符。
|
||||||
|
*/
|
||||||
|
function pushPlayableNote(notes: PreviewNote[], note: BestdoriNote): void {
|
||||||
|
if (note.type === 'Single') {
|
||||||
|
const typedNote = {
|
||||||
|
...note,
|
||||||
|
lane: note.lane,
|
||||||
|
time: note.time as number,
|
||||||
|
type: getSingleNoteType(note),
|
||||||
|
} as PreviewNote;
|
||||||
|
notes.push(typedNote);
|
||||||
|
addSimNote(notes, note.beat, typedNote.time as number, note.lane);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.type === 'Directional') {
|
||||||
|
notes.push({
|
||||||
|
...note,
|
||||||
|
lane: note.lane,
|
||||||
|
time: note.time as number,
|
||||||
|
} as PreviewNote);
|
||||||
|
addSimNote(notes, note.beat, note.time as number, note.lane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中获取排序轨道。
|
||||||
|
*
|
||||||
|
* @param note - 谱面音符。
|
||||||
|
* @returns 计算后的数值。
|
||||||
|
*/
|
||||||
|
function getSortLane(note: PreviewNote): number {
|
||||||
|
return Array.isArray(note.lane) ? note.lane[0] : note.lane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中按时间和轨道排序预览音符。
|
||||||
|
*
|
||||||
|
* @param notes - 谱面音符列表。
|
||||||
|
* @returns 处理后的列表。
|
||||||
|
*/
|
||||||
|
function sortPreviewNotes(notes: PreviewNote[]): PreviewNote[] {
|
||||||
|
notes.sort((a, b) => {
|
||||||
|
const typeSortResult =
|
||||||
|
(PREVIEW_NOTE_TYPE_SORT[a.type] || 0) -
|
||||||
|
(PREVIEW_NOTE_TYPE_SORT[b.type] || 0);
|
||||||
|
if (typeSortResult !== 0) {
|
||||||
|
return typeSortResult;
|
||||||
|
}
|
||||||
|
if (a.time !== b.time) {
|
||||||
|
return (a.time as number) - (b.time as number);
|
||||||
|
}
|
||||||
|
return getSortLane(a) - getSortLane(b);
|
||||||
|
});
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中把 Bestdori 谱面转换为预览音符列表。
|
||||||
|
*
|
||||||
|
* @param chart - 谱面音符数据。
|
||||||
|
* @returns 处理后的列表。
|
||||||
|
*/
|
||||||
|
export function createSongChartPreviewNotes(
|
||||||
|
chart: BestdoriNote[],
|
||||||
|
): PreviewNote[] {
|
||||||
|
const notes: PreviewNote[] = [];
|
||||||
|
|
||||||
|
for (const note of chart) {
|
||||||
|
if (note.type === 'Slide' || note.type === 'Long') {
|
||||||
|
pushSlideNotes(notes, note);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (note.type === 'BPM') {
|
||||||
|
notes.push(note as PreviewNote);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pushPlayableNote(notes, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortPreviewNotes(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在图片布局层中根据谱面长度创建预览布局参数。
|
||||||
|
*
|
||||||
|
* @param notes - 谱面音符列表。
|
||||||
|
* @returns 处理结果。
|
||||||
|
*/
|
||||||
|
export function createSongChartPreviewLayout(
|
||||||
|
notes: PreviewNote[],
|
||||||
|
): PreviewLayout {
|
||||||
|
const {
|
||||||
|
aspectRatioLimit,
|
||||||
|
blockDistance,
|
||||||
|
heightPerSecond,
|
||||||
|
infoAreaWidth,
|
||||||
|
laneCount,
|
||||||
|
laneWidth,
|
||||||
|
minHeight,
|
||||||
|
noteEndPaddingSeconds,
|
||||||
|
splitLineWidth,
|
||||||
|
} = BANGDREAM_SONG_CHART_PREVIEW_SPEC;
|
||||||
|
const displayNotes = notes.filter((note) =>
|
||||||
|
isSongChartDisplayNoteType(note.type),
|
||||||
|
);
|
||||||
|
const chartLength = Math.ceil(
|
||||||
|
(displayNotes[displayNotes.length - 1].time as number) +
|
||||||
|
noteEndPaddingSeconds,
|
||||||
|
);
|
||||||
|
const originalWidth = blockDistance * 2 + laneWidth * laneCount;
|
||||||
|
const originalHeight = heightPerSecond * chartLength;
|
||||||
|
let width = infoAreaWidth + originalWidth;
|
||||||
|
let height = originalHeight;
|
||||||
|
let colCount = 1;
|
||||||
|
|
||||||
|
while (width / height < aspectRatioLimit) {
|
||||||
|
if (width / height > 4 / 3) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Math.ceil(originalHeight / (colCount + 1)) < minHeight) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
colCount++;
|
||||||
|
|
||||||
|
const newWidth = infoAreaWidth + originalWidth * colCount;
|
||||||
|
const newHeight = originalHeight / colCount;
|
||||||
|
if (newHeight < minHeight) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
width = newWidth;
|
||||||
|
height = newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blockDistance,
|
||||||
|
chartLength,
|
||||||
|
colCount,
|
||||||
|
height,
|
||||||
|
heightPerSecond,
|
||||||
|
infoAreaWidth,
|
||||||
|
laneWidth,
|
||||||
|
originalWidth,
|
||||||
|
secondsPerCol: chartLength / colCount,
|
||||||
|
splitLineWidth,
|
||||||
|
width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建谱面预览渲染模型。
|
||||||
|
*
|
||||||
|
* @param chart - Bestdori 谱面数据。
|
||||||
|
* @returns 预览音符和布局。
|
||||||
|
*/
|
||||||
|
export function createSongChartPreviewModel(chart: BestdoriNote[]) {
|
||||||
|
assignSongChartTimes(chart);
|
||||||
|
const notes = createSongChartPreviewNotes(chart);
|
||||||
|
return {
|
||||||
|
layout: createSongChartPreviewLayout(notes),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -5,6 +5,15 @@ import {
|
|||||||
getBangDreamAssetPath,
|
getBangDreamAssetPath,
|
||||||
} from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest';
|
} from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest';
|
||||||
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
|
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme';
|
||||||
|
import {
|
||||||
|
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS,
|
||||||
|
BANGDREAM_SONG_CHART_PREVIEW_SPEC,
|
||||||
|
BestdoriNote,
|
||||||
|
createSongChartPreviewModel,
|
||||||
|
isSongChartCountLineNoteType,
|
||||||
|
PreviewLayout,
|
||||||
|
PreviewNote,
|
||||||
|
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec';
|
||||||
|
|
||||||
interface BestdoriPreviewPayload {
|
interface BestdoriPreviewPayload {
|
||||||
id: number;
|
id: number;
|
||||||
@ -16,49 +25,6 @@ interface BestdoriPreviewPayload {
|
|||||||
cover: string | Buffer;
|
cover: string | Buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BestdoriConnection {
|
|
||||||
beat: number;
|
|
||||||
lane: number;
|
|
||||||
time?: number;
|
|
||||||
skill?: boolean;
|
|
||||||
flick?: boolean;
|
|
||||||
hidden?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BestdoriNote {
|
|
||||||
type: string;
|
|
||||||
beat: number;
|
|
||||||
lane?: number;
|
|
||||||
time?: number | number[];
|
|
||||||
bpm?: number;
|
|
||||||
connections?: BestdoriConnection[];
|
|
||||||
skill?: boolean;
|
|
||||||
flick?: boolean;
|
|
||||||
direction?: 'Left' | 'Right';
|
|
||||||
width?: number;
|
|
||||||
hidden?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreviewNote = Omit<BestdoriNote, 'time' | 'lane'> & {
|
|
||||||
type: string;
|
|
||||||
time: number | number[];
|
|
||||||
lane: number | number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PreviewLayout {
|
|
||||||
infoAreaWidth: number;
|
|
||||||
laneWidth: number;
|
|
||||||
splitLineWidth: number;
|
|
||||||
blockDistance: number;
|
|
||||||
heightPerSecond: number;
|
|
||||||
originalWidth: number;
|
|
||||||
chartLength: number;
|
|
||||||
secondsPerCol: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
colCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OFFSET = 8;
|
const OFFSET = 8;
|
||||||
const NOTE_IMAGE_KEYS = [
|
const NOTE_IMAGE_KEYS = [
|
||||||
'Single',
|
'Single',
|
||||||
@ -91,378 +57,6 @@ const NOTE_IMAGE_ASSET_KEYS: Record<
|
|||||||
Skill: 'songChartNoteSkill',
|
Skill: 'songChartNoteSkill',
|
||||||
Tick: 'songChartNoteTick',
|
Tick: 'songChartNoteTick',
|
||||||
};
|
};
|
||||||
const DISPLAY_NOTE_TYPES = [
|
|
||||||
'Single',
|
|
||||||
'SingleOff',
|
|
||||||
'Skill',
|
|
||||||
'Flick',
|
|
||||||
'Directional',
|
|
||||||
'Long',
|
|
||||||
];
|
|
||||||
const COUNT_LINE_NOTE_TYPES = [
|
|
||||||
'Single',
|
|
||||||
'SingleOff',
|
|
||||||
'Flick',
|
|
||||||
'Long',
|
|
||||||
'Skill',
|
|
||||||
'Tick',
|
|
||||||
'Directional',
|
|
||||||
];
|
|
||||||
const DIFFICULTY_COLOR_LIST: Record<string, string> = {
|
|
||||||
easy: 'rgb(87, 192, 201)',
|
|
||||||
normal: 'rgb(138, 201, 87)',
|
|
||||||
hard: 'rgb(239, 161, 25)',
|
|
||||||
expert: 'rgb(199, 96, 96)',
|
|
||||||
special: 'rgb(195, 96, 199)',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中按节拍排序 BPM 时间点并写入累计时间。
|
|
||||||
*
|
|
||||||
* @param timepoints - BPM 时间点列表。
|
|
||||||
* @returns 处理后的列表。
|
|
||||||
*/
|
|
||||||
function sortTimepoints(timepoints: BestdoriNote[]): BestdoriNote[] {
|
|
||||||
timepoints.sort((a, b) => a.beat - b.beat);
|
|
||||||
for (let i = 0; i < timepoints.length; i++) {
|
|
||||||
const current = timepoints[i];
|
|
||||||
if (i === 0) {
|
|
||||||
current.time = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = timepoints[i - 1];
|
|
||||||
current.time =
|
|
||||||
(previous.time as number) +
|
|
||||||
(current.beat - previous.beat) * (60 / previous.bpm);
|
|
||||||
}
|
|
||||||
return timepoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中用二分查找定位节拍所在的 BPM 时间点。
|
|
||||||
*
|
|
||||||
* @param timepoints - BPM 时间点列表。
|
|
||||||
* @param beat - 谱面节拍位置。
|
|
||||||
* @returns 处理结果。
|
|
||||||
*/
|
|
||||||
function findTimepointAtBeat(
|
|
||||||
timepoints: BestdoriNote[],
|
|
||||||
beat: number,
|
|
||||||
): BestdoriNote {
|
|
||||||
let left = 0;
|
|
||||||
let right = timepoints.length - 1;
|
|
||||||
let result = timepoints[0];
|
|
||||||
|
|
||||||
while (left <= right) {
|
|
||||||
const mid = Math.floor((left + right) / 2);
|
|
||||||
if (timepoints[mid].beat > beat) {
|
|
||||||
right = mid - 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result = timepoints[mid];
|
|
||||||
left = mid + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中根据 BPM 时间点计算谱面音符时间。
|
|
||||||
*
|
|
||||||
* @param timepoints - BPM 时间点列表。
|
|
||||||
* @param beat - 谱面节拍位置。
|
|
||||||
* @returns 计算后的数值。
|
|
||||||
*/
|
|
||||||
function getNoteTime(timepoints: BestdoriNote[], beat: number): number {
|
|
||||||
const timepoint = findTimepointAtBeat(timepoints, beat);
|
|
||||||
return (
|
|
||||||
(timepoint.time as number) + (60 / timepoint.bpm) * (beat - timepoint.beat)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中为谱面音符写入实际时间。
|
|
||||||
*
|
|
||||||
* @param chart - 谱面音符数据。
|
|
||||||
* @returns 处理后的列表。
|
|
||||||
*/
|
|
||||||
function assignChartTimes(chart: BestdoriNote[]): BestdoriNote[] {
|
|
||||||
const timepoints = sortTimepoints(
|
|
||||||
chart.filter((note) => note.type === 'BPM'),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const note of chart) {
|
|
||||||
if (note.type === 'Long' || note.type === 'Slide') {
|
|
||||||
for (const connection of note.connections ?? []) {
|
|
||||||
connection.time = getNoteTime(timepoints, connection.beat);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (note.type !== 'BPM') {
|
|
||||||
note.time = getNoteTime(timepoints, note.beat);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return timepoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中为同拍音符补充双押标记。
|
|
||||||
*
|
|
||||||
* @param notes - 谱面音符列表。
|
|
||||||
* @param beat - 谱面节拍位置。
|
|
||||||
* @param time - 谱面时间点。
|
|
||||||
* @param lane - 轨道位置。
|
|
||||||
*/
|
|
||||||
function addSimNote(
|
|
||||||
notes: PreviewNote[],
|
|
||||||
beat: number,
|
|
||||||
time: number,
|
|
||||||
lane: number,
|
|
||||||
): void {
|
|
||||||
for (const note of notes) {
|
|
||||||
if (note.beat === beat && note.lane === lane) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
['Single', 'Flick', 'Skill', 'Long', 'Directional'].includes(note.type) &&
|
|
||||||
note.beat === beat
|
|
||||||
) {
|
|
||||||
notes.push({
|
|
||||||
type: 'Sim',
|
|
||||||
beat,
|
|
||||||
time,
|
|
||||||
lane: [note.lane as number, lane].sort((a, b) => a - b),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中识别单点音符的展示类型。
|
|
||||||
*
|
|
||||||
* @param note - 谱面音符。
|
|
||||||
* @returns 格式化后的文本。
|
|
||||||
*/
|
|
||||||
function getSingleNoteType(note: BestdoriNote): string {
|
|
||||||
if (note.flick) {
|
|
||||||
return 'Flick';
|
|
||||||
}
|
|
||||||
if (note.skill) {
|
|
||||||
return 'Skill';
|
|
||||||
}
|
|
||||||
if (note.beat % 0.5 !== 0) {
|
|
||||||
return 'SingleOff';
|
|
||||||
}
|
|
||||||
return 'Single';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中把滑条连接点拆成可绘制音符。
|
|
||||||
*
|
|
||||||
* @param notes - 谱面音符列表。
|
|
||||||
* @param note - 谱面音符。
|
|
||||||
*/
|
|
||||||
function pushSlideNotes(notes: PreviewNote[], note: BestdoriNote): void {
|
|
||||||
const barTime: number[] = [];
|
|
||||||
const lane: number[] = [];
|
|
||||||
const connections = note.connections ?? [];
|
|
||||||
|
|
||||||
for (let i = 0; i < connections.length; i++) {
|
|
||||||
const tick = connections[i];
|
|
||||||
const time = tick.time;
|
|
||||||
const firstTick = i === 0;
|
|
||||||
const lastTick = i === connections.length - 1;
|
|
||||||
|
|
||||||
barTime.push(time);
|
|
||||||
lane.push(tick.lane);
|
|
||||||
|
|
||||||
if (!firstTick) {
|
|
||||||
notes.push({
|
|
||||||
type: 'Bar',
|
|
||||||
beat: tick.beat,
|
|
||||||
time: [barTime[0], barTime[1]],
|
|
||||||
lane: [lane[0], lane[1]],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstTick || lastTick) {
|
|
||||||
notes.push({
|
|
||||||
...tick,
|
|
||||||
type: firstTick
|
|
||||||
? tick.skill
|
|
||||||
? 'Skill'
|
|
||||||
: 'Long'
|
|
||||||
: tick.flick
|
|
||||||
? 'Flick'
|
|
||||||
: tick.skill
|
|
||||||
? 'Skill'
|
|
||||||
: 'Long',
|
|
||||||
time,
|
|
||||||
lane: tick.lane,
|
|
||||||
});
|
|
||||||
addSimNote(notes, tick.beat, time, tick.lane);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lane.shift();
|
|
||||||
barTime.shift();
|
|
||||||
if (!tick.hidden) {
|
|
||||||
notes.push({ ...tick, type: 'Tick', time, lane: tick.lane });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中把可游玩音符转换为预览音符。
|
|
||||||
*
|
|
||||||
* @param notes - 谱面音符列表。
|
|
||||||
* @param note - 谱面音符。
|
|
||||||
*/
|
|
||||||
function pushPlayableNote(notes: PreviewNote[], note: BestdoriNote): void {
|
|
||||||
if (note.type === 'Single') {
|
|
||||||
const typedNote = {
|
|
||||||
...note,
|
|
||||||
type: getSingleNoteType(note),
|
|
||||||
time: note.time as number,
|
|
||||||
lane: note.lane,
|
|
||||||
} as PreviewNote;
|
|
||||||
notes.push(typedNote);
|
|
||||||
addSimNote(notes, note.beat, typedNote.time as number, note.lane);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note.type === 'Directional') {
|
|
||||||
notes.push({
|
|
||||||
...note,
|
|
||||||
time: note.time as number,
|
|
||||||
lane: note.lane,
|
|
||||||
} as PreviewNote);
|
|
||||||
addSimNote(notes, note.beat, note.time as number, note.lane);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中获取Sort轨道。
|
|
||||||
*
|
|
||||||
* @param note - 谱面音符。
|
|
||||||
* @returns 计算后的数值。
|
|
||||||
*/
|
|
||||||
function getSortLane(note: PreviewNote): number {
|
|
||||||
return Array.isArray(note.lane) ? note.lane[0] : note.lane;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中按时间和轨道排序预览音符。
|
|
||||||
*
|
|
||||||
* @param notes - 谱面音符列表。
|
|
||||||
* @returns 处理后的列表。
|
|
||||||
*/
|
|
||||||
function sortPreviewNotes(notes: PreviewNote[]): PreviewNote[] {
|
|
||||||
/**
|
|
||||||
* 在图片布局层中处理类型Sort。
|
|
||||||
*
|
|
||||||
* @param type - 数据类型或匹配类型。
|
|
||||||
* @returns 计算后的数值。
|
|
||||||
*/
|
|
||||||
const typeSort = (type: string): number => ({ Bar: -2, Sim: -1 })[type] || 0;
|
|
||||||
notes.sort((a, b) => {
|
|
||||||
const typeSortResult = typeSort(a.type) - typeSort(b.type);
|
|
||||||
if (typeSortResult !== 0) {
|
|
||||||
return typeSortResult;
|
|
||||||
}
|
|
||||||
if (a.time !== b.time) {
|
|
||||||
return (a.time as number) - (b.time as number);
|
|
||||||
}
|
|
||||||
return getSortLane(a) - getSortLane(b);
|
|
||||||
});
|
|
||||||
return notes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中把 Bestdori 谱面转换为预览音符列表。
|
|
||||||
*
|
|
||||||
* @param chart - 谱面音符数据。
|
|
||||||
* @returns 处理后的列表。
|
|
||||||
*/
|
|
||||||
function createPreviewNotes(chart: BestdoriNote[]): PreviewNote[] {
|
|
||||||
const notes: PreviewNote[] = [];
|
|
||||||
|
|
||||||
for (const note of chart) {
|
|
||||||
if (note.type === 'Slide' || note.type === 'Long') {
|
|
||||||
pushSlideNotes(notes, note);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (note.type === 'BPM') {
|
|
||||||
notes.push(note as PreviewNote);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pushPlayableNote(notes, note);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortPreviewNotes(notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在图片布局层中根据谱面长度创建预览布局参数。
|
|
||||||
*
|
|
||||||
* @param notes - 谱面音符列表。
|
|
||||||
* @returns 处理结果。
|
|
||||||
*/
|
|
||||||
function createPreviewLayout(notes: PreviewNote[]): PreviewLayout {
|
|
||||||
const infoAreaWidth = 240;
|
|
||||||
const laneWidth = 32;
|
|
||||||
const splitLineWidth = 2;
|
|
||||||
const blockDistance = 72;
|
|
||||||
const heightPerSecond = 216;
|
|
||||||
const displayNotes = notes.filter((note) =>
|
|
||||||
DISPLAY_NOTE_TYPES.includes(note.type),
|
|
||||||
);
|
|
||||||
const chartLength = Math.ceil(
|
|
||||||
(displayNotes[displayNotes.length - 1].time as number) + 0.25,
|
|
||||||
);
|
|
||||||
const minHeight = 500;
|
|
||||||
const originalWidth = blockDistance * 2 + laneWidth * 7;
|
|
||||||
const originalHeight = heightPerSecond * chartLength;
|
|
||||||
let width = infoAreaWidth + originalWidth;
|
|
||||||
let height = originalHeight;
|
|
||||||
let colCount = 1;
|
|
||||||
|
|
||||||
while (width / height < 16 / 9) {
|
|
||||||
if (width / height > 4 / 3) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (Math.ceil(originalHeight / (colCount + 1)) < minHeight) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
colCount++;
|
|
||||||
|
|
||||||
const newWidth = infoAreaWidth + originalWidth * colCount;
|
|
||||||
const newHeight = originalHeight / colCount;
|
|
||||||
if (newHeight < minHeight) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
width = newWidth;
|
|
||||||
height = newHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
infoAreaWidth,
|
|
||||||
laneWidth,
|
|
||||||
splitLineWidth,
|
|
||||||
blockDistance,
|
|
||||||
heightPerSecond,
|
|
||||||
originalWidth,
|
|
||||||
chartLength,
|
|
||||||
secondsPerCol: chartLength / colCount,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
colCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在图片布局层中加载谱面预览所需音符贴图。
|
* 在图片布局层中加载谱面预览所需音符贴图。
|
||||||
*
|
*
|
||||||
@ -574,7 +168,7 @@ function drawBaseInfo(
|
|||||||
const coverWidth = layout.infoAreaWidth - 16;
|
const coverWidth = layout.infoAreaWidth - 16;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle =
|
ctx.fillStyle =
|
||||||
DIFFICULTY_COLOR_LIST[diff] ??
|
BANGDREAM_SONG_CHART_DIFFICULTY_COLORS[diff] ??
|
||||||
BANGDREAM_RENDER_THEME.color.chartDifficultyFallback;
|
BANGDREAM_RENDER_THEME.color.chartDifficultyFallback;
|
||||||
ctx.fillRect(8 + coverWidth - 116, 8 + coverWidth - 12, 128, 24);
|
ctx.fillRect(8 + coverWidth - 116, 8 + coverWidth - 12, 128, 24);
|
||||||
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
|
ctx.fillStyle = BANGDREAM_RENDER_THEME.color.chartText;
|
||||||
@ -596,7 +190,8 @@ function drawTracks(ctx: any, layout: PreviewLayout): void {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
const x =
|
const x =
|
||||||
layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance;
|
layout.infoAreaWidth + i * layout.originalWidth + layout.blockDistance;
|
||||||
const w = layout.laneWidth * 7;
|
const w =
|
||||||
|
layout.laneWidth * BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount;
|
||||||
const grd = ctx.createLinearGradient(
|
const grd = ctx.createLinearGradient(
|
||||||
x,
|
x,
|
||||||
0,
|
0,
|
||||||
@ -658,7 +253,8 @@ function drawBeatLines(
|
|||||||
}
|
}
|
||||||
const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm);
|
const currentTime = (bpmNote.time as number) + beat * (60 / bpmNote.bpm);
|
||||||
const { x, y } = getTimePosition(layout, currentTime);
|
const { x, y } = getTimePosition(layout, currentTime);
|
||||||
const w = 7 * layout.laneWidth;
|
const w =
|
||||||
|
BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, y);
|
ctx.moveTo(x, y);
|
||||||
@ -706,13 +302,13 @@ function drawCountAndBpmLines(
|
|||||||
notes: PreviewNote[],
|
notes: PreviewNote[],
|
||||||
): void {
|
): void {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const w = 7 * layout.laneWidth;
|
const w = BANGDREAM_SONG_CHART_PREVIEW_SPEC.laneCount * layout.laneWidth;
|
||||||
|
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
const time = note.time as number;
|
const time = note.time as number;
|
||||||
const { x, y } = getTimePosition(layout, time);
|
const { x, y } = getTimePosition(layout, time);
|
||||||
|
|
||||||
if (COUNT_LINE_NOTE_TYPES.includes(note.type)) {
|
if (isSongChartCountLineNoteType(note.type)) {
|
||||||
count++;
|
count++;
|
||||||
if (count % 50 !== 0) {
|
if (count % 50 !== 0) {
|
||||||
continue;
|
continue;
|
||||||
@ -955,9 +551,7 @@ export async function drawBestdoriPreview(
|
|||||||
payload: BestdoriPreviewPayload,
|
payload: BestdoriPreviewPayload,
|
||||||
chart: BestdoriNote[],
|
chart: BestdoriNote[],
|
||||||
): Promise<Canvas> {
|
): Promise<Canvas> {
|
||||||
assignChartTimes(chart);
|
const { layout, notes } = createSongChartPreviewModel(chart);
|
||||||
const notes = createPreviewNotes(chart);
|
|
||||||
const layout = createPreviewLayout(notes);
|
|
||||||
const canvas = new Canvas(layout.width, layout.height);
|
const canvas = new Canvas(layout.width, layout.height);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const [noteImages, coverImg] = await Promise.all([
|
const [noteImages, coverImg] = await Promise.all([
|
||||||
|
|||||||
@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
assignSongChartTimes,
|
||||||
|
BestdoriNote,
|
||||||
|
createSongChartPreviewLayout,
|
||||||
|
createSongChartPreviewModel,
|
||||||
|
createSongChartPreviewNotes,
|
||||||
|
isSongChartCountLineNoteType,
|
||||||
|
isSongChartDisplayNoteType,
|
||||||
|
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/song-chart-preview-spec';
|
||||||
|
|
||||||
|
describe('BangDream song chart preview spec', () => {
|
||||||
|
it('assigns note times from BPM changes', () => {
|
||||||
|
const chart: BestdoriNote[] = [
|
||||||
|
{ beat: 0, bpm: 120, type: 'BPM' },
|
||||||
|
{ beat: 4, bpm: 240, type: 'BPM' },
|
||||||
|
{ beat: 5, lane: 1, type: 'Single' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assignSongChartTimes(chart);
|
||||||
|
|
||||||
|
expect(chart[0].time).toBe(0);
|
||||||
|
expect(chart[1].time).toBe(2);
|
||||||
|
expect(chart[2].time).toBe(2.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts playable notes into preview notes', () => {
|
||||||
|
const chart: BestdoriNote[] = [
|
||||||
|
{ beat: 0, bpm: 120, type: 'BPM' },
|
||||||
|
{ beat: 1.25, lane: 1, type: 'Single' },
|
||||||
|
{ beat: 2, flick: true, lane: 2, type: 'Single' },
|
||||||
|
{ beat: 2, lane: 5, skill: true, type: 'Single' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assignSongChartTimes(chart);
|
||||||
|
const notes = createSongChartPreviewNotes(chart);
|
||||||
|
|
||||||
|
expect(notes.map((note) => note.type)).toEqual([
|
||||||
|
'Sim',
|
||||||
|
'BPM',
|
||||||
|
'SingleOff',
|
||||||
|
'Flick',
|
||||||
|
'Skill',
|
||||||
|
]);
|
||||||
|
expect(notes.find((note) => note.type === 'Sim')?.lane).toEqual([2, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts slide connections into bars, ticks and endpoints', () => {
|
||||||
|
const chart: BestdoriNote[] = [
|
||||||
|
{ beat: 0, bpm: 120, type: 'BPM' },
|
||||||
|
{
|
||||||
|
beat: 1,
|
||||||
|
connections: [
|
||||||
|
{ beat: 1, lane: 1 },
|
||||||
|
{ beat: 2, lane: 3 },
|
||||||
|
{ beat: 3, flick: true, lane: 4 },
|
||||||
|
],
|
||||||
|
type: 'Slide',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assignSongChartTimes(chart);
|
||||||
|
const notes = createSongChartPreviewNotes(chart);
|
||||||
|
|
||||||
|
expect(notes.map((note) => note.type)).toEqual([
|
||||||
|
'Bar',
|
||||||
|
'Bar',
|
||||||
|
'BPM',
|
||||||
|
'Long',
|
||||||
|
'Tick',
|
||||||
|
'Flick',
|
||||||
|
]);
|
||||||
|
expect(notes.filter((note) => note.type === 'Bar')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates layout from display note duration', () => {
|
||||||
|
const layout = createSongChartPreviewLayout([
|
||||||
|
{ beat: 0, lane: 0, time: 0, type: 'BPM' },
|
||||||
|
{ beat: 1, lane: 1, time: 1, type: 'Single' },
|
||||||
|
{ beat: 120, lane: 2, time: 60, type: 'Single' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(layout.chartLength).toBe(61);
|
||||||
|
expect(layout.colCount).toBeGreaterThan(1);
|
||||||
|
expect(layout.secondsPerCol).toBe(layout.chartLength / layout.colCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies display and count-line note types', () => {
|
||||||
|
expect(isSongChartDisplayNoteType('Long')).toBe(true);
|
||||||
|
expect(isSongChartDisplayNoteType('BPM')).toBe(false);
|
||||||
|
expect(isSongChartCountLineNoteType('Tick')).toBe(true);
|
||||||
|
expect(isSongChartCountLineNoteType('Sim')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates preview model in one call', () => {
|
||||||
|
const chart: BestdoriNote[] = [
|
||||||
|
{ beat: 0, bpm: 120, type: 'BPM' },
|
||||||
|
{ beat: 1, lane: 1, type: 'Single' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const model = createSongChartPreviewModel(chart);
|
||||||
|
|
||||||
|
expect(model.notes.some((note) => note.type === 'Single')).toBe(true);
|
||||||
|
expect(model.layout.width).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user