feat: simulate individual active skill failures in analysis
Publish GitHub Pages output / publish (push) Successful in 45s
Publish GitHub Pages output / publish (push) Successful in 45s
This commit is contained in:
@@ -17,8 +17,8 @@
|
||||
import PresetManager from '$lib/components/PresetManager.svelte';
|
||||
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
|
||||
import SongPicker from '$lib/components/SongPicker.svelte';
|
||||
import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||
|
||||
@@ -114,6 +114,44 @@
|
||||
let running = false;
|
||||
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||
let result: ExactOptimizationResult | null = null;
|
||||
let simulationContext: { cards: OptimizerCard[]; chart: ExactSongChart; options: ExactScoreOptions } | null = null;
|
||||
let simulationBase: ExactOptimizationResult | null = null;
|
||||
let simulatedResult: ExactOptimizationResult | null = null;
|
||||
let excludedActiveWindows: SkillWindow[] = [];
|
||||
$: if (result !== simulationBase) {
|
||||
simulationBase = result;
|
||||
simulatedResult = null;
|
||||
excludedActiveWindows = [];
|
||||
}
|
||||
$: displayedResult = simulatedResult ?? result;
|
||||
|
||||
function isExcluded(window: SkillWindow): boolean {
|
||||
return excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds);
|
||||
}
|
||||
|
||||
function toggleActiveWindow(window: SkillWindow): void {
|
||||
if (!result || !simulationContext) return;
|
||||
excludedActiveWindows = isExcluded(window)
|
||||
? excludedActiveWindows.filter((excluded) => excluded.cardId !== window.cardId || excluded.startSeconds !== window.startSeconds)
|
||||
: [...excludedActiveWindows, window];
|
||||
if (!excludedActiveWindows.length) {
|
||||
simulatedResult = null;
|
||||
return;
|
||||
}
|
||||
const { cards, chart, options } = simulationContext;
|
||||
const order = result.order.map((id) => cards.find((card) => card.id === id)!);
|
||||
simulatedResult = {
|
||||
...result,
|
||||
...calculateExactScore(order, result.cooldownReductions, chart, { ...options, excludedActiveWindows }),
|
||||
...buildExactSkillWindows(order, result.cooldownReductions, chart, excludedActiveWindows)
|
||||
};
|
||||
}
|
||||
|
||||
function resetActiveWindows(): void {
|
||||
excludedActiveWindows = [];
|
||||
simulatedResult = null;
|
||||
}
|
||||
|
||||
let ratingResultKey = '';
|
||||
let resultCapture: AnalysisCapture | null = null;
|
||||
let pendingCapture: AnalysisCapture | null = null;
|
||||
@@ -176,7 +214,7 @@
|
||||
$: inGamePerfectNoteScore = finalNoteScore(perfectNoteScore, boardEffects.directSongScoreBonusPercent);
|
||||
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
|
||||
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
|
||||
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
|
||||
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(displayedResult?.effectiveActiveWindows ?? []);
|
||||
$: timelineTicks = buildTimelineTicks(timelineDuration);
|
||||
$: timelineWidth = Math.max(720, timelineDuration * 6);
|
||||
$: candidateCount = (() => {
|
||||
@@ -749,6 +787,7 @@
|
||||
}))
|
||||
});
|
||||
const chart = await loadExactSongChart(selectedChart.path);
|
||||
simulationContext = { cards, chart, options: { perfectNoteScore: noteScore, directSongScoreBonusPercent: songBonus } };
|
||||
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
|
||||
worker.postMessage({
|
||||
type: 'optimize-exact',
|
||||
@@ -1037,11 +1076,11 @@
|
||||
</section>
|
||||
</div>
|
||||
<div class="score-grid">
|
||||
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>예상 점수</span><strong>{displayedResult!.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>이론상 고점</span><strong>{displayedResult!.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
|
||||
</div>
|
||||
{#if mode === 'analysis' || achievementProbabilities}
|
||||
{#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)}
|
||||
<div class="probability-grid">
|
||||
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
|
||||
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
|
||||
@@ -1096,21 +1135,34 @@
|
||||
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{resultCardName(cardId)}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if mode === 'analysis'}
|
||||
<p class="simulation-guide" aria-live="polite">카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다.
|
||||
{#if excludedActiveWindows.length}
|
||||
<strong>{excludedActiveWindows.length}회 미발동 적용 중</strong>
|
||||
<button type="button" on:click={resetActiveWindows}>모두 복원</button>
|
||||
<span>미발동 적용 중에는 달성 확률을 표시하지 않습니다. 분석 기록에는 원래 분석 결과가 저장됩니다.</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
|
||||
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
|
||||
<ScoreTimelineChart notes={result.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
|
||||
<ScoreTimelineChart notes={displayedResult!.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
|
||||
{#each result.order as cardId, slot}
|
||||
<div class="timeline-row member-row">
|
||||
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
|
||||
<div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
|
||||
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
class="skill-window active-window"
|
||||
class:excluded={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
|
||||
disabled={mode !== 'analysis' || !simulationContext || running}
|
||||
style={coloredWindowStyle(window, timelineDuration)}
|
||||
title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
|
||||
role="img"
|
||||
aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}
|
||||
></span>
|
||||
title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원`}
|
||||
aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리`}
|
||||
aria-pressed={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
|
||||
on:click={() => toggleActiveWindow(window)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1290,7 +1342,13 @@
|
||||
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
|
||||
.skill-track { height: 36px; }
|
||||
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
||||
.active-window { top: 12px; height: 12px; opacity: .72; }
|
||||
.active-window { top: 8px; height: 20px; opacity: .72; padding: 0; border: 0; cursor: pointer; }
|
||||
.active-window:hover:not(:disabled), .active-window:focus-visible { opacity: 1; outline: 2px solid #fff; outline-offset: 2px; }
|
||||
.active-window.excluded { opacity: .28; border: 1px dashed var(--member-color); background: repeating-linear-gradient(135deg, transparent 0 3px, var(--member-color) 3px 4px); }
|
||||
.active-window:disabled { cursor: default; }
|
||||
.simulation-guide { color: #adb5c2; font-size: 13px; line-height: 1.7; }
|
||||
.simulation-guide strong, .simulation-guide span { display: block; }
|
||||
.simulation-guide button { margin: 4px 0; }
|
||||
.summary-row { background: rgb(255 255 255 / 1.5%); }
|
||||
.summary-label { color: #e1e5ec; font-weight: 800; }
|
||||
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
|
||||
|
||||
Reference in New Issue
Block a user