feat: show playable charts with score hover and special-start markers
Publish GitHub Pages output / publish (push) Successful in 47s

This commit is contained in:
2026-09-29 23:12:18 +09:00
parent 9fc3d35d2d
commit 049e37bcff
9 changed files with 362 additions and 42 deletions
+7
View File
@@ -4,6 +4,13 @@ updatedAt: 2026-09-29T00:00:00Z
## 2026년 9월 29일 ## 2026년 9월 29일
### 채보에서 분석 점수 확인
`NEW` 배치 분석 결과에 레인·노트 너비·플릭·롱노트 연결을 표시하는 채보를 추가했습니다. 채보를 가리키거나 터치하면 해당 시각의 누적 평균 점수·최고 점수·최고점 유지 확률을 확인할 수 있습니다. 액티브 미발동 설정을 반영하며, 시간 확대와 스페셜 구간 이동을 지원합니다.
`IMPROVED` 스페셜 시작과 수동 판정 노트가 겹치는 밀어치기 가능 지점을 무지개 선으로 표시합니다. 롱노트 중간·끝의 자동 판정만 겹치는 지점은 제외합니다.
### 게임 DB 갱신 ### 게임 DB 갱신
`NEW` 마린·코요리·코보·벨즈의 신규 ★5 수영복 카드 4장을 추가했습니다. `NEW` 마린·코요리·코보·벨즈의 신규 ★5 수영복 카드 4장을 추가했습니다.
+2 -2
View File
@@ -39,8 +39,8 @@ style += `
`; `;
await writeFile(stylePath, style); await writeFile(stylePath, style);
// 채보는 app.js에 포함되므로 public 디렉터리에서 복사된 중복 파일은 제거한다. // 점수 채보는 app.js에 포함된다. 별도로 불러오는 시각화 채보는 보존한다.
await rm(path.join(output, 'data'), { recursive: true, force: true }); await rm(path.join(output, 'data', 'charts'), { recursive: true, force: true });
await rm(path.join(output, 'VERSION.txt'), { force: true }); await rm(path.join(output, 'VERSION.txt'), { force: true });
await writeFile(path.join(output, 'README.txt'), `홀로도리 계산기 await writeFile(path.join(output, 'README.txt'), `홀로도리 계산기
+234
View File
@@ -0,0 +1,234 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import type { ExactChartNote, NoteScorePoint, SkillWindow } from '$lib/engine/types.ts';
import type { ChartVisual, ChartPosition } from '$lib/models/chart-visual.ts';
import { sampleHold } from '$lib/engine/chart-geometry.ts';
import { buildScorePoints, scorePointAtTime, formatTimelineScore, formatTimelineProbability } from '$lib/engine/score-timeline.ts';
export let songId: string;
export let difficulty: string;
export let durationSeconds: number;
export let notes: readonly NoteScorePoint[];
export let chartNotes: readonly ExactChartNote[] = [];
export let maximumScoreProbabilities: readonly number[] = [];
export let activeWindows: readonly SkillWindow[] = [];
export let specialWindows: readonly SkillWindow[] = [];
export let cardName: (id: string) => string;
export let memberColor: (id: string) => string;
const WIDTH = 480;
const LEFT = 48;
const LANE_WIDTH = 30;
const PAD = 24;
let mounted = false;
let controller: AbortController | undefined;
let visual: ChartVisual | null = null;
let error = '';
let pixelsPerSecond = 90;
let hoverTime: number | null = null;
let scroller: HTMLDivElement;
let surface: HTMLDivElement;
let surfaceWidth = WIDTH;
let pointer: { x: number; y: number } | null = null;
let tooltipTop = 16;
$: chartHeight = Math.max(1, durationSeconds) * pixelsPerSecond + PAD * 2;
$: y = (time: number): number => PAD + (durationSeconds - time) * pixelsPerSecond;
$: points = buildScorePoints(notes, maximumScoreProbabilities);
$: selected = hoverTime === null ? null : scorePointAtTime(points, hoverTime);
$: holds = visual?.holds.map(sampleHold) ?? [];
$: seconds = Array.from({ length: Math.floor(durationSeconds) + 1 }, (_, i) => i);
// Match the scoring engine's special-start boundary and manual-judgement rules.
$: pushableSpecialStarts = [...new Set(specialWindows
.filter(window => chartNotes.some(note => note.type !== 'mid' && !note.isHoldEnd && Math.abs(note.timeSeconds - window.startSeconds) < 1e-9))
.map(window => window.startSeconds))];
$: rainbowId = `special-start-${songId}-${difficulty}`;
$: if (mounted) void loadVisual(songId, difficulty);
onMount(() => { mounted = true; });
onDestroy(() => controller?.abort());
async function loadVisual(id: string, level: string) {
controller?.abort();
const request = new AbortController();
controller = request;
visual = null;
error = '';
hoverTime = null;
pointer = null;
try {
const response = await fetch(`/data/chart-visuals/${encodeURIComponent(id)}.${encodeURIComponent(level)}.json`, { signal: request.signal });
if (!response.ok) throw new Error('채보를 불러오지 못했습니다.');
const data: ChartVisual = await response.json();
if (data.schemaVersion !== 1 || data.songId !== id || data.difficulty !== level || !Array.isArray(data.notes) || !Array.isArray(data.holds)) throw new Error('채보 데이터를 확인할 수 없습니다.');
if (request.signal.aborted) return;
visual = data;
await tick();
if (!request.signal.aborted) scroller.scrollTop = scroller.scrollHeight;
} catch (cause) {
if (!request.signal.aborted) error = cause instanceof Error ? cause.message : '채보를 불러오지 못했습니다.';
}
}
function x(lane: number): number { return LEFT + lane * LANE_WIDTH; }
function holdPath(hold: ChartPosition[], _scale: number, _duration: number): string {
const vertices = [...hold.map(p => `${x(p.lane)},${y(p.timeSeconds)}`), ...[...hold].reverse().map(p => `${x(p.lane + p.width)},${y(p.timeSeconds)}`)];
return `M${vertices.join(' L')} Z`;
}
function formatTime(time: number): string {
return `${Math.floor(time / 60)}:${(time % 60).toFixed(3).padStart(6, '0')}`;
}
function updatePointer() {
if (!pointer || !surface) return;
const bounds = surface.getBoundingClientRect();
const pixelY = (pointer.y - bounds.top) * chartHeight / bounds.height;
let time = Math.max(0, Math.min(durationSeconds, durationSeconds - (pixelY - PAD) / pixelsPerSecond));
const lane = ((pointer.x - bounds.left) * WIDTH / bounds.width - LEFT) / LANE_WIDTH;
// Hovering a note includes that judgement, even when its source time was rounded.
const nearest = visual?.notes.filter(n => lane >= n.lane && lane <= n.lane + n.width)
.reduce<ChartVisual['notes'][number] | null>((best, note) => Math.abs(note.timeSeconds - time) < Math.abs((best?.timeSeconds ?? Infinity) - time) ? note : best, null);
if (nearest && Math.abs(nearest.timeSeconds - time) * pixelsPerSecond < 7) time = nearest.timeSeconds;
hoverTime = time;
const viewport = scroller.getBoundingClientRect();
tooltipTop = Math.max(8, Math.min(viewport.height - 148, pointer.y - viewport.top + 18));
}
function onPointer(event: PointerEvent) { pointer = { x: event.clientX, y: event.clientY }; updatePointer(); }
function clearHover() { pointer = null; hoverTime = null; }
function reveal(time: number) {
hoverTime = Math.max(0, Math.min(durationSeconds, time));
pointer = null;
const ratio = surfaceWidth / WIDTH;
scroller.scrollTop = y(hoverTime) * ratio - scroller.clientHeight / 2;
tooltipTop = 16;
}
function onKeydown(event: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) return;
event.preventDefault();
const step = event.key === 'PageUp' ? 5 : event.key === 'PageDown' ? -5 : event.key === 'ArrowUp' ? 0.1 : -0.1;
reveal(event.key === 'Home' ? 0 : event.key === 'End' ? durationSeconds : (hoverTime ?? 0) + step);
}
async function zoom(event: Event) {
const ratio = surfaceWidth / WIDTH;
const centerTime = durationSeconds - ((scroller.scrollTop + scroller.clientHeight / 2) / ratio - PAD) / pixelsPerSecond;
pixelsPerSecond = Number((event.currentTarget as HTMLSelectElement).value);
await tick();
scroller.scrollTop = y(centerTime) * ratio - scroller.clientHeight / 2;
updatePointer();
}
</script>
<section class="playable-chart" aria-label="채보와 점수">
<div class="heading">
<div><h3>채보와 점수</h3><p>아래에서 위로 시간이 흐릅니다. 채보 위에 마우스를 올리거나 터치하면 해당 시각의 누적 점수를 볼 수 있습니다.</p></div>
{#if visual}
<label>시간 확대 <select value={String(pixelsPerSecond)} on:change={zoom}><option value="60">작게</option><option value="90">기본</option><option value="150">크게</option></select></label>
{/if}
</div>
{#if error}
<p role="status">{error} <button type="button" on:click={() => loadVisual(songId, difficulty)}>다시 불러오기</button></p>
{:else if !visual}
<p role="status">채보를 불러오는 중입니다…</p>
{:else}
<div class="viewer-layout">
<div class="chart-panel">
<div class="chart-legend"><span class="tap-key">━ 탭</span><span class="hold-key">━ 롱노트</span><span class="flick-key">⌃ 플릭</span><span><i class="rainbow-key"></i> 밀어치기 가능</span><span>오른쪽: 액티브 / 스페셜</span></div>
<div class="viewport">
<div class="scroll" bind:this={scroller} on:scroll={updatePointer}>
<div class="surface" bind:this={surface} bind:clientWidth={surfaceWidth} style={`height: ${chartHeight * surfaceWidth / WIDTH}px;`}
role="slider" aria-label="채보에서 점수를 확인할 시각" aria-orientation="vertical" aria-valuemin="0" aria-valuemax={durationSeconds}
aria-valuenow={hoverTime ?? 0} aria-valuetext={selected && hoverTime !== null ? `${formatTime(hoverTime)}, 평균 점수 ${formatTimelineScore(selected.averageScore)}, 최고 점수 ${formatTimelineScore(selected.maximumScore)}, 이 시간까지 최고점 유지 확률 ${formatTimelineProbability(selected.maximumProbability)}` : '위아래 방향키로 시각을 선택하세요'}
tabindex="0" on:pointermove={onPointer} on:pointerdown={onPointer} on:pointerleave={(event) => { if (event.pointerType !== 'touch') clearHover(); }}
on:keydown={onKeydown} on:blur={clearHover}>
<svg viewBox={`0 0 ${WIDTH} ${chartHeight}`} aria-hidden="true">
<defs>
<linearGradient id={rainbowId} gradientUnits="userSpaceOnUse" x1={LEFT} y1="0" x2={x(12)} y2="0">
<stop offset="0%" stop-color="#ff6685" /><stop offset="17%" stop-color="#ffac63" />
<stop offset="33%" stop-color="#ffe877" /><stop offset="50%" stop-color="#75edaa" />
<stop offset="67%" stop-color="#6ddcff" /><stop offset="83%" stop-color="#9990ff" />
<stop offset="100%" stop-color="#ee8dff" />
</linearGradient>
</defs>
<rect class="lane-background" x={LEFT} y="0" width={LANE_WIDTH * 12} height={chartHeight} />
{#each Array(13) as _, lane}<line class="lane-line" x1={x(lane)} x2={x(lane)} y1="0" y2={chartHeight} />{/each}
{#each seconds as second}
<line class="time-line" x1={LEFT} x2={WIDTH} y1={y(second)} y2={y(second)} />
<text class="time-label" x={LEFT - 6} y={y(second) + 4} text-anchor="end">{second}초</text>
{/each}
{#each activeWindows as window}<rect x="418" y={y(Math.min(durationSeconds, window.endSeconds))} width="20" height={Math.max(0, Math.min(durationSeconds, window.endSeconds) - window.startSeconds) * pixelsPerSecond} fill={memberColor(window.cardId)} opacity="0.8" />{/each}
{#each specialWindows as window}<rect x="449" y={y(Math.min(durationSeconds, window.endSeconds))} width="20" height={Math.max(0, Math.min(durationSeconds, window.endSeconds) - window.startSeconds) * pixelsPerSecond} fill={memberColor(window.cardId)} opacity="0.8" />{/each}
{#each holds as hold}<path class="hold-body" d={holdPath(hold, pixelsPerSecond, durationSeconds)} />{/each}
{#each pushableSpecialStarts as start}
<g class="special-start" stroke={`url(#${rainbowId})`}>
<line class="special-start-glow" x1={LEFT} x2={x(12)} y1={y(start)} y2={y(start)} />
<line x1={LEFT} x2={x(12)} y1={y(start)} y2={y(start)} />
</g>
{/each}
{#each visual.notes as note}
<g class:critical={note.critical} class:flick={note.kind === 'flick' || note.direction} class:hold={note.kind === 'hold'} class:relay={note.kind === 'relay'} class="note">
<rect x={x(note.lane) + 1} y={y(note.timeSeconds) - (note.kind === 'relay' ? 2 : 4)} width={Math.max(1, note.width * LANE_WIDTH - 2)} height={note.kind === 'relay' ? 4 : 8} rx="2" />
{#if note.direction}<text x={x(note.lane + note.width / 2)} y={y(note.timeSeconds) - 6} text-anchor="middle">{note.direction === 'left' ? '↖' : note.direction === 'right' ? '↗' : '⌃'}</text>{/if}
</g>
{/each}
{#if hoverTime !== null}<line class="cursor" x1={LEFT} x2={WIDTH} y1={y(hoverTime)} y2={y(hoverTime)} />{/if}
</svg>
</div>
</div>
{#if selected && hoverTime !== null}
<div class="hover-info" style={`top: ${tooltipTop}px;`}>
<strong>{formatTime(hoverTime)}</strong>
<span>평균 점수 <b class="average">{formatTimelineScore(selected.averageScore)}</b></span>
<span>최고 점수 <b class="maximum">{formatTimelineScore(selected.maximumScore)}</b></span>
<span>이 시간까지 최고점 유지 확률 <b>{formatTimelineProbability(selected.maximumProbability)}</b></span>
</div>
{/if}
</div>
</div>
<aside>
<h4>스페셜 구간으로 이동</h4>
{#each specialWindows as window, index}<button type="button" on:click={() => reveal(window.startSeconds)}><i style={`background: ${memberColor(window.cardId)}`}></i><span>{index + 1}. {cardName(window.cardId)}<small>{formatTime(window.startSeconds)}</small></span></button>{/each}
<button type="button" on:click={() => reveal(0)}>곡 시작으로 이동</button>
<p>휠·스크롤로 이동할 수 있습니다. 키보드는 채보에 포커스를 둔 뒤 ↑ ↓로 0.1초, Page Up / Down으로 5초씩 이동합니다.</p>
<p>무지개 선은 스페셜 시작과 수동 판정 노트가 겹치는 밀어치기 가능 지점입니다. 롱노트 중간·끝의 자동 판정은 제외합니다.</p>
<p>노트의 위치·너비와 롱노트 연결을 표시합니다. 시간 간격은 초 기준이며 게임 내 스크롤 속도 변화는 반영하지 않습니다.</p>
</aside>
</div>
{/if}
</section>
<style>
.playable-chart { margin: 20px 0; padding: 18px 0; border-top: 1px solid #2b313c; border-bottom: 1px solid #2b313c; }
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
h3 { margin: 0; font-size: 16px; color: #e8ebf1; }
p { color: #929dad; font-size: 12px; line-height: 1.65; }
.heading p { margin: 7px 0 0; }
label { flex-shrink: 0; color: #aab3c2; font-size: 12px; }
select, button { color: #dde3ed; background: #202631; border: 1px solid #3a4657; border-radius: 7px; padding: 8px; }
select { margin-left: 6px; }
button { cursor: pointer; }
button:hover { background: #303b4b; }
.viewer-layout { display: grid; grid-template-columns: minmax(0, 560px) minmax(160px, 1fr); gap: 20px; }
.chart-panel { min-width: 0; border: 1px solid #344052; border-radius: 10px; overflow: hidden; background: #10141a; }
.chart-legend { display: flex; flex-wrap: wrap; gap: 10px; padding: 10px; color: #929dad; font-size: 11px; }
.tap-key { color: #73caff; }.hold-key { color: #6ee5ad; }.flick-key { color: #f294bd; }
.viewport { position: relative; }
.scroll { height: 560px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; }
.surface { position: relative; width: 100%; outline-offset: -2px; touch-action: pan-y; }
svg { display: block; width: 100%; height: 100%; pointer-events: none; }
.lane-background { fill: #181e29; }.lane-line { stroke: #354052; stroke-width: 0.7; }
.time-line { stroke: #566174; stroke-width: 0.7; }.time-label { fill: #97a4b8; font-size: 10px; }
.hold-body { fill: #53bf93; fill-opacity: 0.35; stroke: #66d3a6; stroke-opacity: 0.7; stroke-width: 0.8; }
.note rect { fill: #70c9ff; stroke: #c6edff; stroke-width: 0.7; }
.note.hold rect, .note.relay rect { fill: #6ce8ab; stroke: #c4ffe2; }
.note.flick rect { fill: #f184ba; stroke: #ffd4eb; }.note.critical rect { fill: #f4d070; stroke: #fff1b8; }
.note text { fill: #ffb3d5; font-size: 19px; font-weight: 800; }
.cursor { stroke: white; stroke-width: 1.5; stroke-dasharray: 5 3; }
.special-start { stroke-width: 3; }
.special-start-glow { stroke-width: 12; opacity: 0.22; }
.rainbow-key { display: inline-block; width: 18px; height: 3px; vertical-align: middle; background: linear-gradient(90deg, #ff6685, #ffac63, #ffe877, #75edaa, #6ddcff, #9990ff, #ee8dff); }
.hover-info { position: absolute; left: 12px; z-index: 2; display: grid; gap: 6px; padding: 10px 12px; border: 1px solid #58647a; border-radius: 8px; color: #c6cedb; background: rgb(15 20 29 / 94%); box-shadow: 0 4px 18px #0008; font-size: 11px; pointer-events: none; max-width: calc(100% - 48px); }
.hover-info strong { color: white; }.hover-info span { display: flex; justify-content: space-between; gap: 14px; }.hover-info b { color: #ecf0f6; white-space: nowrap; }.hover-info .average { color: #f08db8; }.hover-info .maximum { color: #64cbe4; }
aside h4 { margin: 2px 0 12px; font-size: 13px; color: #c3cbd8; }
aside button { display: flex; gap: 8px; align-items: center; width: 100%; margin-bottom: 7px; text-align: left; font-size: 12px; }
aside i { width: 8px; height: 8px; border-radius: 50%; flex: none; }small { display: block; margin-top: 3px; color: #98a5b8; }
@media (max-width: 700px) { .heading { align-items: flex-start; flex-direction: column; }.viewer-layout { grid-template-columns: minmax(0, 1fr); }.scroll { height: 480px; }aside { display: flex; flex-wrap: wrap; gap: 6px; }aside h4, aside p { width: 100%; }aside button { width: auto; flex: 1 0 40%; } }
</style>
+1 -40
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { NoteScorePoint } from '$lib/engine/index.ts'; import type { NoteScorePoint } from '$lib/engine/index.ts';
import { buildScorePoints as buildPoints, scorePointAtTime as pointAtTime, formatTimelineScore as formatScore, formatTimelineProbability as formatProbability, type ScorePoint } from '$lib/engine/score-timeline.ts';
export let notes: readonly NoteScorePoint[]; export let notes: readonly NoteScorePoint[];
export let maximumScoreProbabilities: readonly number[] = []; export let maximumScoreProbabilities: readonly number[] = [];
@@ -7,13 +8,6 @@
export let minimumWidth = 720; export let minimumWidth = 720;
export let timeTicks: readonly number[] = []; export let timeTicks: readonly number[] = [];
type ScorePoint = {
timeSeconds: number;
averageScore: number;
maximumScore: number;
maximumProbability?: number;
};
const HEIGHT = 220; const HEIGHT = 220;
const LEFT = 0; const LEFT = 0;
const RIGHT = 0; const RIGHT = 0;
@@ -43,18 +37,6 @@
tooltipPercent = 0; tooltipPercent = 0;
} }
function buildPoints(source: readonly NoteScorePoint[], probabilities: readonly number[]): ScorePoint[] {
let averageScore = 0;
let maximumScore = 0;
const result: ScorePoint[] = [{ timeSeconds: 0, averageScore: 0, maximumScore: 0, maximumProbability: 1 }];
for (const [index, note] of source.entries()) {
averageScore += note.expectedScore;
maximumScore += note.theoreticalMaxScore;
result.push({ timeSeconds: note.timeSeconds, averageScore, maximumScore, maximumProbability: probabilities[index] });
}
return result;
}
function x(timeSeconds: number, width: number, duration: number): number { function x(timeSeconds: number, width: number, duration: number): number {
return LEFT + Math.max(0, Math.min(1, timeSeconds / Math.max(1, duration))) * (width - LEFT - RIGHT); return LEFT + Math.max(0, Math.min(1, timeSeconds / Math.max(1, duration))) * (width - LEFT - RIGHT);
} }
@@ -79,17 +61,6 @@
return commands.join(' '); return commands.join(' ');
} }
function pointAtTime(source: readonly ScorePoint[], timeSeconds: number): ScorePoint {
let low = 0;
let high = source.length - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (source[middle].timeSeconds <= timeSeconds) low = middle;
else high = middle - 1;
}
return source[low] ?? { timeSeconds: 0, averageScore: 0, maximumScore: 0 };
}
function updateHover(clientX: number, target: EventTarget | null): void { function updateHover(clientX: number, target: EventTarget | null): void {
if (!(target instanceof HTMLElement)) return; if (!(target instanceof HTMLElement)) return;
const bounds = target.getBoundingClientRect(); const bounds = target.getBoundingClientRect();
@@ -112,16 +83,6 @@
tooltipPercent = (LEFT + hoverTime / Math.max(1, durationSeconds) * plotWidth) / viewWidth * 100; tooltipPercent = (LEFT + hoverTime / Math.max(1, durationSeconds) * plotWidth) / viewWidth * 100;
} }
function formatScore(score: number): string {
return Math.round(score).toLocaleString('ko-KR');
}
function formatProbability(probability: number | undefined): string {
if (probability === undefined) return '—';
if (probability > 0 && probability < 0.000001) return '<0.0001%';
return `${(probability * 100).toLocaleString('ko-KR', { maximumFractionDigits: probability < 0.0001 ? 4 : 2 })}%`;
}
function formatAxisScore(score: number): string { function formatAxisScore(score: number): string {
return score === 0 ? '0' : `${Math.round(score / 10_000).toLocaleString('ko-KR')}만`; return score === 0 ? '0' : `${Math.round(score / 10_000).toLocaleString('ko-KR')}만`;
} }
+27
View File
@@ -0,0 +1,27 @@
import type { ChartPosition } from '../models/chart-visual.ts';
/** Sample Bezier control points without drawing them as playable notes. */
export function sampleHold(points: readonly ChartPosition[]): ChartPosition[] {
if (points.length < 2) return [...points];
const result: ChartPosition[] = [points[0]];
let start = points[0];
let controls: ChartPosition[] = [];
for (const end of points.slice(1)) {
if (end.control) { controls.push(end); continue; }
if (controls.length) {
for (let step = 1; step <= 24; step++) {
const t = step / 24;
const work = [start, ...controls, end].map(p => ({ ...p }));
for (let size = work.length - 1; size > 0; size--) {
for (let i = 0; i < size; i++) {
for (const field of ['timeSeconds', 'lane', 'width'] as const) work[i][field] = work[i][field] * (1 - t) + work[i + 1][field] * t;
}
}
result.push(work[0]);
}
} else result.push(end);
start = end;
controls = [];
}
return result;
}
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildScorePoints, scorePointAtTime } from './score-timeline.ts';
import { sampleHold } from './chart-geometry.ts';
test('채보와 그래프는 같은 시각의 모든 판정을 합산하고 해당 확률을 표시한다', () => {
const notes = [1, 1, 3].map((timeSeconds, index) => ({ timeSeconds, combo: index + 1, expectedScore: 80, theoreticalMaxScore: 100, baselineScore: 50 }));
const points = buildScorePoints(notes, [0.9, 0.8, 0.7]);
assert.equal(scorePointAtTime(points, 0.999).maximumScore, 0);
assert.deepEqual(scorePointAtTime(points, 1), { timeSeconds: 1, averageScore: 160, maximumScore: 200, maximumProbability: 0.8 });
assert.equal(scorePointAtTime(points, 2).maximumScore, 200);
assert.equal(scorePointAtTime(points, 100).maximumScore, 300);
const excluded = buildScorePoints(notes.map(n => ({ ...n, expectedScore: 50, theoreticalMaxScore: 50 })), [1, 1, 1]);
assert.equal(scorePointAtTime(excluded, 1).maximumScore, 100);
assert.equal(scorePointAtTime(buildScorePoints([], []), 0).maximumProbability, 1);
assert.equal(scorePointAtTime(buildScorePoints(notes, []), 1).maximumProbability, undefined);
});
test('롱노트의 제어점은 곡선을 만들고 중간 앵커의 너비를 보존한다', () => {
const hold = [{ timeSeconds: 0, lane: 0, width: 2 }, { timeSeconds: 1, lane: 8, width: 4, control: true }, { timeSeconds: 2, lane: 0, width: 2 }, { timeSeconds: 3, lane: 2, width: 3 }];
const points = sampleHold(hold);
assert.equal(points[12].lane, 4);
assert.equal(points[12].timeSeconds, 1);
assert.equal(points[12].width, 3);
assert.deepEqual(points.at(-1), hold.at(-1));
assert.equal(points[24].lane, 0);
assert.deepEqual(sampleHold([]), []);
});
+40
View File
@@ -0,0 +1,40 @@
import type { NoteScorePoint } from './types.ts';
export type ScorePoint = {
timeSeconds: number;
averageScore: number;
maximumScore: number;
maximumProbability?: number;
};
export function buildScorePoints(notes: readonly NoteScorePoint[], probabilities: readonly number[]): ScorePoint[] {
let averageScore = 0;
let maximumScore = 0;
return [{ timeSeconds: 0, averageScore: 0, maximumScore: 0, maximumProbability: 1 }, ...notes.map((note, index) => {
averageScore += note.expectedScore;
maximumScore += note.theoreticalMaxScore;
return { timeSeconds: note.timeSeconds, averageScore, maximumScore, maximumProbability: probabilities[index] };
})];
}
/** Include every simultaneous judgement, exactly as in the cumulative score graph. */
export function scorePointAtTime(points: readonly ScorePoint[], timeSeconds: number): ScorePoint {
let low = 0;
let high = points.length - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (points[middle].timeSeconds <= timeSeconds) low = middle;
else high = middle - 1;
}
return points[low] ?? { timeSeconds: 0, averageScore: 0, maximumScore: 0 };
}
export function formatTimelineScore(score: number): string {
return Math.round(score).toLocaleString('ko-KR');
}
export function formatTimelineProbability(probability: number | undefined): string {
if (probability === undefined) return '—';
if (probability > 0 && probability < 0.000001) return '<0.0001%';
return `${(probability * 100).toLocaleString('ko-KR', { maximumFractionDigits: probability < 0.0001 ? 4 : 2 })}%`;
}
+15
View File
@@ -0,0 +1,15 @@
export type ChartPosition = { timeSeconds: number; lane: number; width: number; control?: boolean };
export type ChartVisualNote = ChartPosition & {
kind: 'tap' | 'flick' | 'hold' | 'relay';
direction?: 'up' | 'left' | 'right';
critical?: boolean;
};
export type ChartVisual = {
schemaVersion: 1;
songId: string;
difficulty: string;
durationSeconds: number;
laneCount: number;
notes: ChartVisualNote[];
holds: ChartPosition[][];
};
+8
View File
@@ -19,6 +19,7 @@
import { releasedSongCharts } from '$lib/data/song-release.ts'; import { releasedSongCharts } from '$lib/data/song-release.ts';
import PresetManager from '$lib/components/PresetManager.svelte'; import PresetManager from '$lib/components/PresetManager.svelte';
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte'; import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
import PlayableChart from '$lib/components/PlayableChart.svelte';
import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts'; import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts';
import SongPicker from '$lib/components/SongPicker.svelte'; import SongPicker from '$lib/components/SongPicker.svelte';
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
@@ -1308,6 +1309,13 @@
</div> </div>
</details> </details>
{/if} {/if}
{#if simulationContext}
<PlayableChart songId={simulationContext.chart.songId} difficulty={simulationContext.chart.difficulty}
chartNotes={simulationContext.chart.notes}
durationSeconds={timelineDuration} notes={displayedResult!.notes} {maximumScoreProbabilities}
activeWindows={displayedEffectiveActiveWindows} specialWindows={result.specialWindows}
cardName={resultCardName} {memberColor} />
{/if}
<p class="board-guide">그래프를 좌우로 밀면 전체 시간대를 확인할 수 있습니다.</p> <p class="board-guide">그래프를 좌우로 밀면 전체 시간대를 확인할 수 있습니다.</p>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users must be able to scroll the timeline.) --> <!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users must be able to scroll the timeline.) -->
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프" tabindex="0"> <div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프" tabindex="0">