feat: improve board review and connection optimization assumptions
Publish GitHub Pages output / publish (push) Successful in 45s
Publish GitHub Pages output / publish (push) Successful in 45s
This commit is contained in:
1 parent
0b794edc80
commit
d18972382a
8 files changed
+277
-29
No files matched your search
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { refineBoardConnectionSongBonus } from '$lib/engine/board-connection-song-refinement';
|
||||
import { assumeTeamBlueBoards, missingBoardOpenings, type BoardOpeningRequirement } from '$lib/engine/board-blue-assumption.ts';
|
||||
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
|
||||
@@ -35,6 +36,8 @@
|
||||
type MemberBoards = Record<string, string[]>;
|
||||
type BoardCards = Record<string, Record<string, string>>;
|
||||
type ConnectionOptimizationProposal = {
|
||||
assumedBlue: boolean;
|
||||
openingRequirements: BoardOpeningRequirement[];
|
||||
placements: BoardCards;
|
||||
assignments: { memberId: string; socketId: string; previousCardId: string | null; cardId: string | null }[];
|
||||
beforeExpected: number;
|
||||
@@ -215,6 +218,7 @@
|
||||
let errorMessage = '';
|
||||
let analysisReady = false;
|
||||
let connectionOptimizationRunning = false;
|
||||
let assumeBlueForConnections = false;
|
||||
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
|
||||
let connectionOptimizationError = '';
|
||||
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
|
||||
@@ -605,8 +609,8 @@
|
||||
};
|
||||
}
|
||||
|
||||
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride) {
|
||||
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
|
||||
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride, boards = effectiveMemberBoards) {
|
||||
const localBoardEffects = calculateBoardEffects(boardSummary, boards, selectedSourceCards, leaderCharacterId, chartSummary, {
|
||||
placements,
|
||||
cards: connectionCards
|
||||
}, {
|
||||
@@ -684,19 +688,28 @@
|
||||
if (resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
|
||||
const analysisResult = result;
|
||||
const chartSummary = selectedChart;
|
||||
const assumedBlue = assumeBlueForConnections;
|
||||
const optimizationInputKey = currentAnalysisKey;
|
||||
const optimizationBoards = assumeTeamBlueBoards(boardSummary, effectiveMemberBoards, selectedSourceCards.map((card) => card.characterId), assumedBlue);
|
||||
const openingRequirements = assumedBlue ? missingBoardOpenings(boardSummary, memberBoards, optimizationBoards) : [];
|
||||
const scoreContext = (placements: BoardCards) => connectionScoreContext(placements, chartSummary, songScoreBonusOverride, optimizationBoards);
|
||||
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) =>
|
||||
const beforeContext = scoreContext(boardCards);
|
||||
const emptyContext = scoreContext({});
|
||||
const baseline = assumedBlue ? optimizeExactOrderAndCooldowns(beforeContext.cards, chart, {
|
||||
perfectNoteScore: perfectNoteScoreFromPower(beforeContext.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
directSongScoreBonusPercent: beforeContext.boardEffects.directSongScoreBonusPercent
|
||||
}) : analysisResult;
|
||||
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => baseline.order.map((cardId) =>
|
||||
context.cards.find((card) => card.id === cardId)!
|
||||
);
|
||||
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
|
||||
orderedCards(context), analysisResult.cooldownReductions, chart, {
|
||||
orderedCards(context), baseline.cooldownReductions, chart, {
|
||||
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
|
||||
}
|
||||
@@ -715,7 +728,7 @@
|
||||
const key = `${memberId}:${socketId}:${effectKey}`;
|
||||
const cached = placementMetricCache.get(key);
|
||||
if (cached) return cached;
|
||||
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
|
||||
const context = scoreContext({ [memberId]: { [socketId]: card.id } });
|
||||
const metrics = {
|
||||
score: referenceScore(context) - emptyScore,
|
||||
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
|
||||
@@ -725,7 +738,7 @@
|
||||
};
|
||||
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 active = new Set(optimizationBoards[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'))
|
||||
@@ -746,7 +759,7 @@
|
||||
}
|
||||
connectionOptimizationProgress = { stage: '추천 배치 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
|
||||
const optimized = optimizeBoardConnectionCards(boardSummary, optimizationBoards, candidateCards, {
|
||||
currentPlacements: boardCards,
|
||||
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
|
||||
preservePlacementOnTie: () => true,
|
||||
@@ -754,13 +767,13 @@
|
||||
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
|
||||
});
|
||||
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
|
||||
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, effectiveMemberBoards, candidateCards, optimized.placements, {
|
||||
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, optimizationBoards, candidateCards, optimized.placements, {
|
||||
currentPlacements: boardCards,
|
||||
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
|
||||
song: chartSummary,
|
||||
songBonusEnabled: songScoreBonusOverride === null,
|
||||
evaluate: (placements) => {
|
||||
const context = connectionScoreContext(placements, chartSummary);
|
||||
const context = scoreContext(placements);
|
||||
return { score: referenceScore(context), activationRateUp: totalActivationRateUp(context) };
|
||||
},
|
||||
onProgress: async (checks) => {
|
||||
@@ -770,16 +783,16 @@
|
||||
});
|
||||
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
|
||||
const afterContext = scoreContext(optimized.placements);
|
||||
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
|
||||
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
|
||||
});
|
||||
const afterActivationRateUp = totalActivationRateUp(afterContext);
|
||||
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
|
||||
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
|
||||
if (after.theoreticalMaxScore < baseline.theoreticalMaxScore) {
|
||||
throw new Error(`동일 보드 조건의 기존 배치 최고점(${baseline.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
|
||||
}
|
||||
if (after.theoreticalMaxScore === analysisResult.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
|
||||
if (after.theoreticalMaxScore === baseline.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
|
||||
throw new Error('최고점이 같고 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
|
||||
}
|
||||
const changedAssignments = boardMembers.flatMap((member) => {
|
||||
@@ -794,12 +807,15 @@
|
||||
cardId: afterPlacements[socketId] ?? null
|
||||
}));
|
||||
});
|
||||
if (optimizationInputKey !== currentAnalysisKey) throw new Error('탐색 중 입력 조건이 변경되었습니다. 다시 분석한 뒤 연결 카드 최적화를 실행해 주세요.');
|
||||
connectionOptimizationProposal = {
|
||||
assumedBlue,
|
||||
openingRequirements,
|
||||
placements: optimized.placements,
|
||||
assignments: changedAssignments,
|
||||
beforeExpected: analysisResult.expectedScore,
|
||||
beforeExpected: baseline.expectedScore,
|
||||
afterExpected: after.expectedScore,
|
||||
beforeMaximum: analysisResult.theoreticalMaxScore,
|
||||
beforeMaximum: baseline.theoreticalMaxScore,
|
||||
afterMaximum: after.theoreticalMaxScore,
|
||||
beforeActivationRateUp,
|
||||
afterActivationRateUp
|
||||
@@ -813,7 +829,7 @@
|
||||
}
|
||||
|
||||
async function applyConnectionOptimization(): Promise<void> {
|
||||
if (resultStale || !connectionOptimizationProposal) return;
|
||||
if (resultStale || !connectionOptimizationProposal || connectionOptimizationProposal.openingRequirements.length > 0) return;
|
||||
connectionOptimizationSnapshot = Object.fromEntries(Object.entries(boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
|
||||
saveBoardCardPlacements(connectionOptimizationProposal.placements);
|
||||
connectionOptimizationProposal = null;
|
||||
@@ -1226,6 +1242,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<p>단, 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 해당 카드의 기존 배치를 우선 유지합니다.</p>
|
||||
<label class="connection-assumption-option">
|
||||
<input type="checkbox" bind:checked={assumeBlueForConnections} disabled={running || connectionOptimizationRunning} on:change={clearConnectionOptimizationProposal}>
|
||||
<span><strong>파란 보드 개방 가정</strong><small>편성 5명의 전체 파란 노드와 파란 영역 연결 소켓을 임시 개방해 추천합니다. 다른 멤버·다른 색상 보드는 유지하며 저장된 보드는 변경하지 않습니다.</small></span>
|
||||
</label>
|
||||
{#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>
|
||||
@@ -1235,6 +1255,21 @@
|
||||
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
|
||||
{#if connectionOptimizationProposal}
|
||||
<div class="connection-proposal" aria-live="polite">
|
||||
{#if connectionOptimizationProposal.assumedBlue}
|
||||
<p class="connection-assumption-notice"><strong>개방 가정 결과</strong> · 아래 전후 점수는 모두 동일한 개방 가정 기준입니다. 현재 성장 대비 상승량이 아니라 카드 재배치 효과를 비교합니다.</p>
|
||||
{#if connectionOptimizationProposal.openingRequirements.length > 0}
|
||||
<div class="connection-opening-requirements">
|
||||
<strong>적용 전 필요한 보드 개방</strong>
|
||||
<p>아래 노드는 실제 저장 보드에서 미개방 상태입니다. 사용자 데이터에서 실제 개방 상태를 갱신한 뒤 다시 분석·최적화해 주세요. 지금은 추천만 확인할 수 있습니다.</p>
|
||||
<ul>{#each connectionOptimizationProposal.openingRequirements as requirement}
|
||||
<li><strong>{boardMembers.find((member) => member.id === requirement.memberId)?.name ?? requirement.memberId}</strong> · 능력치 노드 {requirement.nodeIds.length}개 · 연결 소켓 {requirement.socketIds.length}개
|
||||
<details><summary>필요 노드 확인</summary><p>{requirement.nodeIds.join(', ')}{requirement.socketIds.length ? ` · 연결: ${requirement.socketIds.map((id) => `${connectionSocketName(requirement.memberId, id)} (${id})`).join(', ')}` : ''}</p></details>
|
||||
</li>
|
||||
{/each}</ul>
|
||||
<a href="/data/boards" target="_blank" rel="noreferrer">사용자 보드 열기</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<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>
|
||||
@@ -1249,7 +1284,7 @@
|
||||
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? `${previousCard.rarity}★ ${cardLabel(previousCard)}` : '비어 있음'} → {card ? `${card.rarity}★ ${cardLabel(card)}` : '배치 해제'}</strong></span>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
|
||||
<button type="button" class="apply-connection" disabled={resultStale || running || connectionOptimizationProposal.openingRequirements.length > 0} on:click={applyConnectionOptimization}>{connectionOptimizationProposal.openingRequirements.length > 0 ? '필요 보드 개방 후 적용 가능' : '변경 배치를 적용하고 다시 분석'}</button>
|
||||
{:else}
|
||||
<p class="connection-no-changes">이번 보정에서는 기존 배치보다 좋은 배치를 찾지 못했습니다.</p>
|
||||
{/if}
|
||||
@@ -1506,6 +1541,13 @@
|
||||
.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-assumption-option { display: flex; align-items: flex-start; gap: 10px; margin-top: 14px; padding: 12px; border: 1px solid #46574c; border-radius: 9px; }
|
||||
.connection-assumption-option span { display: grid; gap: 5px; }
|
||||
.connection-assumption-option small { color: #a9b8ad; line-height: 1.5; }
|
||||
.connection-assumption-notice, .connection-opening-requirements { padding: 12px; border: 1px solid #967445; border-radius: 8px; background: #29251c; color: #eed6ac; }
|
||||
.connection-opening-requirements li { margin: 8px 0; }
|
||||
.connection-opening-requirements details p { overflow-wrap: anywhere; }
|
||||
.connection-opening-requirements a { color: #a6ddba; }
|
||||
.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; }
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
let activeSearchChart: ExactSongChart | null = null;
|
||||
let primaryRankings: RankedResult[] = [];
|
||||
let searchPhase: 'idle' | 'screening' | 'exact' | 'probability' = 'idle';
|
||||
$: searchStep = searchPhase === 'screening' ? 1 : searchPhase === 'probability' ? 4 : exactPass === 'refinement' ? 3 : 2;
|
||||
let screeningProgress = 0;
|
||||
let errorMessage = '';
|
||||
let ready = false;
|
||||
@@ -1379,7 +1380,14 @@
|
||||
</label>
|
||||
<p id="result-limit-guide" class="candidate-guide">다음 탐색부터 적용합니다. 결과 수를 늘리면 달성 확률 계산 시간이 늘어납니다. 분석한 조합이 적으면 선택한 수보다 적게 표시됩니다.</p>
|
||||
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
|
||||
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
|
||||
{#if running}
|
||||
<div class="search-step-status" role="status" aria-live="polite">
|
||||
<strong class="search-step-badge" aria-label={`현재 단계 ${searchStep}/4`}>{searchStep}<span>/4</span></strong>
|
||||
<div><small>현재 진행 단계</small><strong>{['후보 선별', '선별 후보 평가', '확장 후보 검증', '달성 확률 계산'][searchStep - 1]}</strong></div>
|
||||
</div>
|
||||
<p class="candidate-guide">퍼센트는 현재 단계의 진행률입니다. 확장 후보가 없으면 3단계는 건너뜁니다.</p>
|
||||
{/if}
|
||||
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `${exactPass === 'refinement' ? '확장 후보 검증' : '선별 후보 평가'} ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
|
||||
<label class="compute-option">
|
||||
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
|
||||
<span aria-hidden="true"></span>
|
||||
@@ -1391,7 +1399,7 @@
|
||||
</section>
|
||||
|
||||
<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}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.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 userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if}
|
||||
{#if !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if}
|
||||
{#if transferError}<p class="error" role="alert">{transferError}</p>{/if}
|
||||
@@ -1545,6 +1553,12 @@
|
||||
.hint { margin: 13px 0 0; color: #858f9f; font-size: 13px; line-height: 1.6; }
|
||||
.error { margin: 14px 0 0; color: #ff9f9f; font-size: 13px; }
|
||||
.search { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
|
||||
.search-step-status { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 14px 16px; border: 1px solid #b65c84; border-radius: 10px; background: #30212c; }
|
||||
.search-step-badge { display: flex; align-items: baseline; gap: 3px; flex-shrink: 0; padding: 8px 12px; border-radius: 8px; background: #ed8bb5; color: #24131e; font-size: 26px; font-weight: 900; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||
.search-step-badge span { font-size: 16px; }
|
||||
.search-step-status > div { display: grid; gap: 3px; }
|
||||
.search-step-status small { color: #d6a1ba; font-size: 11px; font-weight: 700; }
|
||||
.search-step-status > div > strong { color: #ffe4f0; font-size: 16px; }
|
||||
.search:hover:not(:disabled) { background: #f2a0c2; }
|
||||
.search:disabled { opacity: .55; cursor: default; }
|
||||
.owned-cards-option, .compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { boardMemberOptions, unplacedOwnedCards } from '$lib/engine/board-editor-options.ts';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import cardSummary from '$lib/data/cards-summary.json';
|
||||
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
|
||||
@@ -113,6 +114,8 @@
|
||||
let boardDragStart = { x: 0, y: 0, left: 0, top: 0 };
|
||||
let selectedSocketGroupId = '';
|
||||
let boardCardSearch = '';
|
||||
let boardMemberSort: 'default' | 'most' | 'least' = 'default';
|
||||
let unplacedOwnedOnly = false;
|
||||
let automaticPlacementSnapshot: BoardCardPlacements | null = null;
|
||||
let automaticPlacementResult: AutomaticPlacementResult[] = [];
|
||||
let savedAnalysis: SavedAnalysisState | null = null;
|
||||
@@ -136,6 +139,7 @@
|
||||
$: filteredFiveStarCards = filteredCards.filter((card) => card.rarity === 5);
|
||||
$: filteredFourStarCards = filteredCards.filter((card) => card.rarity === 4);
|
||||
$: selectedBoardMember = boardMembers.find((member) => member.id === selectedMemberId) ?? null;
|
||||
$: sortedBoardMembers = boardMemberOptions(boardMembers, boardModels, userData.memberBoards, boardMemberSort);
|
||||
$: selectedActiveBoardNodes = userData.memberBoards[selectedMemberId] ?? [];
|
||||
$: selectedActiveBoardNodeCount = new Set(selectedActiveBoardNodes.filter((nodeId) => nodeId !== centerNodeId())).size;
|
||||
$: selectedBoardNodeCount = selectedBoardMember
|
||||
@@ -148,7 +152,7 @@
|
||||
selectedActiveBoardNodes,
|
||||
selectedBoardMember?.positionGroupId ?? ''
|
||||
);
|
||||
$: filteredConnectCards = connectCards.filter((card) => {
|
||||
$: filteredConnectCards = unplacedOwnedCards(connectCards, userData.collection, userData.boardCards, unplacedOwnedOnly).filter((card) => {
|
||||
return cardMatchesSearch(card, boardCardSearch);
|
||||
});
|
||||
$: allBoardMaterialTotals = boardMaterialTotals(userData.memberBoards);
|
||||
@@ -381,10 +385,10 @@
|
||||
}
|
||||
|
||||
function selectAdjacentBoardMember(offset: -1 | 1) {
|
||||
if (boardMembers.length < 2) return;
|
||||
const currentIndex = boardMembers.findIndex((member) => member.id === selectedMemberId);
|
||||
const nextIndex = (Math.max(0, currentIndex) + offset + boardMembers.length) % boardMembers.length;
|
||||
selectedMemberId = boardMembers[nextIndex].id;
|
||||
if (sortedBoardMembers.length < 2) return;
|
||||
const currentIndex = sortedBoardMembers.findIndex((member) => member.id === selectedMemberId);
|
||||
const nextIndex = (Math.max(0, currentIndex) + offset + sortedBoardMembers.length) % sortedBoardMembers.length;
|
||||
selectedMemberId = sortedBoardMembers[nextIndex].id;
|
||||
finishBoardMemberChange();
|
||||
}
|
||||
|
||||
@@ -1400,11 +1404,19 @@
|
||||
<label class="member-select">
|
||||
<span>멤버 선택</span>
|
||||
<select bind:value={selectedMemberId} on:change={finishBoardMemberChange}>
|
||||
{#each boardMembers as member}<option value={member.id}>{member.name}</option>{/each}
|
||||
{#each sortedBoardMembers as member}<option value={member.id}>{member.name} · {member.openedCount}개 개방</option>{/each}
|
||||
</select>
|
||||
<small><kbd>A</kbd> 이전 멤버 · <kbd>D</kbd> 다음 멤버</small>
|
||||
</label>
|
||||
<div class="member-navigation" aria-label="멤버 보드 이동">
|
||||
<label class="member-select">
|
||||
<span>멤버 정렬</span>
|
||||
<select bind:value={boardMemberSort}>
|
||||
<option value="default">기본순</option>
|
||||
<option value="most">보드 개방 많은 순</option>
|
||||
<option value="least">보드 개방 적은 순</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" on:click={() => selectAdjacentBoardMember(-1)} aria-label="이전 멤버 보드">‹ 이전</button>
|
||||
<button type="button" on:click={() => selectAdjacentBoardMember(1)} aria-label="다음 멤버 보드">다음 ›</button>
|
||||
</div>
|
||||
@@ -1502,11 +1514,13 @@
|
||||
</div>
|
||||
<p class="card-picker-note">배치한 카드의 범위에 포함된 활성 노드만 강화됩니다. 같은 카드를 다시 선택하면 기존 위치에서 이곳으로 이동합니다.</p>
|
||||
<input class="card-picker-search" type="search" bind:value={boardCardSearch} placeholder="멤버 또는 버전 검색" aria-label="연결 카드 검색" />
|
||||
<label class="card-picker-filter"><input type="checkbox" bind:checked={unplacedOwnedOnly} /> 미배치 보유 카드만</label>
|
||||
{#if selectedBoardCardPlacements[selectedSocketGroupId]}
|
||||
{@const currentCard = connectCardById.get(selectedBoardCardPlacements[selectedSocketGroupId])}
|
||||
<div class="current-placement"><span>현재 배치</span><strong>{currentCard ? cardLabel(currentCard) : selectedBoardCardPlacements[selectedSocketGroupId]}</strong><button type="button" on:click={removeBoardCard}>배치 해제</button></div>
|
||||
{/if}
|
||||
<div class="connect-card-list">
|
||||
{#if filteredConnectCards.length === 0}<p class="card-picker-note">조건에 맞는 연결 카드가 없습니다.</p>{/if}
|
||||
{#each filteredConnectCards as card}
|
||||
{@const effect = selectedConnectEffect(card)}
|
||||
{@const placement = cardPlacementLabel(card.id)}
|
||||
@@ -1645,13 +1659,14 @@
|
||||
.board-member-settings { order: 1; margin-bottom: 0; }
|
||||
.board-tools { order: 0; }
|
||||
.board-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; }
|
||||
.card-picker-filter { display: flex; align-items: center; gap: 8px; margin: 12px 0; color: #c9d0dc; font-size: 13px; }
|
||||
.board-guidance { margin: 0 0 12px; padding: 12px 14px; border: 1px solid #4a3b46; border-radius: 10px; color: #c8b7c1; background: rgb(239 139 182 / 7%); font-size: 13px; line-height: 1.6; }
|
||||
.member-select { display: grid; gap: 5px; width: min(360px, 100%); }
|
||||
.member-select span { color: #818b9c; font-size: 12px; font-weight: 750; }
|
||||
.member-select select { height: 38px; padding: 0 10px; border: 1px solid #394252; border-radius: 8px; color: #dfe3ea; background: #12171f; }
|
||||
.member-select small { color: #6f7888; font-size: 11px; }
|
||||
.member-select kbd { display: inline-grid; min-width: 18px; height: 18px; padding: 0 4px; place-items: center; border: 1px solid #465064; border-bottom-width: 2px; border-radius: 4px; color: #c9d0dc; background: #171d27; font: 700 10px/1 Pretendard, "Noto Sans KR", sans-serif; }
|
||||
.member-navigation { display: flex; gap: 7px; }
|
||||
.member-navigation { display: flex; align-items: flex-end; gap: 7px; }
|
||||
.member-navigation button { min-width: 82px; height: 38px; padding: 0 12px; border: 1px solid #465064; border-radius: 8px; color: #d5dbe4; background: #202733; font-size: 13px; font-weight: 850; cursor: pointer; }
|
||||
.member-navigation button:hover { border-color: #d96b9a; color: #f4b0cc; }
|
||||
.board-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 20px 0 10px; }
|
||||
@@ -1852,6 +1867,7 @@
|
||||
.board-heading { align-items: stretch; flex-direction: column; }
|
||||
.member-select { width: 100%; }
|
||||
.member-navigation { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.member-navigation .member-select { grid-column: 1 / -1; }
|
||||
.member-navigation button { width: 100%; min-height: 44px; }
|
||||
.board-toolbar { align-items: flex-start; gap: 10px; }
|
||||
.automatic-placement { align-items: stretch; flex-direction: column; gap: 10px; }
|
||||
|
||||
Reference in new issue
Block a user