feat: emphasize active skill windows by score impact
Publish GitHub Pages output / publish (push) Successful in 50s
Publish GitHub Pages output / publish (push) Successful in 50s
This commit is contained in:
+7
-1
@@ -1,7 +1,13 @@
|
|||||||
---
|
---
|
||||||
updatedAt: 2026-09-09T19:13:48+09:00
|
updatedAt: 2026-09-10T10:52:51+09:00
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026년 9월 10일
|
||||||
|
|
||||||
|
### 액티브 점수 영향력 표시
|
||||||
|
|
||||||
|
`NEW` 배치 분석에서 현재 설정의 최고점에 미치는 영향이 큰 액티브를 진한 색과 굵은 테두리로 강조합니다. 구간에 마우스를 올리면 토글 시 점수 변화를 확인할 수 있으며, 미발동 구간은 복원 시 영향력을 표시합니다.
|
||||||
|
|
||||||
## 2026년 9월 9일
|
## 2026년 9월 9일
|
||||||
|
|
||||||
### 조합 탐색 결과 출력 수 선택
|
### 조합 탐색 결과 출력 수 선택
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { activeWindowKey, calculateActiveImpacts } from './active-impact.ts';
|
||||||
|
import { buildExactSkillWindows } from './exact-optimizer.ts';
|
||||||
|
import type { ExactSongChart, OptimizerCard } from './types.ts';
|
||||||
|
|
||||||
|
test('영향력은 겹친 액티브와 스페셜을 반영하며 제외 후 복원 효과로 갱신된다', () => {
|
||||||
|
const cards: OptimizerCard[] = [100, 50].map((scoreUpPercent, index) => ({
|
||||||
|
id: String(index), name: String(index),
|
||||||
|
active: { cooldownSeconds: 10, durationSeconds: 2, scoreUpPercent },
|
||||||
|
special: { durationSeconds: 2, scoreUpPercent: index === 0 ? 100 : 0 }
|
||||||
|
}));
|
||||||
|
const chart: ExactSongChart = {
|
||||||
|
songId: 'synthetic', difficulty: 'expert', durationSeconds: 22, scoreRatio: 1,
|
||||||
|
notes: [10, 20].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
|
||||||
|
specialStartSeconds: [10, null]
|
||||||
|
};
|
||||||
|
const windows = buildExactSkillWindows(cards, {}, chart).activeWindows;
|
||||||
|
const key = (cardId: string, startSeconds: number) => activeWindowKey({ cardId, startSeconds });
|
||||||
|
const impacts = calculateActiveImpacts(cards, {}, chart, { perfectNoteScore: 1000 }, windows);
|
||||||
|
assert.deepEqual(impacts.get(key('0', 10)), { scoreChange: -1000, strength: 1 });
|
||||||
|
assert.deepEqual(impacts.get(key('0', 20)), { scoreChange: -500, strength: 0.5 });
|
||||||
|
assert.deepEqual(impacts.get(key('1', 10)), { scoreChange: 0, strength: 0 });
|
||||||
|
const after = calculateActiveImpacts(cards, {}, chart, {
|
||||||
|
perfectNoteScore: 1000, excludedActiveWindows: [{ cardId: '0', startSeconds: 10 }]
|
||||||
|
}, windows);
|
||||||
|
assert.deepEqual(after.get(key('0', 10)), { scoreChange: 1000, strength: 1 });
|
||||||
|
assert.deepEqual(after.get(key('1', 10)), { scoreChange: -1000, strength: 1 });
|
||||||
|
const empty = calculateActiveImpacts(cards, {}, { ...chart, notes: [] }, { perfectNoteScore: 1000 }, windows);
|
||||||
|
assert.ok([...empty.values()].every((value) => value.scoreChange === 0 && value.strength === 0));
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { calculateExactScore } from './score.ts';
|
||||||
|
import type { CooldownReduction, ExactScoreOptions, ExactSongChart, OptimizerCard, SkillWindow } from './types.ts';
|
||||||
|
|
||||||
|
export function activeWindowKey(window: Pick<SkillWindow, 'cardId' | 'startSeconds'>): string {
|
||||||
|
return JSON.stringify([window.cardId, window.startSeconds]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current-setting marginal maximum-score change when each activation is toggled. */
|
||||||
|
export function calculateActiveImpacts(
|
||||||
|
cards: readonly OptimizerCard[],
|
||||||
|
reductions: Readonly<Record<string, CooldownReduction>>,
|
||||||
|
chart: ExactSongChart,
|
||||||
|
options: ExactScoreOptions,
|
||||||
|
windows: readonly SkillWindow[]
|
||||||
|
): Map<string, { scoreChange: number; strength: number }> {
|
||||||
|
const excluded = options.excludedActiveWindows ?? [];
|
||||||
|
const excludedKeys = new Set(excluded.map(activeWindowKey));
|
||||||
|
const current = calculateExactScore(cards, reductions, chart, options).theoreticalMaxScore;
|
||||||
|
const changes = windows.map((window) => {
|
||||||
|
const key = activeWindowKey(window);
|
||||||
|
const toggled = excludedKeys.has(key)
|
||||||
|
? excluded.filter((entry) => activeWindowKey(entry) !== key)
|
||||||
|
: [...excluded, window];
|
||||||
|
const score = calculateExactScore(cards, reductions, chart, { ...options, excludedActiveWindows: toggled });
|
||||||
|
return { key, scoreChange: score.theoreticalMaxScore - current };
|
||||||
|
});
|
||||||
|
const maximum = Math.max(0, ...changes.map((entry) => Math.abs(entry.scoreChange)));
|
||||||
|
return new Map(changes.map(({ key, scoreChange }) => [key, {
|
||||||
|
scoreChange, strength: maximum > 0 ? Math.abs(scoreChange) / maximum : 0
|
||||||
|
}]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { calculateExactScore } from './score.ts';
|
||||||
|
import { buildExactSkillWindows } from './exact-optimizer.ts';
|
||||||
|
import type { ExactSongChart, OptimizerCard } from './types.ts';
|
||||||
|
|
||||||
|
const strong: OptimizerCard = {
|
||||||
|
id: 'strong', name: 'strong',
|
||||||
|
active: { cooldownSeconds: 10, durationSeconds: 4, scoreUpPercent: 100, probabilityPercent: 50 },
|
||||||
|
special: { durationSeconds: 2, scoreUpPercent: 145 }
|
||||||
|
};
|
||||||
|
const weak: OptimizerCard = {
|
||||||
|
id: 'weak', name: 'weak',
|
||||||
|
active: { cooldownSeconds: 10, durationSeconds: 4, scoreUpPercent: 50, probabilityPercent: 100 },
|
||||||
|
special: { durationSeconds: 0, scoreUpPercent: 0 }
|
||||||
|
};
|
||||||
|
const chart: ExactSongChart = {
|
||||||
|
songId: 'synthetic-special-overlap', difficulty: 'expert', durationSeconds: 25, scoreRatio: 1,
|
||||||
|
specialStartSeconds: [11, null],
|
||||||
|
notes: [10, 11, 12, 13, 14, 20].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false }))
|
||||||
|
};
|
||||||
|
const excludedActiveWindows = [{ cardId: 'strong', startSeconds: 10 }];
|
||||||
|
const options = { perfectNoteScore: 1000 };
|
||||||
|
|
||||||
|
test('스페셜 중첩 발동을 제외해도 같은 카드의 스페셜은 대체 액티브에 유지된다', () => {
|
||||||
|
const before = calculateExactScore([strong, weak], {}, chart, options);
|
||||||
|
const after = calculateExactScore([strong, weak], {}, chart, { ...options, excludedActiveWindows });
|
||||||
|
// 11~13초에는 액티브 100%/50%에 서포트 145%를 한 번만 적용한다.
|
||||||
|
assert.deepEqual(before.notes.map((note) => note.theoreticalMaxScore), [2000, 3450, 3450, 2000, 1000, 2000]);
|
||||||
|
assert.deepEqual(after.notes.map((note) => note.theoreticalMaxScore), [1500, 2225, 2225, 1500, 1000, 2000]);
|
||||||
|
assert.equal(before.theoreticalMaxScore - after.theoreticalMaxScore, 3450);
|
||||||
|
assert.deepEqual(before.notes.map((note, index) => note.theoreticalMaxScore - after.notes[index].theoreticalMaxScore), [500, 1225, 1225, 500, 0, 0]);
|
||||||
|
const windows = buildExactSkillWindows([strong, weak], {}, chart, excludedActiveWindows);
|
||||||
|
assert.deepEqual(windows.specialWindows, buildExactSkillWindows([strong, weak], {}, chart).specialWindows);
|
||||||
|
const winner = windows.effectiveActiveWindows.find((window) => window.startSeconds <= 11 && 11 < window.endSeconds);
|
||||||
|
assert.equal(winner?.cardId, 'weak');
|
||||||
|
assert.ok(Math.abs((winner?.scoreUpPercent ?? 0) - 122.5) < 1e-9);
|
||||||
|
assert.deepEqual(calculateExactScore([strong, weak], {}, chart, { ...options, excludedActiveWindows: [] }), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('대체 액티브가 없으면 스페셜 중첩 부분을 포함한 발동 전체의 증가분만 사라진다', () => {
|
||||||
|
const before = calculateExactScore([strong], {}, chart, options);
|
||||||
|
const after = calculateExactScore([strong], {}, chart, { ...options, excludedActiveWindows });
|
||||||
|
assert.deepEqual(after.notes.map((note) => note.theoreticalMaxScore), [1000, 1000, 1000, 1000, 1000, 2000]);
|
||||||
|
assert.equal(before.theoreticalMaxScore - after.theoreticalMaxScore, 6900);
|
||||||
|
assert.equal(before.baselineScore, after.baselineScore);
|
||||||
|
// 최고점 변화는 성공을 가정한다. 발동률 50%를 적용한 기대값 변화와 다르다.
|
||||||
|
assert.equal(before.expectedScore - after.expectedScore, 3450);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('스페셜이 중첩된 발동도 여러 토글의 증감 합과 원래 대비 차이가 일치한다', () => {
|
||||||
|
const cards = [strong, weak];
|
||||||
|
const before = calculateExactScore(cards, {}, chart, options);
|
||||||
|
const first = calculateExactScore(cards, {}, chart, { ...options, excludedActiveWindows });
|
||||||
|
const both = calculateExactScore(cards, {}, chart, {
|
||||||
|
...options, excludedActiveWindows: [...excludedActiveWindows, { cardId: 'weak', startSeconds: 10 }]
|
||||||
|
});
|
||||||
|
assert.equal(before.theoreticalMaxScore - first.theoreticalMaxScore, 3450);
|
||||||
|
assert.equal(first.theoreticalMaxScore - both.theoreticalMaxScore, 3450);
|
||||||
|
assert.equal(before.theoreticalMaxScore - both.theoreticalMaxScore, 6900);
|
||||||
|
const weakOnly = calculateExactScore(cards, {}, chart, {
|
||||||
|
...options, excludedActiveWindows: [{ cardId: 'weak', startSeconds: 10 }]
|
||||||
|
});
|
||||||
|
assert.equal(weakOnly.theoreticalMaxScore, before.theoreticalMaxScore);
|
||||||
|
assert.ok(weakOnly.expectedScore < before.expectedScore);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
|
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
|
||||||
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
|
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
|
||||||
import { analytics } from '$lib/analytics.ts';
|
import { analytics } from '$lib/analytics.ts';
|
||||||
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
|
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
|
||||||
@@ -133,6 +134,13 @@
|
|||||||
excludedActiveWindows = [];
|
excludedActiveWindows = [];
|
||||||
}
|
}
|
||||||
$: displayedResult = simulatedResult ?? result;
|
$: displayedResult = simulatedResult ?? result;
|
||||||
|
$: activeImpacts = result && simulationContext && mode === 'analysis'
|
||||||
|
? calculateActiveImpacts(
|
||||||
|
result.order.map((id) => simulationContext!.cards.find((card) => card.id === id)!),
|
||||||
|
result.cooldownReductions, simulationContext.chart,
|
||||||
|
{ ...simulationContext.options, excludedActiveWindows }, result.activeWindows
|
||||||
|
)
|
||||||
|
: new Map<string, { scoreChange: number; strength: number }>();
|
||||||
|
|
||||||
function isExcluded(window: SkillWindow): boolean {
|
function isExcluded(window: SkillWindow): boolean {
|
||||||
return excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds);
|
return excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds);
|
||||||
@@ -1197,7 +1205,7 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="simulation-guide" aria-live="polite">카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다.
|
<p class="simulation-guide" aria-live="polite">카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다. 색이 진하고 테두리가 굵을수록 현재 설정에서 토글할 때 최고점 변화가 큽니다. 빗금은 미발동 상태이며, 해당 구간은 복원 시 영향력을 표시합니다.
|
||||||
{#if excludedActiveWindows.length}
|
{#if excludedActiveWindows.length}
|
||||||
<strong>{excludedActiveWindows.length}회 미발동 적용 중</strong>
|
<strong>{excludedActiveWindows.length}회 미발동 적용 중</strong>
|
||||||
<button type="button" on:click={resetActiveWindows}>모두 복원</button>
|
<button type="button" on:click={resetActiveWindows}>모두 복원</button>
|
||||||
@@ -1213,14 +1221,15 @@
|
|||||||
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
|
<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)}>
|
<div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
|
||||||
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
|
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
|
||||||
|
{@const impact = activeImpacts.get(activeWindowKey(window))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-window active-window"
|
class="skill-window active-window"
|
||||||
class:excluded={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
|
class:excluded={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
|
||||||
disabled={mode !== 'analysis' || !simulationContext || running}
|
disabled={mode !== 'analysis' || !simulationContext || running}
|
||||||
style={coloredWindowStyle(window, timelineDuration)}
|
style={coloredWindowStyle(window, timelineDuration) + (impact ? ` --impact-opacity: ${0.25 + 0.75 * Math.sqrt(impact.strength)}; --impact-border: ${impact.strength > 0 ? 1 + 2 * impact.strength : 0}px;` : '')}
|
||||||
title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원`}
|
title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원${impact ? ` · 토글 시 최고점 ${signedScoreChange(impact.scoreChange)}` : ''}`}
|
||||||
aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리`}
|
aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리${impact ? ` · 토글 시 최고점 ${signedScoreChange(impact.scoreChange)}` : ''}`}
|
||||||
aria-pressed={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
|
aria-pressed={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
|
||||||
on:click={() => toggleActiveWindow(window)}
|
on:click={() => toggleActiveWindow(window)}
|
||||||
></button>
|
></button>
|
||||||
@@ -1405,9 +1414,9 @@
|
|||||||
.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; }
|
.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-track { height: 36px; }
|
||||||
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
||||||
.active-window { top: 8px; height: 20px; opacity: .72; padding: 0; border: 0; cursor: pointer; }
|
.active-window { top: 8px; height: 20px; opacity: var(--impact-opacity, .72); padding: 0; border: 0; box-shadow: inset 0 0 0 var(--impact-border, 0px) rgb(255 255 255 / 85%); cursor: pointer; }
|
||||||
.active-window:hover:not(:disabled), .active-window:focus-visible { opacity: 1; outline: 2px solid #fff; outline-offset: 2px; }
|
.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.excluded { opacity: var(--impact-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; }
|
.active-window:disabled { cursor: default; }
|
||||||
.timeline-score-summary { display: flex; flex-wrap: wrap; gap: 16px 32px; padding: 16px; margin-bottom: 12px; border: 1px solid #454052; border-radius: 10px; background: #22202a; }
|
.timeline-score-summary { display: flex; flex-wrap: wrap; gap: 16px 32px; padding: 16px; margin-bottom: 12px; border: 1px solid #454052; border-radius: 10px; background: #22202a; }
|
||||||
.timeline-score-summary > div { display: grid; gap: 6px; }
|
.timeline-score-summary > div { display: grid; gap: 6px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user