feat: improve combination search and offline build
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.svelte-kit
|
.svelte-kit
|
||||||
build
|
build
|
||||||
|
offline-dist
|
||||||
|
release
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import HomePage from '../src/routes/+page.svelte';
|
||||||
|
import CardsPage from '../src/routes/cards/+page.svelte';
|
||||||
|
import AnalysisPage from '../src/routes/analysis/+page.svelte';
|
||||||
|
import CombinationPage from '../src/routes/combination/+page.svelte';
|
||||||
|
import DataPage from '../src/routes/data/+page.svelte';
|
||||||
|
import DataCardsPage from '../src/routes/data/cards/+page.svelte';
|
||||||
|
import DataBoardsPage from '../src/routes/data/boards/+page.svelte';
|
||||||
|
import { setOfflinePage } from './page-store';
|
||||||
|
|
||||||
|
const routes = new Map([
|
||||||
|
['/', HomePage],
|
||||||
|
['/cards', CardsPage],
|
||||||
|
['/analysis', AnalysisPage],
|
||||||
|
['/combination', CombinationPage],
|
||||||
|
['/data', DataPage],
|
||||||
|
['/data/cards', DataCardsPage],
|
||||||
|
['/data/boards', DataBoardsPage]
|
||||||
|
]);
|
||||||
|
|
||||||
|
function pathFromHash() {
|
||||||
|
const path = window.location.hash.replace(/^#/, '') || '/';
|
||||||
|
return routes.has(path) ? path : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPath = pathFromHash();
|
||||||
|
let CurrentPage = routes.get(currentPath) ?? HomePage;
|
||||||
|
setOfflinePage(currentPath);
|
||||||
|
|
||||||
|
function syncRoute() {
|
||||||
|
currentPath = pathFromHash();
|
||||||
|
CurrentPage = routes.get(currentPath) ?? HomePage;
|
||||||
|
setOfflinePage(currentPath);
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const rewriteInternalLinks = () => {
|
||||||
|
for (const anchor of document.querySelectorAll<HTMLAnchorElement>('a[href^="/"]')) {
|
||||||
|
anchor.href = `#${anchor.getAttribute('href')}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[href]') : null;
|
||||||
|
const href = target?.getAttribute('href');
|
||||||
|
if (!href?.startsWith('/') || target?.target === '_blank') return;
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.hash = `#${href}`;
|
||||||
|
};
|
||||||
|
rewriteInternalLinks();
|
||||||
|
const linkObserver = new MutationObserver(rewriteInternalLinks);
|
||||||
|
linkObserver.observe(document.body, { childList: true, subtree: true });
|
||||||
|
window.addEventListener('hashchange', syncRoute);
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', syncRoute);
|
||||||
|
document.removeEventListener('click', handleClick);
|
||||||
|
linkObserver.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="site-nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a class="brand" href="#/" aria-label="홀로도리 계산기 홈">홀로도리 계산기</a>
|
||||||
|
<nav aria-label="주요 메뉴">
|
||||||
|
<a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
|
||||||
|
<a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
|
||||||
|
<a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
|
||||||
|
<a href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<svelte:component this={CurrentPage} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(*) { box-sizing: border-box; }
|
||||||
|
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 20;
|
||||||
|
top: 0;
|
||||||
|
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
background: rgb(16 19 25 / 88%);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: min(var(--page-max-width), calc(100% - var(--page-gutter)));
|
||||||
|
min-height: 62px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: #f3f5f8;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav { display: flex; align-items: center; gap: 4px; }
|
||||||
|
nav a {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #929cad;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
nav a:hover { color: #e2e6ed; background: rgb(255 255 255 / 5%); }
|
||||||
|
nav a[aria-current='page'] { color: #f1a1c3; background: rgb(239 139 182 / 12%); }
|
||||||
|
a:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
:global(:root) { --page-gutter: 24px; }
|
||||||
|
.nav-inner { width: calc(100% - 24px); min-height: 54px; }
|
||||||
|
.brand { font-size: 15px; }
|
||||||
|
nav a { padding: 7px 9px; font-size: 14px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ExactSongChart } from '$lib/engine/index.ts';
|
||||||
|
|
||||||
|
const chartSources = import.meta.glob('../static/data/charts/*.json', {
|
||||||
|
eager: true,
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default'
|
||||||
|
}) as Record<string, string>;
|
||||||
|
|
||||||
|
const sourceByFilename = new Map(
|
||||||
|
Object.entries(chartSources).map(([path, source]) => [path.split('/').at(-1) ?? path, source])
|
||||||
|
);
|
||||||
|
const parsedCharts = new Map<string, ExactSongChart>();
|
||||||
|
|
||||||
|
export async function loadExactSongChart(path: string): Promise<ExactSongChart> {
|
||||||
|
const filename = path.split('/').at(-1) ?? path;
|
||||||
|
const cached = parsedCharts.get(filename);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const source = sourceByFilename.get(filename);
|
||||||
|
if (!source) throw new Error(`포함된 채보 데이터를 찾지 못했습니다: ${filename}`);
|
||||||
|
const chart = JSON.parse(source) as ExactSongChart;
|
||||||
|
parsedCharts.set(filename, chart);
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#111827" />
|
||||||
|
<title>홀로도리 계산기</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { mount } from 'svelte';
|
||||||
|
import App from './OfflineApp.svelte';
|
||||||
|
import '../src/lib/styles/layout-tokens.css';
|
||||||
|
|
||||||
|
mount(App, {
|
||||||
|
target: document.getElementById('app')!
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
function pageValue(pathname: string) {
|
||||||
|
return { url: new URL(pathname, 'https://offline.holodori.local') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const page = writable(pageValue('/'));
|
||||||
|
|
||||||
|
export function setOfflinePage(pathname: string) {
|
||||||
|
page.set(pageValue(pathname));
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0 --port 5173",
|
"dev": "vite dev --host 0.0.0.0 --port 5173",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"build:offline": "vite build --config vite.offline.config.ts && node scripts/finalize-offline-build.mjs",
|
||||||
|
"package:offline": "npm run build:offline && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-offline.ps1",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"test:engine": "node --test src/lib/engine/*.test.ts",
|
"test:engine": "node --test src/lib/engine/*.test.ts",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const output = path.resolve('offline-dist');
|
||||||
|
const htmlPath = path.join(output, 'index.html');
|
||||||
|
const scriptPath = path.join(output, 'assets', 'app.js');
|
||||||
|
const stylePath = path.join(output, 'assets', 'app.css');
|
||||||
|
const packageJson = JSON.parse(await readFile(path.resolve('package.json'), 'utf8'));
|
||||||
|
|
||||||
|
let html = await readFile(htmlPath, 'utf8');
|
||||||
|
html = html.replace(/<script type="module" crossorigin src=/g, '<script defer src=');
|
||||||
|
html = html.replace(/<link rel="stylesheet" crossorigin href=/g, '<link rel="stylesheet" href=');
|
||||||
|
await writeFile(htmlPath, html);
|
||||||
|
|
||||||
|
let script = await readFile(scriptPath, 'utf8');
|
||||||
|
script = script.replaceAll('/icons/', './icons/');
|
||||||
|
await writeFile(scriptPath, script);
|
||||||
|
|
||||||
|
let style = await readFile(stylePath, 'utf8');
|
||||||
|
style = style.replaceAll('/icons/', '../icons/');
|
||||||
|
style += `
|
||||||
|
/* file://에서는 CSS 마스크 대신 읽기 쉬운 텍스트 뱃지를 사용한다. */
|
||||||
|
.description-stat-icon { display: none !important; }
|
||||||
|
.stat-badge .stat-icon,
|
||||||
|
.filter-options label .stat-icon {
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
background: none !important;
|
||||||
|
mask: none !important;
|
||||||
|
-webkit-mask: none !important;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.stat-badge .stat-icon-performance::after,
|
||||||
|
.filter-options label .stat-icon-performance::after { content: '퍼포먼스'; }
|
||||||
|
.stat-badge .stat-icon-technique::after,
|
||||||
|
.filter-options label .stat-icon-technique::after { content: '테크닉'; }
|
||||||
|
.stat-badge .stat-icon-sense::after,
|
||||||
|
.filter-options label .stat-icon-sense::after { content: '센스'; }
|
||||||
|
`;
|
||||||
|
await writeFile(stylePath, style);
|
||||||
|
|
||||||
|
// 채보는 app.js에 포함되므로 public 디렉터리에서 복사된 중복 파일은 제거한다.
|
||||||
|
await rm(path.join(output, 'data'), { recursive: true, force: true });
|
||||||
|
|
||||||
|
await writeFile(path.join(output, 'VERSION.txt'), `${packageJson.version}\n`);
|
||||||
|
await writeFile(path.join(output, 'README.txt'), `홀로도리 계산기 ${packageJson.version}
|
||||||
|
|
||||||
|
1. 압축파일의 내용을 모두 한 폴더에 풀어 주세요.
|
||||||
|
2. index.html을 Chrome 또는 Edge로 열어 주세요.
|
||||||
|
3. assets와 icons 폴더는 index.html과 같은 위치에 그대로 두어야 합니다.
|
||||||
|
|
||||||
|
계산과 데이터 저장은 사용자의 브라우저에서 처리됩니다.
|
||||||
|
새 버전으로 교체하기 전에는 사용자 데이터 화면에서 백업 파일을 저장해 주세요.
|
||||||
|
`);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$package = Get-Content -LiteralPath (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json
|
||||||
|
$source = Join-Path $projectRoot 'offline-dist'
|
||||||
|
$releaseDirectory = Join-Path $projectRoot 'release'
|
||||||
|
$archive = Join-Path $releaseDirectory ("HolodoriCalc-offline-v{0}.zip" -f $package.version)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $source)) {
|
||||||
|
throw '오프라인 빌드 결과가 없습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $releaseDirectory -Force | Out-Null
|
||||||
|
if (Test-Path -LiteralPath $archive) {
|
||||||
|
Remove-Item -LiteralPath $archive -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Compress-Archive -Path (Join-Path $source '*') -DestinationPath $archive -CompressionLevel Optimal
|
||||||
|
Write-Output $archive
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ExactSongChart } from '$lib/engine/index.ts';
|
||||||
|
|
||||||
|
export async function loadExactSongChart(path: string): Promise<ExactSongChart> {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
|
||||||
|
return response.json() as Promise<ExactSongChart>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { generateCombinationCandidates } from './index.ts';
|
import { generateCombinationCandidates, resolveCandidateAwakening } from './index.ts';
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{ id: 'a-1', characterId: 'a', score: 10 },
|
{ id: 'a-1', characterId: 'a', score: 10 },
|
||||||
@@ -20,3 +20,27 @@ test('빔 너비만큼 점수가 높은 조합을 남긴다', () => {
|
|||||||
assert.equal(result.length, 2);
|
assert.equal(result.length, 2);
|
||||||
assert.deepEqual(result[0].map((card) => card.id).sort(), ['b-1', 'c-1']);
|
assert.deepEqual(result[0].map((card) => card.id).sort(), ['b-1', 'c-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('편성 점수로 카드 간 시너지를 선별할 수 있다', () => {
|
||||||
|
const synergyCards = [
|
||||||
|
{ id: 'a-1', characterId: 'a', score: 10, group: 'x' },
|
||||||
|
{ id: 'b-1', characterId: 'b', score: 9, group: 'x' },
|
||||||
|
{ id: 'c-1', characterId: 'c', score: 20, group: 'y' }
|
||||||
|
];
|
||||||
|
const result = generateCombinationCandidates(
|
||||||
|
[],
|
||||||
|
synergyCards,
|
||||||
|
2,
|
||||||
|
(card) => card.score,
|
||||||
|
2,
|
||||||
|
(selected) => selected.reduce((sum, card) => sum + card.score, 0) +
|
||||||
|
(selected.filter((card) => card.group === 'x').length >= 2 ? 100 : 0)
|
||||||
|
);
|
||||||
|
assert.deepEqual(result[0].map((card) => card.id).sort(), ['a-1', 'b-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('보유한 자동 후보는 설정값보다 실제 개화가 높으면 실제 개화를 사용한다', () => {
|
||||||
|
assert.equal(resolveCandidateAwakening(2, true, 4), 4);
|
||||||
|
assert.equal(resolveCandidateAwakening(4, true, 2), 4);
|
||||||
|
assert.equal(resolveCandidateAwakening(2, false, 5), 2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ type BeamState<T extends CombinationSearchCard> = {
|
|||||||
score: number;
|
score: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function resolveCandidateAwakening(
|
||||||
|
configuredAwakening: number,
|
||||||
|
owned: boolean,
|
||||||
|
ownedAwakening: number
|
||||||
|
): number {
|
||||||
|
return owned ? Math.max(configuredAwakening, ownedAwakening) : configuredAwakening;
|
||||||
|
}
|
||||||
|
|
||||||
export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
||||||
fixedCards: readonly T[],
|
fixedCards: readonly T[],
|
||||||
candidateCards: readonly T[],
|
candidateCards: readonly T[],
|
||||||
targetSize: number,
|
targetSize: number,
|
||||||
scoreCard: (card: T) => number,
|
scoreCard: (card: T) => number,
|
||||||
beamWidth = 800
|
beamWidth = 800,
|
||||||
|
scoreCombination?: (cards: readonly T[]) => number
|
||||||
): T[][] {
|
): T[][] {
|
||||||
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
|
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
|
||||||
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
|
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
|
||||||
@@ -24,7 +33,7 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
|||||||
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId));
|
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId));
|
||||||
let beam: BeamState<T>[] = [{
|
let beam: BeamState<T>[] = [{
|
||||||
cards: Array.from(fixedCards),
|
cards: Array.from(fixedCards),
|
||||||
score: fixedCards.reduce((sum, card) => sum + scoreCard(card), 0)
|
score: scoreCombination?.(fixedCards) ?? fixedCards.reduce((sum, card) => sum + scoreCard(card), 0)
|
||||||
}];
|
}];
|
||||||
|
|
||||||
for (let size = fixedCards.length; size < targetSize; size += 1) {
|
for (let size = fixedCards.length; size < targetSize; size += 1) {
|
||||||
@@ -35,9 +44,10 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
|||||||
for (const card of pool) {
|
for (const card of pool) {
|
||||||
if (usedIds.has(card.id)) continue;
|
if (usedIds.has(card.id)) continue;
|
||||||
if (usedCharacters.has(card.characterId)) continue;
|
if (usedCharacters.has(card.characterId)) continue;
|
||||||
|
const cards = [...state.cards, card];
|
||||||
const nextState = {
|
const nextState = {
|
||||||
cards: [...state.cards, card],
|
cards,
|
||||||
score: state.score + scoreCard(card)
|
score: scoreCombination?.(cards) ?? state.score + scoreCard(card)
|
||||||
};
|
};
|
||||||
const key = nextState.cards.map((entry) => entry.id).sort().join('|');
|
const key = nextState.cards.map((entry) => entry.id).sort().join('|');
|
||||||
if (!expanded.has(key)) expanded.set(key, nextState);
|
if (!expanded.has(key)) expanded.set(key, nextState);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
:root {
|
||||||
|
--page-max-width: 1520px;
|
||||||
|
--page-gutter: clamp(40px, 5vw, 96px);
|
||||||
|
--page-title-size: 72px;
|
||||||
|
--page-content-top: 48px;
|
||||||
|
--page-header-bottom: 28px;
|
||||||
|
--page-description-size: 16px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import ScoreOptimizerWorker from './score-optimizer.worker.ts?worker&inline';
|
||||||
|
|
||||||
|
export function createScoreOptimizerWorker(): Worker {
|
||||||
|
return new ScoreOptimizerWorker();
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import '$lib/styles/layout-tokens.css';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="site-nav">
|
<header class="site-nav">
|
||||||
@@ -17,11 +18,6 @@
|
|||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(:root) {
|
|
||||||
--page-max-width: 1520px;
|
|
||||||
--page-gutter: clamp(40px, 5vw, 96px);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(*) { box-sizing: border-box; }
|
:global(*) { box-sizing: border-box; }
|
||||||
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
|
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
import boardSummary from '$lib/data/boards-summary.json';
|
import boardSummary from '$lib/data/boards-summary.json';
|
||||||
import memorySummary from '$lib/data/memory-summary.json';
|
import memorySummary from '$lib/data/memory-summary.json';
|
||||||
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
||||||
|
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
||||||
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, SkillWindow } from '$lib/engine/index.ts';
|
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, SkillWindow } from '$lib/engine/index.ts';
|
||||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||||
|
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||||
|
|
||||||
type CardRow = (typeof cardSummary.cards)[number];
|
type CardRow = (typeof cardSummary.cards)[number];
|
||||||
type DifficultyKey = 'hard' | 'expert';
|
type DifficultyKey = 'hard' | 'expert';
|
||||||
@@ -175,9 +177,7 @@
|
|||||||
analysisReady = true;
|
analysisReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
worker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), {
|
worker = createScoreOptimizerWorker();
|
||||||
type: 'module'
|
|
||||||
});
|
|
||||||
worker.onmessage = (event) => {
|
worker.onmessage = (event) => {
|
||||||
if (event.data.type === 'progress') progress = event.data.progress;
|
if (event.data.type === 'progress') progress = event.data.progress;
|
||||||
if (event.data.type === 'result') {
|
if (event.data.type === 'result') {
|
||||||
@@ -334,9 +334,7 @@
|
|||||||
return optimizerCardWithBoard(card, index, unitMembers);
|
return optimizerCardWithBoard(card, index, unitMembers);
|
||||||
});
|
});
|
||||||
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
|
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
|
||||||
const response = await fetch(selectedChart.path);
|
const chart = await loadExactSongChart(selectedChart.path);
|
||||||
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
|
|
||||||
const chart = await response.json() as ExactSongChart;
|
|
||||||
running = true;
|
running = true;
|
||||||
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
|
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
|
||||||
worker.postMessage({
|
worker.postMessage({
|
||||||
@@ -618,10 +616,10 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: 46px 0 80px; }
|
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
|
||||||
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
|
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: var(--page-header-bottom); }
|
||||||
h1 { margin: 0; color: #f3f5f8; font-size: clamp(28px, 5vw, 42px); letter-spacing: -.045em; }
|
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
|
||||||
.page-description { margin: 10px 0 0; color: #929cab; font-size: 14px; line-height: 1.6; }
|
.page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; }
|
||||||
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
|
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
|
||||||
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
.section-title > div { justify-content: flex-start; }
|
.section-title > div { justify-content: flex-start; }
|
||||||
@@ -712,7 +710,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
main { width: calc(100% - 24px); padding-top: 28px; }
|
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
|
||||||
.page-header { align-items: flex-start; }
|
.page-header { align-items: flex-start; }
|
||||||
.panel { padding: 17px; }
|
.panel { padding: 17px; }
|
||||||
.song-fields { grid-template-columns: 1fr 100px; }
|
.song-fields { grid-template-columns: 1fr 100px; }
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
const skillConditionOptions: SkillConditionStatus[] = ['일반', '조건부'];
|
const skillConditionOptions: SkillConditionStatus[] = ['일반', '조건부'];
|
||||||
const activeProbabilityOptions: ActiveProbability[] = ['높음', '중간'];
|
const activeProbabilityOptions: ActiveProbability[] = ['높음', '중간'];
|
||||||
const cardVariantOptions = ['일반', ...new Set(cards.map((card) => card.versionName).filter(Boolean))];
|
const cardVariantOptions = ['일반', ...new Set(cards.map((card) => card.versionName).filter(Boolean))];
|
||||||
const generationOptions = [...new Set(cards.map((card) => card.generation).filter(Boolean))];
|
const generationOptions = [...new Set(cards.flatMap((card) => generationMemberships(card.generation)))];
|
||||||
|
|
||||||
let selectedTypes = [...typeOptions];
|
let selectedTypes = [...typeOptions];
|
||||||
let selectedStats = [...statOptions];
|
let selectedStats = [...statOptions];
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
|
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
|
||||||
&& selectedActiveProbabilities.includes(activeProbabilityStatus(card.details.active))
|
&& selectedActiveProbabilities.includes(activeProbabilityStatus(card.details.active))
|
||||||
&& selectedCardVariants.includes(cardVariantStatus(card))
|
&& selectedCardVariants.includes(cardVariantStatus(card))
|
||||||
&& selectedGenerations.includes(card.generation)
|
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
|
||||||
);
|
);
|
||||||
|
|
||||||
$: allSelected = selectedTypes.length === typeOptions.length
|
$: allSelected = selectedTypes.length === typeOptions.length
|
||||||
@@ -158,7 +158,8 @@
|
|||||||
selectedSpecialConditions = data.preferences.specialConditions.filter((value): value is SkillConditionStatus => skillConditionOptions.includes(value as SkillConditionStatus));
|
selectedSpecialConditions = data.preferences.specialConditions.filter((value): value is SkillConditionStatus => skillConditionOptions.includes(value as SkillConditionStatus));
|
||||||
selectedActiveProbabilities = data.preferences.activeProbabilities.filter((value): value is ActiveProbability => activeProbabilityOptions.includes(value as ActiveProbability));
|
selectedActiveProbabilities = data.preferences.activeProbabilities.filter((value): value is ActiveProbability => activeProbabilityOptions.includes(value as ActiveProbability));
|
||||||
selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value));
|
selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value));
|
||||||
selectedGenerations = data.preferences.generations.filter((value) => generationOptions.includes(value));
|
selectedGenerations = [...new Set(data.preferences.generations.flatMap(generationMemberships))]
|
||||||
|
.filter((value) => generationOptions.includes(value));
|
||||||
collection = data.collection;
|
collection = data.collection;
|
||||||
memberBoards = data.memberBoards;
|
memberBoards = data.memberBoards;
|
||||||
boardCards = data.boardCards;
|
boardCards = data.boardCards;
|
||||||
@@ -171,6 +172,10 @@
|
|||||||
return card.versionName || '일반';
|
return card.versionName || '일반';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generationMemberships(generation: string): string[] {
|
||||||
|
return generation.split(/\s*\/\s*/).filter(Boolean).map(generationLabel);
|
||||||
|
}
|
||||||
|
|
||||||
function generationLabel(generation: string): string {
|
function generationLabel(generation: string): string {
|
||||||
return generation
|
return generation
|
||||||
.replace(/^hololive EN /, '')
|
.replace(/^hololive EN /, '')
|
||||||
@@ -459,7 +464,6 @@
|
|||||||
<div class="skill-grid">
|
<div class="skill-grid">
|
||||||
<section class="skill-card passive-card">
|
<section class="skill-card passive-card">
|
||||||
<header>
|
<header>
|
||||||
<span class="skill-symbol" aria-hidden="true">패</span>
|
|
||||||
<h4>패시브</h4>
|
<h4>패시브</h4>
|
||||||
</header>
|
</header>
|
||||||
{#if passive}
|
{#if passive}
|
||||||
@@ -477,7 +481,6 @@
|
|||||||
|
|
||||||
<section class="skill-card active-card">
|
<section class="skill-card active-card">
|
||||||
<header>
|
<header>
|
||||||
<span class="skill-symbol" aria-hidden="true">액</span>
|
|
||||||
<h4>액티브</h4>
|
<h4>액티브</h4>
|
||||||
{#if active?.condition}<span class="condition-badge">조건부</span>{/if}
|
{#if active?.condition}<span class="condition-badge">조건부</span>{/if}
|
||||||
</header>
|
</header>
|
||||||
@@ -529,7 +532,6 @@
|
|||||||
|
|
||||||
<section class="skill-card special-card">
|
<section class="skill-card special-card">
|
||||||
<header>
|
<header>
|
||||||
<span class="skill-symbol" aria-hidden="true">스</span>
|
|
||||||
<h4>스페셜</h4>
|
<h4>스페셜</h4>
|
||||||
{#if special?.condition}<span class="condition-badge">조건부</span>{/if}
|
{#if special?.condition}<span class="condition-badge">조건부</span>{/if}
|
||||||
</header>
|
</header>
|
||||||
@@ -624,7 +626,7 @@
|
|||||||
main {
|
main {
|
||||||
width: min(var(--page-max-width), calc(100% - var(--page-gutter)));
|
width: min(var(--page-max-width), calc(100% - var(--page-gutter)));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 36px 0 80px;
|
padding: var(--page-content-top) 0 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -632,20 +634,21 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(36px, 5vw, 54px);
|
font-size: var(--page-title-size);
|
||||||
line-height: 1.12;
|
line-height: 1.15;
|
||||||
letter-spacing: -0.045em;
|
letter-spacing: -0.045em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin: 12px 0 0;
|
margin: 10px 0 0;
|
||||||
color: #687185;
|
color: #687185;
|
||||||
font-size: 16px;
|
font-size: var(--page-description-size);
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total {
|
.total {
|
||||||
@@ -1034,18 +1037,6 @@
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
.passive-card .skill-symbol {
|
|
||||||
background: #8057c7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-card .skill-symbol {
|
|
||||||
background: #4778d8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.special-card .skill-symbol {
|
|
||||||
background: #e64f8f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costume-card .skill-symbol {
|
.costume-card .skill-symbol {
|
||||||
background: #d99a27;
|
background: #d99a27;
|
||||||
}
|
}
|
||||||
@@ -1590,7 +1581,7 @@
|
|||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
main {
|
main {
|
||||||
width: min(100% - 24px, 1120px);
|
width: min(100% - 24px, 1120px);
|
||||||
padding: 38px 0 48px;
|
padding: var(--page-content-top) 0 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import boardSummary from '$lib/data/boards-summary.json';
|
import boardSummary from '$lib/data/boards-summary.json';
|
||||||
import memorySummary from '$lib/data/memory-summary.json';
|
import memorySummary from '$lib/data/memory-summary.json';
|
||||||
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
||||||
|
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
||||||
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
|
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
|
||||||
import {
|
import {
|
||||||
calculateBoardEffects,
|
calculateBoardEffects,
|
||||||
@@ -12,10 +13,12 @@
|
|||||||
generateCombinationCandidates,
|
generateCombinationCandidates,
|
||||||
memoryParameterPermil,
|
memoryParameterPermil,
|
||||||
perfectNoteScoreFromPower,
|
perfectNoteScoreFromPower,
|
||||||
|
resolveCandidateAwakening,
|
||||||
toOptimizerCard
|
toOptimizerCard
|
||||||
} from '$lib/engine/index.ts';
|
} from '$lib/engine/index.ts';
|
||||||
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard } from '$lib/engine/index.ts';
|
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard } from '$lib/engine/index.ts';
|
||||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||||
|
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||||
|
|
||||||
type CardRow = (typeof cardSummary.cards)[number];
|
type CardRow = (typeof cardSummary.cards)[number];
|
||||||
type DifficultyKey = 'hard' | 'expert';
|
type DifficultyKey = 'hard' | 'expert';
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
skillPotential: number;
|
skillPotential: number;
|
||||||
};
|
};
|
||||||
type RankedResult = { result: ExactOptimizationResult; overallPower: number };
|
type RankedResult = { result: ExactOptimizationResult; overallPower: number };
|
||||||
|
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
|
||||||
type SavedState = {
|
type SavedState = {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
songId: string;
|
songId: string;
|
||||||
@@ -60,8 +64,9 @@
|
|||||||
|
|
||||||
const STORAGE_KEY = 'holodori.combination.v1';
|
const STORAGE_KEY = 'holodori.combination.v1';
|
||||||
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||||
const SCREENING_LIMIT = 3_000;
|
const SCREENING_LIMIT = 4_000;
|
||||||
const EXACT_FINALIST_LIMIT = 24;
|
const BASE_EXACT_FINALIST_LIMIT = 48;
|
||||||
|
const ALTERNATIVE_EXACT_FINALIST_LIMIT = 48;
|
||||||
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
|
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
|
||||||
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
|
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
|
||||||
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
|
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
|
||||||
@@ -99,6 +104,7 @@
|
|||||||
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||||
let result: ExactOptimizationResult | null = null;
|
let result: ExactOptimizationResult | null = null;
|
||||||
let rankedResults: RankedResult[] = [];
|
let rankedResults: RankedResult[] = [];
|
||||||
|
let displayedRankings: DisplayRankedResult[] = [];
|
||||||
let baselineState: SavedAnalysisState | null = null;
|
let baselineState: SavedAnalysisState | null = null;
|
||||||
let baselineResult: ExactOptimizationResult | null = null;
|
let baselineResult: ExactOptimizationResult | null = null;
|
||||||
let baselineOverallPower = 0;
|
let baselineOverallPower = 0;
|
||||||
@@ -118,6 +124,10 @@
|
|||||||
: undefined;
|
: undefined;
|
||||||
$: baselineMatchesConditions = Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
|
$: baselineMatchesConditions = Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
|
||||||
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
|
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
|
||||||
|
$: displayedRankings = buildDisplayedRankings(
|
||||||
|
rankedResults,
|
||||||
|
comparableToBaseline ? { result: baselineResult!, overallPower: baselineOverallPower, isBaseline: true } : null
|
||||||
|
);
|
||||||
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
|
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
|
||||||
$: if (!result && rankedResults.length > 0) rankedResults = [];
|
$: if (!result && rankedResults.length > 0) rankedResults = [];
|
||||||
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
||||||
@@ -191,7 +201,7 @@
|
|||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
worker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
worker = createScoreOptimizerWorker();
|
||||||
worker.onmessage = (event) => {
|
worker.onmessage = (event) => {
|
||||||
if (event.data.type === 'progress') progress = event.data.progress;
|
if (event.data.type === 'progress') progress = event.data.progress;
|
||||||
if (event.data.type === 'combination-result') {
|
if (event.data.type === 'combination-result') {
|
||||||
@@ -206,7 +216,7 @@
|
|||||||
searchPhase = 'idle';
|
searchPhase = 'idle';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
baselineWorker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
baselineWorker = createScoreOptimizerWorker();
|
||||||
baselineWorker.onmessage = (event) => {
|
baselineWorker.onmessage = (event) => {
|
||||||
if (event.data.type === 'progress') baselineProgress = event.data.progress;
|
if (event.data.type === 'progress') baselineProgress = event.data.progress;
|
||||||
if (event.data.type === 'result') {
|
if (event.data.type === 'result') {
|
||||||
@@ -326,7 +336,13 @@
|
|||||||
|
|
||||||
function awakeningForCard(card: CardRow): number {
|
function awakeningForCard(card: CardRow): number {
|
||||||
const slot = fixedSlot(card.id);
|
const slot = fixedSlot(card.id);
|
||||||
return slot >= 0 ? fixedAwakenings[slot] : candidateAwakening;
|
if (slot >= 0) return fixedAwakenings[slot];
|
||||||
|
const storedCard = collection[card.id];
|
||||||
|
return resolveCandidateAwakening(
|
||||||
|
candidateAwakening,
|
||||||
|
Boolean(storedCard?.owned),
|
||||||
|
normalizeAwakening(storedCard?.awakening)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLeader(characterId: string): void {
|
function updateLeader(characterId: string): void {
|
||||||
@@ -358,6 +374,21 @@
|
|||||||
return { adapted, activeValue, specialValue };
|
return { adapted, activeValue, specialValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roughCombinationScore(cards: readonly CardRow[]): number {
|
||||||
|
const statTotal = cards.reduce((sum, card) => sum + roughStatScore(card), 0);
|
||||||
|
const skillPotential = cards.reduce((sum, card) => {
|
||||||
|
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards);
|
||||||
|
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
|
||||||
|
Math.max(1, adapted.active.cooldownSeconds) * ((adapted.active.probabilityPercent ?? 100) / 100);
|
||||||
|
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
|
||||||
|
const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100;
|
||||||
|
const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) =>
|
||||||
|
effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0);
|
||||||
|
return sum + activeValue + specialValue + activationValue + passiveValue;
|
||||||
|
}, 0);
|
||||||
|
return statTotal * (1 + skillPotential / 500);
|
||||||
|
}
|
||||||
|
|
||||||
function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string {
|
function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string {
|
||||||
return cards.map((card) => card.id).sort().join('|');
|
return cards.map((card) => card.id).sort().join('|');
|
||||||
}
|
}
|
||||||
@@ -377,13 +408,23 @@
|
|||||||
if (fixedCards.length === 4) {
|
if (fixedCards.length === 4) {
|
||||||
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, SCREENING_LIMIT);
|
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, SCREENING_LIMIT);
|
||||||
}
|
}
|
||||||
const perStrategy = Math.ceil(SCREENING_LIMIT / 3);
|
const perStrategy = Math.ceil(SCREENING_LIMIT / 4);
|
||||||
const combined = new Map<string, CardRow[]>();
|
const combined = new Map<string, CardRow[]>();
|
||||||
for (const score of [roughCardScore, roughSkillScore, roughStatScore]) {
|
for (const score of [roughCardScore, roughSkillScore, roughStatScore]) {
|
||||||
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
|
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
|
||||||
combined.set(combinationKey(cards), cards);
|
combined.set(combinationKey(cards), cards);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const cards of generateCombinationCandidates(
|
||||||
|
fixedCards,
|
||||||
|
fiveStarCards,
|
||||||
|
5,
|
||||||
|
roughStatScore,
|
||||||
|
perStrategy,
|
||||||
|
roughCombinationScore
|
||||||
|
)) {
|
||||||
|
combined.set(combinationKey(cards), cards);
|
||||||
|
}
|
||||||
return Array.from(combined.values()).slice(0, SCREENING_LIMIT);
|
return Array.from(combined.values()).slice(0, SCREENING_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,16 +434,56 @@
|
|||||||
const add = (candidates: PreparedCandidate[], count: number) => {
|
const add = (candidates: PreparedCandidate[], count: number) => {
|
||||||
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
|
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
|
||||||
};
|
};
|
||||||
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 12);
|
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 24);
|
||||||
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 6);
|
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 12);
|
||||||
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 6);
|
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 12);
|
||||||
if (selected.size < EXACT_FINALIST_LIMIT) {
|
if (selected.size < BASE_EXACT_FINALIST_LIMIT) {
|
||||||
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
|
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
|
||||||
selected.set(combinationKey(candidate.cards), candidate);
|
selected.set(combinationKey(candidate.cards), candidate);
|
||||||
if (selected.size >= EXACT_FINALIST_LIMIT) break;
|
if (selected.size >= BASE_EXACT_FINALIST_LIMIT) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(selected.values()).slice(0, EXACT_FINALIST_LIMIT);
|
return Array.from(selected.values()).slice(0, BASE_EXACT_FINALIST_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAlternativeVariantFinalists(baseFinalists: PreparedCandidate[]): PreparedCandidate[] {
|
||||||
|
if (fixedCards.length === 4) return baseFinalists;
|
||||||
|
const selected = new Map(baseFinalists.map((candidate) => [combinationKey(candidate.cards), candidate]));
|
||||||
|
const alternatives = new Map<string, PreparedCandidate>();
|
||||||
|
const bestByReplacementId = new Map<string, PreparedCandidate>();
|
||||||
|
const fixedIds = new Set(fixedCards.map((card) => card.id));
|
||||||
|
|
||||||
|
for (const finalist of baseFinalists) {
|
||||||
|
const sourceCards = finalist.cards.map((card) => cardById.get(card.id)).filter((card): card is CardRow => Boolean(card));
|
||||||
|
sourceCards.forEach((source, index) => {
|
||||||
|
if (fixedIds.has(source.id)) return;
|
||||||
|
for (const replacement of fiveStarCards) {
|
||||||
|
if (replacement.characterId !== source.characterId || replacement.id === source.id) continue;
|
||||||
|
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
|
||||||
|
const key = combinationKey(cards);
|
||||||
|
if (selected.has(key) || alternatives.has(key)) continue;
|
||||||
|
const prepared = prepareCandidate(cards);
|
||||||
|
alternatives.set(key, prepared);
|
||||||
|
const previous = bestByReplacementId.get(replacement.id);
|
||||||
|
if (!previous || prepared.heuristic > previous.heuristic) bestByReplacementId.set(replacement.id, prepared);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
|
||||||
|
right.heuristic - left.heuristic || right.skillPotential - left.skillPotential || right.overallPower - left.overallPower);
|
||||||
|
const chosenAlternatives = new Map<string, PreparedCandidate>();
|
||||||
|
for (const candidate of bestByReplacementId.values()) {
|
||||||
|
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||||
|
}
|
||||||
|
for (const candidate of rankedAlternatives) {
|
||||||
|
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||||
|
if (chosenAlternatives.size >= ALTERNATIVE_EXACT_FINALIST_LIMIT) break;
|
||||||
|
}
|
||||||
|
for (const candidate of chosenAlternatives.values()) {
|
||||||
|
selected.set(combinationKey(candidate.cards), candidate);
|
||||||
|
}
|
||||||
|
return Array.from(selected.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
|
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
|
||||||
@@ -497,9 +578,7 @@
|
|||||||
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const response = await fetch(chartMetadata.path);
|
const chart = await loadExactSongChart(chartMetadata.path);
|
||||||
if (!response.ok) throw new Error('기준 조합의 정확 채보 데이터를 불러오지 못했습니다.');
|
|
||||||
const chart = await response.json() as ExactSongChart;
|
|
||||||
baselineOverallPower = power.overallPower;
|
baselineOverallPower = power.overallPower;
|
||||||
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
|
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
|
||||||
baselineWorker.postMessage({
|
baselineWorker.postMessage({
|
||||||
@@ -549,7 +628,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
groups.forEach((candidates, workerIndex) => {
|
groups.forEach((candidates, workerIndex) => {
|
||||||
const parallelWorker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
const parallelWorker = createScoreOptimizerWorker();
|
||||||
parallelWorkers.push(parallelWorker);
|
parallelWorkers.push(parallelWorker);
|
||||||
parallelWorker.onmessage = (event) => {
|
parallelWorker.onmessage = (event) => {
|
||||||
if (failed) return;
|
if (failed) return;
|
||||||
@@ -607,12 +686,10 @@
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const finalists = selectExactFinalists(prepared);
|
const finalists = addAlternativeVariantFinalists(selectExactFinalists(prepared));
|
||||||
exactCombinationCount = finalists.length;
|
exactCombinationCount = finalists.length;
|
||||||
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
|
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
|
||||||
const response = await fetch(selectedChart.path);
|
const chart = await loadExactSongChart(selectedChart.path);
|
||||||
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
|
|
||||||
const chart = await response.json() as ExactSongChart;
|
|
||||||
searchPhase = 'exact';
|
searchPhase = 'exact';
|
||||||
if (maximizeCompute) runParallelExact(finalists, chart);
|
if (maximizeCompute) runParallelExact(finalists, chart);
|
||||||
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
|
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
|
||||||
@@ -632,7 +709,45 @@
|
|||||||
|
|
||||||
function resultCardName(cardId: string): string {
|
function resultCardName(cardId: string): string {
|
||||||
const card = cardById.get(cardId);
|
const card = cardById.get(cardId);
|
||||||
return card ? cardLabel(card) : cardId;
|
return card ? `${card.name}${card.versionName ? ` · ${card.versionName}` : ''}` : cardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultCardAwakening(cardId: string): number {
|
||||||
|
const card = cardById.get(cardId);
|
||||||
|
return card ? awakeningForCard(card) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayedCardAwakening(cardId: string, isBaseline: boolean): number {
|
||||||
|
if (!isBaseline) return resultCardAwakening(cardId);
|
||||||
|
const index = baselineState?.cardIds.indexOf(cardId) ?? -1;
|
||||||
|
return index >= 0 ? baselineState?.awakenings[index] ?? 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayedCompositionKey(ranking: DisplayRankedResult): string {
|
||||||
|
return ranking.result.order
|
||||||
|
.map((cardId) => `${cardId}:${displayedCardAwakening(cardId, ranking.isBaseline)}`)
|
||||||
|
.sort()
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDisplayedRankings(
|
||||||
|
rankings: readonly RankedResult[],
|
||||||
|
baseline: DisplayRankedResult | null
|
||||||
|
): DisplayRankedResult[] {
|
||||||
|
if (rankings.length === 0) return [];
|
||||||
|
const unique = new Map<string, DisplayRankedResult>();
|
||||||
|
for (const ranking of rankings) {
|
||||||
|
const display = { ...ranking, isBaseline: false };
|
||||||
|
unique.set(displayedCompositionKey(display), display);
|
||||||
|
}
|
||||||
|
if (baseline) {
|
||||||
|
// 카드와 개화가 같다면 실제 배치 분석값인 기준 조합을 대표 결과로 사용한다.
|
||||||
|
unique.set(displayedCompositionKey(baseline), baseline);
|
||||||
|
}
|
||||||
|
return Array.from(unique.values())
|
||||||
|
.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||||
|
right.result.expectedScore - left.result.expectedScore || Number(right.isBaseline) - Number(left.isBaseline))
|
||||||
|
.slice(0, 10);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -687,10 +802,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<label>자동 후보 기수 보너스 덮어쓰기<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:change={(event) => updateCandidateGenerationBonus(event.currentTarget.value)}></label>
|
<label>자동 후보 기수 보너스 덮어쓰기<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:change={(event) => updateCandidateGenerationBonus(event.currentTarget.value)}></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="candidate-guide">기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.</p>
|
<p class="candidate-guide">
|
||||||
|
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
|
||||||
|
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
|
||||||
|
</p>
|
||||||
<div class="fixed-fields">
|
<div class="fixed-fields">
|
||||||
<div class="fixed-column-headings">
|
<div class="fixed-column-headings">
|
||||||
<span></span><span></span><span></span>
|
<span>고정 멤버</span><span>개화</span><span>빈도 감소 고정</span>
|
||||||
<div><span>파란 노드 전체</span><span class="generation-heading">기수 보너스<br>덮어쓰기</span></div>
|
<div><span>파란 노드 전체</span><span class="generation-heading">기수 보너스<br>덮어쓰기</span></div>
|
||||||
</div>
|
</div>
|
||||||
{#each fixedCardIds as selectedId, slot}
|
{#each fixedCardIds as selectedId, slot}
|
||||||
@@ -702,7 +820,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="fixed-awakening">
|
<div class="fixed-awakening">
|
||||||
<span>개화</span>
|
|
||||||
<div class="awakening-stepper">
|
<div class="awakening-stepper">
|
||||||
<button type="button" disabled={!selectedId || fixedAwakenings[slot] <= 0} aria-label={`${slot + 1}번 고정 카드 개화 낮추기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] - 1)}>‹</button>
|
<button type="button" disabled={!selectedId || fixedAwakenings[slot] <= 0} aria-label={`${slot + 1}번 고정 카드 개화 낮추기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] - 1)}>‹</button>
|
||||||
<strong>{fixedAwakenings[slot]}</strong>
|
<strong>{fixedAwakenings[slot]}</strong>
|
||||||
@@ -710,7 +827,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fixed-reduction">
|
<div class="fixed-reduction">
|
||||||
<span>빈도 감소 고정</span>
|
|
||||||
<div class="reduction-options">
|
<div class="reduction-options">
|
||||||
{#each REDUCTION_LOCK_OPTIONS as reduction}
|
{#each REDUCTION_LOCK_OPTIONS as reduction}
|
||||||
<button type="button" disabled={!selectedId} class:active={fixedCooldownReductions[slot] === reduction} aria-pressed={fixedCooldownReductions[slot] === reduction} on:click={() => updateFixedReduction(slot, reduction)}>{reduction === null ? '미사용' : `${reduction}%`}</button>
|
<button type="button" disabled={!selectedId} class:active={fixedCooldownReductions[slot] === reduction} aria-pressed={fixedCooldownReductions[slot] === reduction} on:click={() => updateFixedReduction(slot, reduction)}>{reduction === null ? '미사용' : `${reduction}%`}</button>
|
||||||
@@ -749,22 +865,24 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" aria-labelledby="result-title">
|
<section class="panel" aria-labelledby="result-title">
|
||||||
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · 상위 {rankedResults.length}개</strong>{/if}</div>
|
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
|
||||||
{#if rankedResults.length > 0}
|
{#if displayedRankings.length > 0}
|
||||||
<div class="ranking-list">
|
<div class="ranking-list">
|
||||||
{#each rankedResults as ranking, rank}
|
{#each displayedRankings as ranking, rank}
|
||||||
<details class="ranking-card" open={rank === 0}>
|
<details class:baseline-ranking={ranking.isBaseline} class="ranking-card" open={rank === 0}>
|
||||||
<summary>
|
<summary>
|
||||||
<span class="rank-number">{rank + 1}위</span>
|
<span class="rank-number">{rank + 1}위</span>
|
||||||
<span class="rank-members">
|
<span class="rank-members">
|
||||||
|
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
|
||||||
{#each ranking.result.order as cardId, memberIndex}
|
{#each ranking.result.order as cardId, memberIndex}
|
||||||
<span class:added-member={!fixedCardIds.includes(cardId)}>
|
<span class:added-member={!ranking.isBaseline && !fixedCardIds.includes(cardId)}>
|
||||||
{resultCardName(cardId)}
|
<small class="member-awakening">{displayedCardAwakening(cardId, ranking.isBaseline)}개화</small>
|
||||||
|
<span class="member-name">{resultCardName(cardId)}</span>
|
||||||
</span>
|
</span>
|
||||||
{#if memberIndex < ranking.result.order.length - 1}<i aria-hidden="true">·</i>{/if}
|
{#if memberIndex < ranking.result.order.length - 1}<i aria-hidden="true">·</i>{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span>
|
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="ranking-detail">
|
<div class="ranking-detail">
|
||||||
<AnalysisResultView
|
<AnalysisResultView
|
||||||
@@ -772,7 +890,7 @@
|
|||||||
durationSeconds={selectedChart?.durationSeconds ?? 0}
|
durationSeconds={selectedChart?.durationSeconds ?? 0}
|
||||||
cardName={resultCardName}
|
cardName={resultCardName}
|
||||||
instanceId={`combination-${rank}`}
|
instanceId={`combination-${rank}`}
|
||||||
baseline={comparableToBaseline ? baselineResult : null}
|
baseline={comparableToBaseline && !ranking.isBaseline ? baselineResult : null}
|
||||||
showScoreSummary={false}
|
showScoreSummary={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -786,10 +904,10 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: 46px 0 80px; }
|
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
|
||||||
header { margin-bottom: 28px; }
|
header { margin-bottom: var(--page-header-bottom); }
|
||||||
h1 { margin: 0; color: #f3f5f8; font-size: clamp(28px, 5vw, 42px); letter-spacing: -.045em; }
|
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
|
||||||
.page-description { margin: 10px 0 0; color: #929cab; font-size: 14px; line-height: 1.6; }
|
.page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; }
|
||||||
.baseline-guide { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border-radius: 8px; color: #8d96a5; background: #1b2029; font-size: 13px; line-height: 1.55; }
|
.baseline-guide { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border-radius: 8px; color: #8d96a5; background: #1b2029; font-size: 13px; line-height: 1.55; }
|
||||||
.baseline-guide strong { color: #dba0b9; }
|
.baseline-guide strong { color: #dba0b9; }
|
||||||
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
|
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
|
||||||
@@ -815,12 +933,12 @@
|
|||||||
.fixed-fields { display: grid; gap: 9px; margin-top: 16px; }
|
.fixed-fields { display: grid; gap: 9px; margin-top: 16px; }
|
||||||
.fixed-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
|
.fixed-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
|
||||||
.fixed-column-headings { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(120px, 11vw, 170px) clamp(304px, 26vw, 390px) clamp(180px, 16vw, 240px); gap: 9px; margin-bottom: -3px; }
|
.fixed-column-headings { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(120px, 11vw, 170px) clamp(304px, 26vw, 390px) clamp(180px, 16vw, 240px); gap: 9px; margin-bottom: -3px; }
|
||||||
|
.fixed-column-headings > span { align-self: center; color: #8f98a8; font-size: 12px; font-weight: 800; text-align: center; }
|
||||||
|
.fixed-column-headings > span:first-child { text-align: left; }
|
||||||
.fixed-column-headings > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 12px; font-weight: 800; text-align: center; }
|
.fixed-column-headings > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 12px; font-weight: 800; text-align: center; }
|
||||||
.generation-heading { line-height: 1.25; white-space: nowrap; }
|
.generation-heading { line-height: 1.25; white-space: nowrap; }
|
||||||
.fixed-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(120px, 11vw, 170px) clamp(304px, 26vw, 390px) clamp(180px, 16vw, 240px); gap: 9px; }
|
.fixed-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(120px, 11vw, 170px) clamp(304px, 26vw, 390px) clamp(180px, 16vw, 240px); gap: 9px; }
|
||||||
.fixed-awakening, .fixed-reduction { display: grid; align-self: end; align-items: center; height: 42px; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
|
.fixed-awakening, .fixed-reduction { display: grid; align-self: end; align-items: center; height: 42px; }
|
||||||
.fixed-awakening { grid-template-columns: 32px minmax(0, 1fr); }
|
|
||||||
.fixed-reduction { grid-template-columns: 88px minmax(0, 1fr); }
|
|
||||||
.awakening-stepper { display: grid; grid-template-columns: 30px minmax(0, 1fr) 30px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
|
.awakening-stepper { display: grid; grid-template-columns: 30px minmax(0, 1fr) 30px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
|
||||||
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 22px; cursor: pointer; }
|
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 22px; cursor: pointer; }
|
||||||
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; }
|
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; }
|
||||||
@@ -861,15 +979,20 @@
|
|||||||
.ranking-list { display: grid; gap: 10px; margin-top: 18px; }
|
.ranking-list { display: grid; gap: 10px; margin-top: 18px; }
|
||||||
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
|
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
|
||||||
.ranking-card[open] { border-color: #55404f; background: #15151b; }
|
.ranking-card[open] { border-color: #55404f; background: #15151b; }
|
||||||
|
.ranking-card.baseline-ranking { border-color: #665061; background: #1b171d; }
|
||||||
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(170px, 15vw, 230px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
|
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(170px, 15vw, 230px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
|
||||||
.ranking-card summary::-webkit-details-marker { display: none; }
|
.ranking-card summary::-webkit-details-marker { display: none; }
|
||||||
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
|
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
|
||||||
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
|
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
|
||||||
.rank-number { display: grid; place-items: center; width: 48px; height: 34px; border-radius: 8px; color: #171019; background: #ed8bb5; font-size: 14px; font-weight: 900; }
|
.rank-number { display: grid; place-items: center; width: 48px; height: 34px; border-radius: 8px; color: #171019; background: #ed8bb5; font-size: 14px; font-weight: 900; }
|
||||||
.rank-members { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: #dce1e8; font-size: 13px; font-weight: 800; line-height: 1.55; }
|
.rank-members { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: #dce1e8; font-size: 13px; font-weight: 800; line-height: 1.55; }
|
||||||
.rank-members > span { white-space: nowrap; }
|
.rank-members > span { display: inline-grid; justify-items: center; gap: 1px; white-space: nowrap; }
|
||||||
|
.member-awakening { color: #8d96a5; font-size: 10px; font-weight: 750; line-height: 1.2; }
|
||||||
|
.member-name { line-height: 1.35; }
|
||||||
|
.rank-members .added-member .member-awakening { color: #c987a4; }
|
||||||
.rank-members > i { flex: 0 0 auto; color: #596270; font-style: normal; }
|
.rank-members > i { flex: 0 0 auto; color: #596270; font-style: normal; }
|
||||||
.rank-members .added-member { color: #f3a5c6; }
|
.rank-members .added-member { color: #f3a5c6; }
|
||||||
|
.baseline-result-label { padding: 3px 7px; border: 1px solid #6a5060; border-radius: 999px; color: #e7b2ca; background: #2a2028; font-size: 10px; font-weight: 850; }
|
||||||
.rank-metric { display: grid; gap: 4px; }
|
.rank-metric { display: grid; gap: 4px; }
|
||||||
.rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; }
|
.rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; }
|
||||||
.rank-metric strong { color: #f0a0c2; font-size: 18px; line-height: 1.2; }
|
.rank-metric strong { color: #f0a0c2; font-size: 18px; line-height: 1.2; }
|
||||||
@@ -887,16 +1010,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
main { width: calc(100% - 24px); padding-top: 28px; }
|
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
|
||||||
.panel { padding: 17px; }
|
.panel { padding: 17px; }
|
||||||
.song-fields { grid-template-columns: 1fr 100px; }
|
.song-fields { grid-template-columns: 1fr 100px; }
|
||||||
.candidate-fields { grid-template-columns: 1fr; }
|
.candidate-fields { grid-template-columns: 1fr; }
|
||||||
.fixed-column-headings { grid-template-columns: minmax(0, 1fr) 102px; }
|
.fixed-column-headings { display: none; }
|
||||||
.fixed-column-headings > span { display: none; }
|
|
||||||
.fixed-column-headings > div { grid-column: 1 / -1; }
|
|
||||||
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
|
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
|
||||||
.fixed-reduction, .fixed-overrides { grid-column: 1 / -1; }
|
.fixed-reduction, .fixed-overrides { grid-column: 1 / -1; }
|
||||||
.fixed-awakening { grid-template-columns: 1fr; }
|
|
||||||
.leader-fields, .scores { grid-template-columns: 1fr; }
|
.leader-fields, .scores { grid-template-columns: 1fr; }
|
||||||
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
|
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
|
||||||
.baseline-members { grid-template-columns: 1fr; }
|
.baseline-members { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -941,7 +941,6 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>
|
||||||
<p class="eyebrow">내 데이터</p>
|
|
||||||
<h1>{dataView === 'cards' ? '카드 데이터' : dataView === 'boards' ? '보드 데이터' : '사용자 데이터'}</h1>
|
<h1>{dataView === 'cards' ? '카드 데이터' : dataView === 'boards' ? '보드 데이터' : '사용자 데이터'}</h1>
|
||||||
<p class="description">{dataView === 'cards' ? '카드 보유 여부와 개화 단계를 관리합니다.' : dataView === 'boards' ? '멤버별 보드 활성화 상태를 관리합니다.' : '공통 설정을 관리하고 사용자 데이터를 백업할 수 있습니다.'}</p>
|
<p class="description">{dataView === 'cards' ? '카드 보유 여부와 개화 단계를 관리합니다.' : dataView === 'boards' ? '멤버별 보드 활성화 상태를 관리합니다.' : '공통 설정을 관리하고 사용자 데이터를 백업할 수 있습니다.'}</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -1374,7 +1373,7 @@
|
|||||||
:global(*) { box-sizing: border-box; }
|
:global(*) { box-sizing: border-box; }
|
||||||
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
|
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
|
||||||
:global(button), :global(input) { font: inherit; }
|
:global(button), :global(input) { font: inherit; }
|
||||||
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: 36px 0 80px; }
|
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
|
||||||
.data-nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
|
.data-nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
|
||||||
.data-nav a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
|
.data-nav a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
|
||||||
.data-nav a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
|
.data-nav a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
|
||||||
@@ -1388,10 +1387,10 @@
|
|||||||
.memory-input input { width: 88px; height: 40px; padding: 0 10px; border: 1px solid #434c5c; border-radius: 9px; color: #f1f3f7; background: #11161e; text-align: right; outline: none; }
|
.memory-input input { width: 88px; height: 40px; padding: 0 10px; border: 1px solid #434c5c; border-radius: 9px; color: #f1f3f7; background: #11161e; text-align: right; outline: none; }
|
||||||
.memory-input input:focus { border-color: #d96b9a; box-shadow: 0 0 0 3px rgb(239 139 182 / 12%); }
|
.memory-input input:focus { border-color: #d96b9a; box-shadow: 0 0 0 3px rgb(239 139 182 / 12%); }
|
||||||
.memory-input small { grid-column: 1 / -1; color: #ef9fc0; font-size: 12px; text-align: right; }
|
.memory-input small { grid-column: 1 / -1; color: #ef9fc0; font-size: 12px; text-align: right; }
|
||||||
header { margin-bottom: 26px; }
|
header { margin-bottom: var(--page-header-bottom); }
|
||||||
.eyebrow, .section-label { margin: 0 0 8px; color: #ee75aa; font-size: 14px; font-weight: 800; letter-spacing: .08em; }
|
.section-label { margin: 0 0 8px; color: #ee75aa; font-size: 14px; font-weight: 800; letter-spacing: .08em; }
|
||||||
h1 { margin: 0; font-size: clamp(30px, 5vw, 44px); letter-spacing: -.04em; }
|
h1 { margin: 0; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
|
||||||
.description { margin: 12px 0 0; color: #9ca6b7; line-height: 1.6; }
|
.description { margin: 10px 0 0; color: #9ca6b7; font-size: var(--page-description-size); line-height: 1.6; }
|
||||||
.summary { display: grid; grid-template-columns: repeat(2, minmax(0, 280px)); gap: 10px; margin-bottom: 18px; }
|
.summary { display: grid; grid-template-columns: repeat(2, minmax(0, 280px)); gap: 10px; margin-bottom: 18px; }
|
||||||
.summary article { min-width: 0; padding: 18px; border: 1px solid #2b3240; border-radius: 14px; background: #181d26; }
|
.summary article { min-width: 0; padding: 18px; border: 1px solid #2b3240; border-radius: 14px; background: #181d26; }
|
||||||
.summary span { display: block; margin-bottom: 9px; color: #8e98a9; font-size: 14px; }
|
.summary span { display: block; margin-bottom: 9px; color: #8e98a9; font-size: 14px; }
|
||||||
@@ -1605,7 +1604,7 @@
|
|||||||
aside strong { color: #cbd1db; }
|
aside strong { color: #cbd1db; }
|
||||||
aside p { margin: 5px 0 0; }
|
aside p { margin: 5px 0 0; }
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
main { width: min(100% - 24px, 900px); padding-top: 24px; }
|
main { width: min(100% - 24px, 900px); padding-top: var(--page-content-top); }
|
||||||
.summary { grid-template-columns: repeat(2, 1fr); }
|
.summary { grid-template-columns: repeat(2, 1fr); }
|
||||||
.settings-grid { grid-template-columns: 1fr; }
|
.settings-grid { grid-template-columns: 1fr; }
|
||||||
.setting-panel { align-items: stretch; flex-direction: column; gap: 16px; }
|
.setting-panel { align-items: stretch; flex-direction: column; gap: 16px; }
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: path.join(root, 'offline'),
|
||||||
|
base: './',
|
||||||
|
publicDir: path.join(root, 'static'),
|
||||||
|
plugins: [svelte()],
|
||||||
|
resolve: {
|
||||||
|
alias: [
|
||||||
|
{ find: '$app/stores', replacement: path.join(root, 'offline/page-store.ts') },
|
||||||
|
{ find: '$lib/data/chart-loader.ts', replacement: path.join(root, 'offline/chart-loader.ts') },
|
||||||
|
{ find: '$lib', replacement: path.join(root, 'src/lib') }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
worker: { format: 'iife' },
|
||||||
|
build: {
|
||||||
|
outDir: path.join(root, 'offline-dist'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
cssCodeSplit: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
format: 'iife',
|
||||||
|
entryFileNames: 'assets/app.js',
|
||||||
|
assetFileNames: (assetInfo) => assetInfo.name?.endsWith('.css') ? 'assets/app.css' : 'assets/[name][extname]'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 20000
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user