feat: release version 1.0.7
This commit is contained in:
@@ -9,8 +9,9 @@
|
||||
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
|
||||
import { releasedSongCharts } from '$lib/data/song-release.ts';
|
||||
import PresetManager from '$lib/components/PresetManager.svelte';
|
||||
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import SongPicker from '$lib/components/SongPicker.svelte';
|
||||
import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||
|
||||
@@ -19,6 +20,16 @@
|
||||
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
||||
type MemberBoards = Record<string, string[]>;
|
||||
type BoardCards = Record<string, Record<string, string>>;
|
||||
type ConnectionOptimizationProposal = {
|
||||
placements: BoardCards;
|
||||
assignments: { memberId: string; socketId: string; previousCardId: string | null; cardId: string | null }[];
|
||||
beforeExpected: number;
|
||||
afterExpected: number;
|
||||
beforeMaximum: number;
|
||||
afterMaximum: number;
|
||||
beforeActivationRateUp: number;
|
||||
afterActivationRateUp: number;
|
||||
};
|
||||
type DisplayEffectiveWindow = SkillWindow & {
|
||||
baseScoreUpPercent: number;
|
||||
minScoreUpPercent: number;
|
||||
@@ -52,6 +63,8 @@
|
||||
let charts = releasedSongCharts(allCharts, releaseNow);
|
||||
let songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
|
||||
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
|
||||
const allCardById = new Map(cardSummary.cards.map((card) => [card.id, card]));
|
||||
const boardMembers = boardSummary.members;
|
||||
const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [
|
||||
member.id,
|
||||
(boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId]
|
||||
@@ -91,6 +104,11 @@
|
||||
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
|
||||
let errorMessage = '';
|
||||
let analysisReady = false;
|
||||
let connectionOptimizationRunning = false;
|
||||
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
|
||||
let connectionOptimizationError = '';
|
||||
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
|
||||
let connectionOptimizationSnapshot: BoardCards | null = null;
|
||||
|
||||
$: charts = releasedSongCharts(allCharts, releaseNow);
|
||||
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
|
||||
@@ -136,6 +154,7 @@
|
||||
})();
|
||||
$: overallPower = unitPower.overallPower;
|
||||
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
|
||||
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
|
||||
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
|
||||
$: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : [];
|
||||
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
|
||||
@@ -411,14 +430,212 @@
|
||||
...adapted,
|
||||
active: {
|
||||
...adapted.active,
|
||||
probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100)
|
||||
activationRateUpPercent: activationRate
|
||||
},
|
||||
scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
||||
};
|
||||
}
|
||||
|
||||
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>) {
|
||||
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
|
||||
placements,
|
||||
cards: connectionCards
|
||||
}, { generationBonusByCardId });
|
||||
const localUnitPower = calculateUnitPower(selectedCardIds.map((id, index) => ({
|
||||
card: cardById.get(id)!, awakening: selectedAwakenings[index]
|
||||
})), leaderOutfit, localBoardEffects, memoryPermil, memberPowerUpPercent);
|
||||
const cards = selectedSourceCards.map((card, index) => {
|
||||
const fixedReduction = fixedCooldownReductions[index];
|
||||
const adapted = toOptimizerCard(
|
||||
card,
|
||||
selectedAwakenings[index] ?? 0,
|
||||
fixedReduction === null || fixedReduction === undefined
|
||||
? localBoardEffects.allowedCooldownReductionsByCardId[card.id]
|
||||
: [fixedReduction],
|
||||
selectedSourceCards
|
||||
);
|
||||
return {
|
||||
...adapted,
|
||||
active: { ...adapted.active, activationRateUpPercent: localBoardEffects.activationRateUpByCardId[card.id] ?? 0 },
|
||||
scoreSupportPercent: localUnitPower.outfitScoreSupportPercent + localBoardEffects.scoreSupportPercent
|
||||
};
|
||||
});
|
||||
return { cards, boardEffects: localBoardEffects, unitPower: localUnitPower };
|
||||
}
|
||||
|
||||
function connectionSocketName(memberId: string, socketId: string): string {
|
||||
const member = boardMembers.find((item) => item.id === memberId);
|
||||
const socket = member
|
||||
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
|
||||
: null;
|
||||
if (!socket || (socket.x === 0 && socket.y === 0)) return '중앙';
|
||||
if (socket.x < 0) return '왼쪽';
|
||||
if (socket.x > 0) return '오른쪽';
|
||||
return socket.y > 0 ? '위쪽' : '아래쪽';
|
||||
}
|
||||
|
||||
function isBlueConnectionSocket(memberId: string, socketId: string): boolean {
|
||||
const member = boardMembers.find((item) => item.id === memberId);
|
||||
const socket = member
|
||||
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
|
||||
: null;
|
||||
return (socket?.x ?? 0) < 0;
|
||||
}
|
||||
|
||||
function totalActivationRateUp(context: ReturnType<typeof connectionScoreContext>): number {
|
||||
return Object.values(context.boardEffects.activationRateUpByCardId).reduce((total, value) => total + value, 0);
|
||||
}
|
||||
|
||||
function saveBoardCardPlacements(placements: BoardCards): void {
|
||||
const raw = localStorage.getItem(USER_DATA_STORAGE_KEY);
|
||||
const stored = raw ? JSON.parse(raw) : {};
|
||||
stored.boardCards = placements;
|
||||
stored.updatedAt = new Date().toISOString();
|
||||
localStorage.setItem(USER_DATA_STORAGE_KEY, JSON.stringify(stored));
|
||||
boardCards = placements;
|
||||
}
|
||||
|
||||
function clearConnectionOptimizationProposal(): void {
|
||||
connectionOptimizationProposal = null;
|
||||
connectionOptimizationError = '';
|
||||
}
|
||||
|
||||
async function optimizeConnectionPlacements(): Promise<void> {
|
||||
if (!result || !selectedChart || selectedSourceCards.length !== 5) return;
|
||||
const analysisResult = result;
|
||||
const chartSummary = selectedChart;
|
||||
connectionOptimizationRunning = true;
|
||||
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
|
||||
connectionOptimizationError = '';
|
||||
connectionOptimizationProposal = null;
|
||||
try {
|
||||
const chart = await loadExactSongChart(chartSummary.path);
|
||||
const beforeContext = connectionScoreContext(boardCards, chartSummary);
|
||||
const emptyContext = connectionScoreContext({}, chartSummary);
|
||||
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => analysisResult.order.map((cardId) =>
|
||||
context.cards.find((card) => card.id === cardId)!
|
||||
);
|
||||
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
|
||||
orderedCards(context), analysisResult.cooldownReductions, chart, {
|
||||
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
|
||||
}
|
||||
).expectedScore;
|
||||
const emptyScore = referenceScore(emptyContext);
|
||||
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
|
||||
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
|
||||
const candidateCards = cardSummary.cards
|
||||
.filter((card) => card.details.connect.length > 0 && collection[card.id]?.owned)
|
||||
.map((card) => ({ id: card.id, awakening: collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
|
||||
if (candidateCards.length === 0) throw new Error('최적화에 사용할 보유 연결 카드가 없습니다.');
|
||||
const placementMetricCache = new Map<string, { score: number; activationRateUp: number }>();
|
||||
const placementMetrics = (card: OptimizableConnectionCard, memberId: string, socketId: string) => {
|
||||
const effect = card.connect.find((entry) => entry.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
|
||||
const effectKey = effect ? `${effect.effectPermilUp}:${effect.extent.map((offset) => `${offset.x},${offset.y}`).join(';')}` : card.id;
|
||||
const key = `${memberId}:${socketId}:${effectKey}`;
|
||||
const cached = placementMetricCache.get(key);
|
||||
if (cached) return cached;
|
||||
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
|
||||
const metrics = {
|
||||
score: referenceScore(context) - emptyScore,
|
||||
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
|
||||
};
|
||||
placementMetricCache.set(key, metrics);
|
||||
return metrics;
|
||||
};
|
||||
const activeConnectionSlots = boardMembers.flatMap((member) => {
|
||||
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
|
||||
const active = new Set(effectiveMemberBoards[member.id] ?? []);
|
||||
const center = positions.find((position) => position.x === 0 && position.y === 0);
|
||||
if (center) active.add(center.groupId);
|
||||
return positions.filter((position) => active.has(position.groupId) && position.groupId.startsWith('S'))
|
||||
.map((position) => ({ memberId: member.id, socketId: position.groupId }));
|
||||
});
|
||||
const totalPlacementChecks = candidateCards.length * activeConnectionSlots.length;
|
||||
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: 0, total: totalPlacementChecks };
|
||||
let completedPlacementChecks = 0;
|
||||
for (const card of candidateCards) {
|
||||
for (const slot of activeConnectionSlots) {
|
||||
placementMetrics(card, slot.memberId, slot.socketId);
|
||||
completedPlacementChecks += 1;
|
||||
if (completedPlacementChecks % 8 === 0 || completedPlacementChecks === totalPlacementChecks) {
|
||||
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: completedPlacementChecks, total: totalPlacementChecks };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
}
|
||||
}
|
||||
connectionOptimizationProgress = { stage: '최적 조합 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
|
||||
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
|
||||
placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId)
|
||||
? placementMetrics(card, memberId, socketId).activationRateUp
|
||||
: 0
|
||||
});
|
||||
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
|
||||
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
|
||||
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
|
||||
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
|
||||
});
|
||||
const afterActivationRateUp = totalActivationRateUp(afterContext);
|
||||
if (afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
|
||||
throw new Error('현재 배치보다 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
|
||||
}
|
||||
if (Math.abs(afterActivationRateUp - beforeActivationRateUp) < 1e-9 && after.expectedScore < analysisResult.expectedScore) {
|
||||
throw new Error(`현재 배치의 예상 점수(${analysisResult.expectedScore.toLocaleString('ko-KR')})가 탐색 결과보다 높아 기존 배치를 유지합니다.`);
|
||||
}
|
||||
const changedAssignments = boardMembers.flatMap((member) => {
|
||||
const before = boardCards[member.id] ?? {};
|
||||
const afterPlacements = optimized.placements[member.id] ?? {};
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(afterPlacements)])]
|
||||
.filter((socketId) => before[socketId] !== afterPlacements[socketId])
|
||||
.map((socketId) => ({
|
||||
memberId: member.id,
|
||||
socketId,
|
||||
previousCardId: before[socketId] ?? null,
|
||||
cardId: afterPlacements[socketId] ?? null
|
||||
}));
|
||||
});
|
||||
connectionOptimizationProposal = {
|
||||
placements: optimized.placements,
|
||||
assignments: changedAssignments,
|
||||
beforeExpected: analysisResult.expectedScore,
|
||||
afterExpected: after.expectedScore,
|
||||
beforeMaximum: analysisResult.theoreticalMaxScore,
|
||||
afterMaximum: after.theoreticalMaxScore,
|
||||
beforeActivationRateUp,
|
||||
afterActivationRateUp
|
||||
};
|
||||
} catch (error) {
|
||||
connectionOptimizationError = error instanceof Error ? error.message : '연결 카드 최적화를 완료하지 못했습니다.';
|
||||
} finally {
|
||||
connectionOptimizationRunning = false;
|
||||
connectionOptimizationProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyConnectionOptimization(): Promise<void> {
|
||||
if (!connectionOptimizationProposal) return;
|
||||
connectionOptimizationSnapshot = Object.fromEntries(Object.entries(boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
|
||||
saveBoardCardPlacements(connectionOptimizationProposal.placements);
|
||||
connectionOptimizationProposal = null;
|
||||
await analyze();
|
||||
}
|
||||
|
||||
async function undoConnectionOptimization(): Promise<void> {
|
||||
if (!connectionOptimizationSnapshot) return;
|
||||
saveBoardCardPlacements(connectionOptimizationSnapshot);
|
||||
connectionOptimizationSnapshot = null;
|
||||
connectionOptimizationProposal = null;
|
||||
await analyze();
|
||||
}
|
||||
|
||||
async function analyze(): Promise<void> {
|
||||
errorMessage = '';
|
||||
clearConnectionOptimizationProposal();
|
||||
result = null;
|
||||
achievementProbabilities = undefined;
|
||||
if (!worker || !selectedSong || !selectedChart) return;
|
||||
@@ -561,11 +778,7 @@
|
||||
</div>
|
||||
|
||||
<div class="song-fields">
|
||||
<label>악곡
|
||||
<select bind:value={selectedSongId} on:change={() => result = null}>
|
||||
{#each songs as song}<option value={song.id}>{song.title}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
|
||||
<label>난이도
|
||||
<select bind:value={difficulty} on:change={() => result = null}>
|
||||
<option value="expert">Expert</option>
|
||||
@@ -573,7 +786,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점</span>{#if boardEffects.directSongScoreBonusPercent > 0}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%</span>{/if}</div>
|
||||
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점 · {scoreCalibrationLabel}</span>{#if boardEffects.directSongScoreBonusPercent > 0}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%</span>{/if}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-fields">
|
||||
@@ -684,6 +897,45 @@
|
||||
</div>
|
||||
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
|
||||
|
||||
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
|
||||
<div class="connection-optimizer-heading">
|
||||
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 파란색 영역은 액티브 발동률을 먼저 최대화한 뒤 예상 점수를 비교합니다.</p></div>
|
||||
<div class="connection-optimizer-actions">
|
||||
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
|
||||
<button type="button" class="primary" disabled={running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if connectionOptimizationProgress}
|
||||
<div class="connection-optimizer-progress" aria-live="polite">
|
||||
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>
|
||||
{#if connectionOptimizationProgress.total > 0}<progress max={connectionOptimizationProgress.total} value={connectionOptimizationProgress.completed}></progress>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
|
||||
{#if connectionOptimizationProposal}
|
||||
<div class="connection-proposal" aria-live="polite">
|
||||
<div class="connection-score-comparison">
|
||||
<span>예상 점수 <strong>{connectionOptimizationProposal.beforeExpected.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterExpected.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterExpected >= connectionOptimizationProposal.beforeExpected ? '+' : ''}{(connectionOptimizationProposal.afterExpected - connectionOptimizationProposal.beforeExpected).toLocaleString('ko-KR')}</b></span>
|
||||
<span>이론상 고점 <strong>{connectionOptimizationProposal.beforeMaximum.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterMaximum.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterMaximum >= connectionOptimizationProposal.beforeMaximum ? '+' : ''}{(connectionOptimizationProposal.afterMaximum - connectionOptimizationProposal.beforeMaximum).toLocaleString('ko-KR')}</b></span>
|
||||
<span>액티브 발동률 UP 합계 <strong>{connectionOptimizationProposal.beforeActivationRateUp.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}% → {connectionOptimizationProposal.afterActivationRateUp.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}%</strong><b>{connectionOptimizationProposal.afterActivationRateUp >= connectionOptimizationProposal.beforeActivationRateUp ? '+' : ''}{(connectionOptimizationProposal.afterActivationRateUp - connectionOptimizationProposal.beforeActivationRateUp).toLocaleString('ko-KR', { maximumFractionDigits: 2 })}%p</b></span>
|
||||
</div>
|
||||
{#if connectionOptimizationProposal.assignments.length > 0}
|
||||
<div class="connection-assignment-list">
|
||||
{#each connectionOptimizationProposal.assignments as assignment (`${assignment.memberId}:${assignment.socketId}`)}
|
||||
{@const member = boardMembers.find((item) => item.id === assignment.memberId)}
|
||||
{@const previousCard = assignment.previousCardId ? allCardById.get(assignment.previousCardId) : null}
|
||||
{@const card = assignment.cardId ? allCardById.get(assignment.cardId) : null}
|
||||
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? cardLabel(previousCard) : '비어 있음'} → {card ? cardLabel(card) : '배치 해제'}</strong></span>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="apply-connection" on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
|
||||
{:else}
|
||||
<p class="connection-no-changes">현재 연결 카드 배치가 이미 최적입니다.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="skill-timeline" aria-labelledby="timeline-title">
|
||||
<div class="timeline-heading">
|
||||
<div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
|
||||
@@ -774,11 +1026,12 @@
|
||||
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
|
||||
select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
|
||||
select:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
|
||||
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) clamp(140px, 14vw, 220px); align-items: end; gap: 12px; margin-top: 22px; }
|
||||
.power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
|
||||
.song-fields { display: grid; grid-template-columns: minmax(0, 4fr) clamp(110px, 11vw, 170px) clamp(140px, 14vw, 220px); align-items: start; gap: 12px; margin-top: 22px; }
|
||||
.song-picker-field { min-width: 0; }
|
||||
.power-summary { display: grid; gap: 4px; min-height: 42px; margin-top: 25px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
|
||||
.power-summary span { color: #929cab; font-size: 12px; font-weight: 750; }
|
||||
.power-summary strong { color: #f2a3c4; font-size: 16px; }
|
||||
.chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; }
|
||||
.chart-meta { display: flex; grid-column: 1 / -1; gap: 8px; height: 42px; align-items: center; }
|
||||
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
|
||||
.card-fields { display: grid; gap: 9px; margin-top: 18px; }
|
||||
.card-field-header, .card-field { display: grid; grid-template-columns: minmax(280px, 1fr) clamp(120px, 10vw, 160px) clamp(280px, 24vw, 360px) clamp(180px, 16vw, 240px); gap: 9px; }
|
||||
@@ -838,6 +1091,34 @@
|
||||
.probability-grid strong { color: #e5e9ef; font-size: 20px; }
|
||||
.probability-grid small { color: #8d96a5; font-size: 12px; font-weight: 700; }
|
||||
.probability-guide { margin: 9px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
|
||||
.connection-optimizer { margin-top: 18px; padding: 18px; border: 1px solid #3b5445; border-radius: 13px; background: #151e19; }
|
||||
.connection-optimizer-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.connection-optimizer-heading h3 { margin: 0; color: #dce8e0; font-size: 17px; }
|
||||
.connection-optimizer-heading p { margin: 5px 0 0; color: #8fa095; font-size: 13px; }
|
||||
.connection-optimizer-actions { display: flex; flex: 0 0 auto; gap: 7px; }
|
||||
.connection-optimizer button { min-height: 38px; padding: 8px 12px; border: 1px solid #46574c; border-radius: 8px; color: #becbc2; background: #202a23; font-weight: 800; cursor: pointer; }
|
||||
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }
|
||||
.connection-optimizer button:disabled { opacity: .55; cursor: default; }
|
||||
.connection-optimizer-progress { display: grid; gap: 7px; margin-top: 14px; padding: 10px 12px; border: 1px solid #314238; border-radius: 9px; background: #111a15; }
|
||||
.connection-optimizer-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.connection-optimizer-progress strong { color: #b9dac5; font-size: 13px; }
|
||||
.connection-optimizer-progress span { color: #86a391; font-size: 12px; font-weight: 800; }
|
||||
.connection-optimizer-progress progress { width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: 999px; color: #78c493; background: #27322c; appearance: none; }
|
||||
.connection-optimizer-progress progress::-webkit-progress-bar { border-radius: 999px; background: #27322c; }
|
||||
.connection-optimizer-progress progress::-webkit-progress-value { border-radius: 999px; background: #78c493; }
|
||||
.connection-optimizer-progress progress::-moz-progress-bar { border-radius: 999px; background: #78c493; }
|
||||
.connection-optimizer-error { margin: 12px 0 0; color: #e3a0a0; font-size: 13px; }
|
||||
.connection-proposal { display: grid; gap: 12px; margin-top: 14px; padding-top: 14px; border-top: 1px solid rgb(120 196 147 / 18%); }
|
||||
.connection-score-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.connection-score-comparison span { display: grid; gap: 5px; padding: 10px 12px; border-radius: 8px; color: #91a59a; background: #111813; font-size: 12px; }
|
||||
.connection-score-comparison strong { color: #e0ebe4; font-size: 14px; }
|
||||
.connection-score-comparison b { color: #82d09e; font-size: 13px; }
|
||||
.connection-assignment-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 6px; max-height: 240px; overflow-y: auto; }
|
||||
.connection-assignment-list > span { display: grid; gap: 3px; padding: 8px 10px; border: 1px solid #304038; border-radius: 7px; background: #121915; }
|
||||
.connection-assignment-list small { color: #819287; font-size: 11px; }
|
||||
.connection-assignment-list strong { overflow: hidden; color: #dbe7df; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.connection-no-changes { margin: 0; padding: 10px 12px; border-radius: 8px; color: #a9cdb6; background: #18271d; font-size: 13px; font-weight: 750; }
|
||||
.connection-proposal .apply-connection { justify-self: end; }
|
||||
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
|
||||
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
|
||||
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
|
||||
@@ -881,8 +1162,8 @@
|
||||
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
|
||||
.page-header { align-items: flex-start; }
|
||||
.panel { padding: 17px; }
|
||||
.song-fields { grid-template-columns: 1fr 100px; }
|
||||
.power-summary { grid-column: 1 / -1; }
|
||||
.song-fields { grid-template-columns: minmax(0, 1fr) 100px; }
|
||||
.power-summary { grid-column: 1 / -1; margin-top: 0; }
|
||||
.chart-meta { grid-column: 1 / -1; height: auto; }
|
||||
.card-field { grid-template-columns: minmax(0, 1fr) 102px; }
|
||||
.card-field-header { display: none; }
|
||||
@@ -896,6 +1177,10 @@
|
||||
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.reduction { grid-column: 2; justify-self: start; }
|
||||
.score-grid { grid-template-columns: 1fr; }
|
||||
.connection-optimizer-heading { align-items: stretch; flex-direction: column; }
|
||||
.connection-optimizer-actions { display: grid; }
|
||||
.connection-score-comparison { grid-template-columns: 1fr; }
|
||||
.connection-proposal .apply-connection { justify-self: stretch; }
|
||||
.timeline-heading { align-items: flex-start; flex-direction: column; }
|
||||
.timeline-legend { justify-content: flex-start; }
|
||||
.lane-label { flex-basis: 118px; min-width: 118px; }
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
import { releasedSongCharts } from '$lib/data/song-release.ts';
|
||||
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
|
||||
import PresetManager from '$lib/components/PresetManager.svelte';
|
||||
import SongPicker from '$lib/components/SongPicker.svelte';
|
||||
import {
|
||||
additionalAwakeningsRequired,
|
||||
calculateBoardEffects,
|
||||
calculateUnitPower,
|
||||
combinationSearchLimits,
|
||||
countOptimizationCandidates,
|
||||
effectiveActiveProbability,
|
||||
generateAnchoredCombinationCandidates,
|
||||
generateCombinationCandidates,
|
||||
memoryParameterPermil,
|
||||
@@ -146,6 +148,7 @@
|
||||
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
|
||||
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
|
||||
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
|
||||
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
|
||||
$: baselineChartMetadata = baselineState
|
||||
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
|
||||
: undefined;
|
||||
@@ -492,7 +495,7 @@
|
||||
function roughSkillValues(card: CardRow) {
|
||||
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]);
|
||||
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
|
||||
Math.max(1, adapted.active.cooldownSeconds) * ((adapted.active.probabilityPercent ?? 100) / 100);
|
||||
Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted);
|
||||
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
|
||||
return { adapted, activeValue, specialValue };
|
||||
}
|
||||
@@ -502,7 +505,7 @@
|
||||
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);
|
||||
Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted);
|
||||
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) =>
|
||||
@@ -687,12 +690,12 @@
|
||||
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
|
||||
return {
|
||||
...adapted,
|
||||
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
|
||||
active: { ...adapted.active, activationRateUpPercent: activationRate },
|
||||
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
||||
};
|
||||
});
|
||||
const skillPotential = optimizerCards.reduce((sum, card) => sum +
|
||||
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * ((card.active.probabilityPercent ?? 100) / 100) +
|
||||
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * effectiveActiveProbability(card) +
|
||||
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
|
||||
(card.scoreSupportPercent ?? 0) +
|
||||
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0);
|
||||
@@ -748,7 +751,7 @@
|
||||
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
|
||||
return {
|
||||
...adapted,
|
||||
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
|
||||
active: { ...adapted.active, activationRateUpPercent: activationRate },
|
||||
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
||||
};
|
||||
});
|
||||
@@ -1035,9 +1038,9 @@
|
||||
<section class="panel" aria-labelledby="conditions-title">
|
||||
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? '보유 5성 대상' : '5성 전체 대상'}</strong></div>
|
||||
<div class="song-fields">
|
||||
<label>악곡<select bind:value={selectedSongId} on:change={() => result = null}>{#each songs as song}<option value={song.id}>{song.title}</option>{/each}</select></label>
|
||||
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
|
||||
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
|
||||
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span></div>
|
||||
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>점수 계수 · {scoreCalibrationLabel}</span></div>
|
||||
</div>
|
||||
<div class="candidate-fields">
|
||||
<div class="candidate-awakening">
|
||||
@@ -1199,8 +1202,9 @@
|
||||
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
|
||||
select, .number-input { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
|
||||
select:focus, .number-input:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
|
||||
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) minmax(220px, .8fr); align-items: end; gap: 12px; margin-top: 22px; }
|
||||
.chart-meta { display: flex; align-items: center; gap: 8px; height: 42px; }
|
||||
.song-fields { display: grid; grid-template-columns: minmax(0, 4fr) clamp(110px, 11vw, 170px) minmax(220px, .8fr); align-items: start; gap: 12px; margin-top: 22px; }
|
||||
.song-picker-field { min-width: 0; }
|
||||
.chart-meta { display: flex; align-items: center; gap: 8px; height: 42px; margin-top: 25px; }
|
||||
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
|
||||
.candidate-fields { display: grid; grid-template-columns: clamp(130px, 12vw, 180px) clamp(260px, 24vw, 380px); gap: 9px; margin-top: 18px; }
|
||||
.candidate-awakening { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
|
||||
@@ -1305,7 +1309,7 @@
|
||||
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
|
||||
.panel { padding: 17px; }
|
||||
.song-fields { grid-template-columns: 1fr 100px; }
|
||||
.chart-meta { grid-column: 1 / -1; height: auto; }
|
||||
.chart-meta { grid-column: 1 / -1; height: auto; margin-top: 0; }
|
||||
.candidate-fields { grid-template-columns: 1fr; }
|
||||
.fixed-column-headings { display: none; }
|
||||
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
type AutomaticPlacementResult = {
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
socketId: string;
|
||||
socketName: string;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
rarity: number;
|
||||
@@ -155,7 +157,8 @@
|
||||
: [];
|
||||
$: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5
|
||||
&& efficiencyFormation.cardIds.every((id) => cardById.has(id))
|
||||
&& new Set(efficiencyFormation.cardIds.map((id) => cardById.get(id)?.characterId)).size === 5;
|
||||
&& new Set(efficiencyFormation.cardIds.map((id) => cardById.get(id)?.characterId)).size === 5
|
||||
&& efficiencyFormation.cardIds.some((id) => cardById.get(id)?.characterId === efficiencyFormation.leaderCharacterId);
|
||||
$: dataView = $page.url.pathname.endsWith('/cards') ? 'cards' : $page.url.pathname.endsWith('/boards') ? 'boards' : 'home';
|
||||
onMount(() => {
|
||||
try {
|
||||
@@ -241,11 +244,12 @@
|
||||
|
||||
function importAnalysisFormation() {
|
||||
if (!savedAnalysis) return;
|
||||
const analysis = savedAnalysis;
|
||||
saveEfficiencyFormation({
|
||||
cardIds: [...savedAnalysis.cardIds],
|
||||
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening),
|
||||
leaderCharacterId: savedAnalysis.leaderCharacterId,
|
||||
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId,
|
||||
cardIds: [...analysis.cardIds],
|
||||
awakenings: analysis.awakenings.map(normalizeEfficiencyAwakening),
|
||||
leaderCharacterId: analysis.leaderCharacterId,
|
||||
leaderOutfitCardId: analysis.leaderOutfitCardId,
|
||||
coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly
|
||||
});
|
||||
}
|
||||
@@ -570,7 +574,7 @@
|
||||
}, { points: 0, greenCubes: 0, greenCoreCubes: 0 });
|
||||
}
|
||||
|
||||
function currentFormationPower(memberBoards: Record<string, string[]>): number {
|
||||
function currentFormationPower(memberBoards: Record<string, string[]>, boardCards: BoardCardPlacements = userData.boardCards): number {
|
||||
if (!efficiencyFormationReady) return 0;
|
||||
const members = efficiencyFormation.cardIds.map((id, index) => ({
|
||||
card: cardById.get(id)!,
|
||||
@@ -584,7 +588,7 @@
|
||||
connect: card.details.connect
|
||||
}));
|
||||
const boardEffects = calculateBoardEffects(boardSummary, memberBoards, sourceCards, efficiencyFormation.leaderCharacterId, null, {
|
||||
placements: userData.boardCards,
|
||||
placements: boardCards,
|
||||
cards: connections
|
||||
});
|
||||
const leaderOutfit = cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume ?? null;
|
||||
@@ -626,15 +630,6 @@
|
||||
|| right.cost.greenCubes - left.cost.greenCubes;
|
||||
}
|
||||
|
||||
function upgradeBeatsRemoval(upgrade: GreenNodeEfficiency, removal: GreenNodeEfficiency): boolean {
|
||||
const cubeDifference = upgrade.greenCubeEfficiency - removal.greenCubeEfficiency;
|
||||
if (Math.abs(cubeDifference) > 1e-9) return cubeDifference > 0;
|
||||
if (upgrade.cost.greenCoreCubes === 0 && removal.cost.greenCoreCubes > 0) return true;
|
||||
if (upgrade.cost.greenCoreCubes > 0 && removal.cost.greenCoreCubes === 0) return false;
|
||||
if (upgrade.cost.greenCoreCubes === 0 && removal.cost.greenCoreCubes === 0) return false;
|
||||
return upgrade.greenCoreCubeEfficiency > removal.greenCoreCubeEfficiency;
|
||||
}
|
||||
|
||||
async function analyzeGreenNodeEfficiency() {
|
||||
if (!efficiencyFormationReady) {
|
||||
greenEfficiencyError = '서로 다른 멤버의 5성 카드 5장을 모두 선택해 주세요.';
|
||||
@@ -702,11 +697,9 @@
|
||||
}
|
||||
|
||||
const sortedRemovals = removals.sort(compareRemovalEfficiency);
|
||||
const removalThreshold = sortedRemovals[0];
|
||||
greenEfficiencyResult = {
|
||||
basePower,
|
||||
upgrades: upgrades.sort(compareUpgradeEfficiency)
|
||||
.filter((upgrade) => !removalThreshold || upgradeBeatsRemoval(upgrade, removalThreshold)),
|
||||
upgrades: upgrades.sort(compareUpgradeEfficiency),
|
||||
removals: sortedRemovals
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -818,45 +811,37 @@
|
||||
if (!socket || userData.boardCards[member.id]?.[socket.groupId]) return [];
|
||||
return [{ member, socket }];
|
||||
});
|
||||
|
||||
if (candidateCards.length === 0) {
|
||||
showMessage('자동 배치에 사용할 수 있는 미배치 보유 카드가 없습니다.', true);
|
||||
return;
|
||||
}
|
||||
if (emptyGreenSlots.length === 0) {
|
||||
showMessage('비어 있는 초록색 카드 슬롯이 없습니다. 기존 배치는 변경하지 않았습니다.');
|
||||
showMessage('비어 있는 중앙 연결 노드가 없습니다. 기존 배치는 변경하지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const weights = candidateCards.map((card) => emptyGreenSlots.map(({ member, socket }) => greenSlotScore(member, socket, card)));
|
||||
const matches = maximumWeightMatching(weights);
|
||||
const matches = maximumWeightMatching(candidateCards.map((card) =>
|
||||
emptyGreenSlots.map(({ member, socket }) => greenSlotScore(member, socket, card))
|
||||
));
|
||||
if (matches.length === 0) {
|
||||
showMessage('활성화된 초록 노드에 효과를 주는 배치 조합을 찾지 못했습니다.', true);
|
||||
showMessage('활성화된 초록 노드에 효과를 주는 중앙 배치를 찾지 못했습니다.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
automaticPlacementSnapshot = cloneBoardCards(userData.boardCards);
|
||||
const boardCards = cloneBoardCards(userData.boardCards);
|
||||
const placementResult: AutomaticPlacementResult[] = [];
|
||||
for (const match of matches) {
|
||||
automaticPlacementResult = matches.map((match) => {
|
||||
const card = candidateCards[match.rowIndex];
|
||||
const slot = emptyGreenSlots[match.columnIndex];
|
||||
if (!card || !slot) continue;
|
||||
boardCards[slot.member.id] = { ...(boardCards[slot.member.id] ?? {}), [slot.socket.groupId]: card.id };
|
||||
placementResult.push({
|
||||
memberId: slot.member.id,
|
||||
memberName: slot.member.name,
|
||||
cardId: card.id,
|
||||
cardName: cardLabel(card),
|
||||
rarity: card.rarity
|
||||
});
|
||||
}
|
||||
automaticPlacementResult = placementResult.sort((left, right) =>
|
||||
const { member, socket } = emptyGreenSlots[match.columnIndex];
|
||||
boardCards[member.id] = { ...(boardCards[member.id] ?? {}), [socket.groupId]: card.id };
|
||||
return {
|
||||
memberId: member.id, memberName: member.name, socketId: socket.groupId, socketName: '중앙',
|
||||
cardId: card.id, cardName: cardLabel(card), rarity: card.rarity
|
||||
};
|
||||
}).sort((left, right) =>
|
||||
(boardMembers.find((member) => member.id === left.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
|
||||
- (boardMembers.find((member) => member.id === right.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
persist({ ...userData, boardCards });
|
||||
showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`);
|
||||
showMessage(`기존 배치를 유지하고 중앙 연결 노드 ${matches.length}곳에 보유 카드를 자동 배치했습니다.`);
|
||||
}
|
||||
|
||||
function undoAutomaticPlacement() {
|
||||
@@ -865,7 +850,7 @@
|
||||
automaticPlacementSnapshot = null;
|
||||
automaticPlacementResult = [];
|
||||
persist({ ...userData, boardCards });
|
||||
showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.');
|
||||
showMessage('방금 실행한 중앙 연결 노드 자동 배치를 취소했습니다.');
|
||||
}
|
||||
|
||||
function cardLabel(card: CardSummary): string {
|
||||
@@ -875,7 +860,7 @@
|
||||
function placedCardName(cardId: string | undefined): string {
|
||||
if (!cardId) return '연결 노드';
|
||||
const card = connectCardById.get(cardId);
|
||||
return card ? cardLabel(card) : cardId;
|
||||
return card ? `${card.rarity}★ ${cardLabel(card)}` : cardId;
|
||||
}
|
||||
|
||||
function connectionBoostsForMember(
|
||||
@@ -917,6 +902,7 @@
|
||||
function assignBoardCard(cardId: string) {
|
||||
if (!selectedSocketGroupId) return;
|
||||
automaticPlacementSnapshot = null;
|
||||
automaticPlacementResult = [];
|
||||
const boardCards = Object.fromEntries(Object.entries(userData.boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
|
||||
for (const [memberId, placements] of Object.entries(boardCards)) {
|
||||
for (const [socketId, placedCardId] of Object.entries(placements)) {
|
||||
@@ -932,6 +918,7 @@
|
||||
function removeBoardCard() {
|
||||
if (!selectedSocketGroupId) return;
|
||||
automaticPlacementSnapshot = null;
|
||||
automaticPlacementResult = [];
|
||||
const placements = { ...(userData.boardCards[selectedMemberId] ?? {}) };
|
||||
delete placements[selectedSocketGroupId];
|
||||
const boardCards = { ...userData.boardCards };
|
||||
@@ -1168,7 +1155,7 @@
|
||||
<div class="automatic-placement">
|
||||
<div>
|
||||
<strong>간편 설정</strong>
|
||||
<small>모든 멤버의 기본 초록 노드를 활성화하거나, 보유 카드를 중앙 연결 노드의 빈 슬롯에 자동 배치합니다.</small>
|
||||
<small>모든 멤버의 기본 초록 노드를 활성화하거나, 기존 배치를 유지한 채 중앙 연결 노드의 빈 슬롯에 보유 카드를 자동 배치합니다.</small>
|
||||
</div>
|
||||
<div class="automatic-placement-actions">
|
||||
<button type="button" on:click={activateCommonGreenNodesForAllMembers}>모든 멤버 초록 3칸 활성화</button>
|
||||
@@ -1271,7 +1258,7 @@
|
||||
<div class="efficiency-columns">
|
||||
<section class="efficiency-column upgrade" aria-labelledby="upgrade-efficiency-title">
|
||||
<div class="efficiency-column-heading">
|
||||
<div><h3 id="upgrade-efficiency-title">우선 활성화</h3><p>초기화 검토 1순위보다 효율이 높은 노드만 표시</p></div>
|
||||
<div><h3 id="upgrade-efficiency-title">우선 활성화</h3><p>초기화 효율보다 낮은 후보도 포함해 효율순으로 최대 20개 표시</p></div>
|
||||
</div>
|
||||
{#if greenEfficiencyResult.upgrades.length > 0}
|
||||
<div class="efficiency-list">
|
||||
@@ -1367,7 +1354,7 @@
|
||||
class:efficiency-focus={position.groupId === efficiencyFocusGroupId}
|
||||
style={`left: ${point.x}px; top: ${point.y}px; width: ${size}px; height: ${size}px;`}
|
||||
title={`${boardNodeLabel(node.type)} · ${boardEffectDescription(node.effect, selectedBoardMember.name) || placedCardName(selectedBoardCardPlacements[position.groupId])}${selectedConnectionBoosts[position.groupId] ? ` · ${active ? '연결 효과 적용' : '연결 범위'} +${selectedConnectionBoosts[position.groupId].percent}%` : ''}`}
|
||||
aria-label={`${boardNodeLabel(node.type)} ${node.grade}등급 노드, ${active ? '활성화됨' : '비활성화됨'}${selectedConnectionBoosts[position.groupId] ? `, 연결 ${active ? '효과 적용 중' : '범위에 포함'}` : ''}`}
|
||||
aria-label={`${boardNodeLabel(node.type)} ${node.grade}등급 노드, ${active ? '활성화됨' : '비활성화됨'}${node.type === 'connection' && selectedBoardCardPlacements[position.groupId] ? `, 배치 카드 ${placedCardName(selectedBoardCardPlacements[position.groupId])}` : ''}${selectedConnectionBoosts[position.groupId] ? `, 연결 ${active ? '효과 적용 중' : '범위에 포함'}` : ''}`}
|
||||
aria-pressed={active}
|
||||
on:click={() => handleBoardNodeClick(position.groupId, node)}
|
||||
>
|
||||
@@ -1380,7 +1367,12 @@
|
||||
{#if boardEffectIcon(node)}<img class="board-node-icon" src={boardEffectIcon(node) ?? ''} alt="" draggable="false" />{/if}
|
||||
{#if node.type === 'connection' && selectedBoardCardPlacements[position.groupId]}
|
||||
{@const placedCard = connectCardById.get(selectedBoardCardPlacements[position.groupId])}
|
||||
{#if placedCard}<span class="socket-card" aria-hidden="true">{placedCard.name.slice(0, 2)}</span>{/if}
|
||||
{#if placedCard}
|
||||
<span class="socket-card" aria-hidden="true">
|
||||
<span class="socket-card-name">{placedCard.name.slice(0, 2)}</span>
|
||||
<small class:rarity-five={placedCard.rarity === 5}>{placedCard.rarity}★</small>
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1564,7 +1556,7 @@
|
||||
.automatic-placement-result-item span { color: #91a59a; font-size: 12px; }
|
||||
.automatic-placement-result-item b { display: flex; align-items: center; gap: 7px; min-width: 0; color: #dbe8df; font-size: 13px; }
|
||||
.automatic-placement-result-item i { flex: 0 0 auto; color: #e7bd70; font-size: 11px; font-style: normal; }
|
||||
.green-efficiency { order: 2; margin: 18px 0 0; padding: 0 16px 16px; border: 1px solid #394453; border-radius: 16px; background: #151b23; }
|
||||
.green-efficiency { order: 3; margin: 18px 0 0; padding: 0 16px 16px; border: 1px solid #394453; border-radius: 16px; background: #151b23; }
|
||||
.green-efficiency > summary { display: flex; align-items: center; min-height: 62px; margin: 0 -16px; padding: 12px 16px; color: #dce6e0; cursor: pointer; list-style: none; }
|
||||
.green-efficiency > summary::-webkit-details-marker { display: none; }
|
||||
.green-efficiency > summary:hover { background: rgb(255 255 255 / 3%); }
|
||||
@@ -1657,7 +1649,10 @@
|
||||
.board-node.connect-applied::after { inset: -8px; border: 4px solid #fff; }
|
||||
.board-node.connect-applied::before { content: '+'; position: absolute; z-index: 4; top: -13px; right: -13px; display: grid; place-items: center; width: 19px; height: 19px; border: 2px solid #151a22; border-radius: 50%; color: #111820; background: #fff; font-size: 13px; font-weight: 950; line-height: 1; pointer-events: none; }
|
||||
.board-node.efficiency-focus { z-index: 6; outline: 4px solid #fff; outline-offset: 6px; border-radius: 50%; }
|
||||
.socket-card { position: absolute; inset: 19%; z-index: 2; display: grid; place-items: center; overflow: hidden; border-radius: 50%; color: #fff; background: #3b2d46; font-size: 12px; font-weight: 900; line-height: 1; pointer-events: none; }
|
||||
.socket-card { position: absolute; inset: 19%; z-index: 2; pointer-events: none; }
|
||||
.socket-card-name { display: grid; width: 100%; height: 100%; place-items: center; overflow: hidden; border-radius: 50%; color: #fff; background: #3b2d46; font-size: 12px; font-weight: 900; line-height: 1; }
|
||||
.socket-card small { position: absolute; right: -12px; bottom: -8px; min-width: 24px; padding: 2px 4px; border: 2px solid #151a22; border-radius: 999px; color: #fff; background: #7562c9; font-size: 9px; font-weight: 950; line-height: 1.1; white-space: nowrap; }
|
||||
.socket-card small.rarity-five { background: #d96f9d; }
|
||||
.board-node:hover { z-index: 4; transform: translate(-50%, -50%) scale(1.1); }
|
||||
.board-node.center { cursor: default; }
|
||||
.board-node:focus-visible { z-index: 5; outline: 3px solid rgb(255 255 255 / 55%); outline-offset: 3px; border-radius: 50%; }
|
||||
|
||||
Reference in New Issue
Block a user