diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index df38566..e485b01 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -399,18 +399,110 @@ try { `Expected all 23 campaign officers to use loaded lightweight portrait cards: ${JSON.stringify(missingMidFormationPortraits)}` ); - await page.mouse.move(530, 260); - await page.waitForFunction(() => Boolean(window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId), undefined, { timeout: 30000 }); - const hoveredPortraitUnitId = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId); + const midFormationSelectedBefore = [...(midFormationPortraitState?.selectedSortieUnitIds ?? [])]; + const midFormationCampaignSelectedBefore = [...(midFormationPortraitState?.campaign?.selectedSortieUnitIds ?? [])]; + await page.mouse.click(210, 447); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieFocusedUnitId === 'ma-dai', undefined, { timeout: 30000 }); + const swapFocusState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + JSON.stringify(swapFocusState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) && + JSON.stringify(swapFocusState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore), + `Expected focusing Ma Dai to preserve the actual sortie roster: ${JSON.stringify(swapFocusState?.selectedSortieUnitIds)}` + ); + + await page.mouse.move(530, 544); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap'; + }, undefined, { timeout: 30000 }); + const midFormationSwapPreviewProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const view = scene?.sortieComparisonPanelView; + return { + state: window.__HEROS_DEBUG__?.camp(), + panel: { + title: view?.title?.text ?? null, + headline: view?.headline?.text ?? null, + impact: view?.impact?.text ?? null, + detail: view?.detail?.text ?? null + } + }; + }); + const midFormationSwapPreviewState = midFormationSwapPreviewProbe.state; + const swapPreview = midFormationSwapPreviewState?.sortieComparisonPreview; + const expectedProjectedSwapIds = midFormationSelectedBefore.map((unitId) => unitId === 'ma-dai' ? 'guan-yu' : unitId); assert( midFormationPortraitState.sortiePortraitRoster.some( - (unit) => unit.id === hoveredPortraitUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-') + (unit) => unit.id === swapPreview?.incomingUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-') ), - `Expected portrait-card hover feedback for a mapped officer: ${JSON.stringify(hoveredPortraitUnitId)}` + `Expected portrait-card hover feedback for a mapped swap candidate: ${JSON.stringify(swapPreview?.incomingUnitId)}` + ); + assert( + swapPreview?.mode === 'swap' && + swapPreview?.incomingUnitId === 'guan-yu' && + swapPreview?.outgoingUnitId === 'ma-dai' && + JSON.stringify(swapPreview?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) && + JSON.stringify(swapPreview?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + JSON.stringify(swapPreview?.current?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) && + JSON.stringify(swapPreview?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + JSON.stringify(swapPreview?.comparison?.current?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) && + JSON.stringify(swapPreview?.comparison?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + swapPreview?.metrics?.length === 4 && + swapPreview.metrics.some((metric) => metric.delta !== 0) && + swapPreview?.comparison?.lostRoles?.includes('flank') && + swapPreview?.comparison?.activeBondDelta === 0 && + swapPreview?.comparison?.gainedBonds?.length > 0 && + swapPreview?.comparison?.lostBonds?.length > 0, + `Expected a meaningful non-destructive Ma Dai to Guan Yu swap preview: ${JSON.stringify(swapPreview)}` + ); + assert( + midFormationSwapPreviewProbe.panel.title === '가상 교체 · 마대 → 관우' && + midFormationSwapPreviewProbe.panel.headline?.includes('OUT 마대') && + midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') && + midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') && + midFormationSwapPreviewProbe.panel.detail?.includes('+맥성 북문') && + midFormationSwapPreviewProbe.panel.detail?.includes('-산길과 말발굽') && + midFormationSwapPreviewProbe.panel.detail?.includes('실제 편성 미변경'), + `Expected the visible comparison panel to render the swap preview: ${JSON.stringify(midFormationSwapPreviewProbe.panel)}` + ); + assert( + JSON.stringify(midFormationSwapPreviewState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) && + JSON.stringify(midFormationSwapPreviewState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore), + `Expected hover swap preview to preserve runtime and saved selections: ${JSON.stringify(midFormationSwapPreviewState?.selectedSortieUnitIds)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation-hover.png`, fullPage: true }); await page.mouse.move(40, 710); - await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId === null, undefined, { timeout: 30000 }); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortieHoveredUnitId === null && state?.sortieComparisonPreview?.source === 'focus'; + }, undefined, { timeout: 30000 }); + const afterSwapPreviewProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const view = scene?.sortieComparisonPanelView; + return { + state: window.__HEROS_DEBUG__?.camp(), + panel: { + title: view?.title?.text ?? null, + headline: view?.headline?.text ?? null + } + }; + }); + const afterSwapPreviewState = afterSwapPreviewProbe.state; + assert( + JSON.stringify(afterSwapPreviewState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) && + JSON.stringify(afterSwapPreviewState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore), + `Expected pointerout to restore focus preview without mutating selections: ${JSON.stringify(afterSwapPreviewState?.selectedSortieUnitIds)}` + ); + assert( + afterSwapPreviewState?.sortieComparisonPreview?.source === 'focus' && + afterSwapPreviewState?.sortieComparisonPreview?.mode === 'remove' && + afterSwapPreviewState?.sortieComparisonPreview?.unitId === 'ma-dai' && + afterSwapPreviewState?.sortieComparisonPreview?.incomingUnitId === null && + afterSwapPreviewState?.sortieComparisonPreview?.outgoingUnitId === 'ma-dai' && + afterSwapPreviewProbe.panel.title === '편성 변화 · 마대' && + !afterSwapPreviewProbe.panel.headline?.includes('관우'), + `Expected pointerout to restore the visible Ma Dai focus preview: ${JSON.stringify(afterSwapPreviewProbe)}` + ); const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); await page.mouse.click(658, 196); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index d23d393..a46f4db 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -337,6 +337,46 @@ type SortieUnitSynergyPreview = { comparison?: SortieSynergyComparison; }; +type SortieComparisonMetric = { + key: 'attack' | 'intelligence' | 'move' | 'terrain'; + label: string; + current: number; + projected: number; + delta: number; +}; + +type SortieFormationComparisonPreview = { + source: 'focus' | 'hover-add' | 'hover-swap' | 'hover-blocked'; + mode: SortieUnitSynergyPreview['mode'] | 'swap'; + unitId: string; + incomingUnitId: string | null; + incomingUnitName: string | null; + outgoingUnitId: string | null; + outgoingUnitName: string | null; + selectedUnitIds: string[]; + projectedSelectedUnitIds: string[]; + headline: string; + detail: string; + metrics: SortieComparisonMetric[]; + current: SortieSynergySnapshot; + projected?: SortieSynergySnapshot; + comparison?: SortieSynergyComparison; +}; + +type SortieComparisonPanelView = { + background: Phaser.GameObjects.Rectangle; + title: Phaser.GameObjects.Text; + summary: Phaser.GameObjects.Text; + headline: Phaser.GameObjects.Text; + metrics: { + background: Phaser.GameObjects.Rectangle; + label: Phaser.GameObjects.Text; + value: Phaser.GameObjects.Text; + }[]; + impact: Phaser.GameObjects.Text; + detail: Phaser.GameObjects.Text; +}; + type CampaignTimelineChapter = { id: string; title: string; @@ -10784,6 +10824,7 @@ export class CampScene extends Phaser.Scene { private sortieItemAssignments: CampaignSortieItemAssignments = {}; private sortieFocusedUnitId = 'liu-bei'; private sortieHoveredUnitId?: string; + private sortieComparisonPanelView?: SortieComparisonPanelView; private sortieRosterScroll = 0; private sortiePlanFeedback = ''; private sortiePrepStep: SortiePrepStep = 'briefing'; @@ -10804,6 +10845,7 @@ export class CampScene extends Phaser.Scene { this.contentObjects = []; this.dialogueObjects = []; this.sortieObjects = []; + this.sortieComparisonPanelView = undefined; this.saveSlotObjects = []; this.saveSlotConfirmObjects = []; this.pendingSaveSlot = undefined; @@ -12762,6 +12804,7 @@ export class CampScene extends Phaser.Scene { ); card.on('pointerover', () => { this.sortieHoveredUnitId = unit.id; + this.refreshCampaignSortieComparisonPanel(); card.setFillStyle(!availability.available ? 0x15191e : selected ? 0x20362a : 0x1d2731, 0.98); card.setStrokeStyle(2, palette.gold, 0.98); accent.setFillStyle(palette.gold, 0.98); @@ -12774,6 +12817,7 @@ export class CampScene extends Phaser.Scene { if (this.sortieHoveredUnitId === unit.id) { this.sortieHoveredUnitId = undefined; } + this.refreshCampaignSortieComparisonPanel(); card.setFillStyle(fill, !availability.available ? 0.62 : 0.96); card.setStrokeStyle(restingStrokeWidth, availability.available ? accentColor : 0x53606c, restingStrokeAlpha); accent.setFillStyle(accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34); @@ -12822,46 +12866,170 @@ export class CampScene extends Phaser.Scene { } private renderCampaignSortieComparisonPanel(x: number, y: number, width: number, height: number, depth: number) { - const focused = this.sortieFocusedUnit(); - const synergy = this.sortieSynergySnapshot(); - const plan = this.sortiePlanSummary(); const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.96)); bg.setOrigin(0); bg.setDepth(depth); - bg.setStrokeStyle(1, synergy.coveredRoleCount === 3 ? palette.green : palette.gold, 0.58); - this.trackSortie(this.add.text(x + 18, y + 12, focused ? `편성 변화 · ${focused.name}` : '편성 변화', this.textStyle(16, '#f2e3bf', true))).setDepth(depth + 1); - this.trackSortie( - this.add.text( - x + width - 18, - y + 14, - `공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3 · 지형 ${synergy.terrainGrade}`, - this.textStyle(10, '#d8b15f', true) - ) - ).setOrigin(1, 0).setDepth(depth + 1); + const title = this.trackSortie(this.add.text(x + 18, y + 9, '편성 변화', this.textStyle(15, '#f2e3bf', true))); + title.setDepth(depth + 1); + const summary = this.trackSortie(this.add.text(x + width - 18, y + 11, '', this.textStyle(9, '#d8b15f', true))); + summary.setOrigin(1, 0); + summary.setDepth(depth + 1); + const headline = this.trackSortie(this.add.text(x + 18, y + 31, '', this.textStyle(11, '#c8d2dd', true))); + headline.setDepth(depth + 1); - if (!focused) { - this.trackSortie(this.add.text(x + 18, y + 44, '왼쪽 초상 카드를 선택하면 합류·해제 전후가 표시됩니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1); + const metricGap = 6; + const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4); + const metricLabels: SortieComparisonMetric['label'][] = ['공격', '지력', '기동', '지형']; + const metrics = metricLabels.map((label, index) => { + const metricX = x + 18 + index * (metricWidth + metricGap); + const background = this.trackSortie(this.add.rectangle(metricX, y + 51, metricWidth, 32, 0x111922, 0.9)); + background.setOrigin(0); + background.setDepth(depth + 1); + background.setStrokeStyle(1, 0x53606c, 0.34); + const labelText = this.trackSortie(this.add.text(metricX + 7, y + 55, label, this.textStyle(8, '#9fb0bf', true))); + labelText.setDepth(depth + 2); + const value = this.trackSortie(this.add.text(metricX + metricWidth - 7, y + 65, '-', this.textStyle(10, '#c8d2dd', true))); + value.setOrigin(1, 0); + value.setDepth(depth + 2); + return { background, label: labelText, value }; + }); + const impact = this.trackSortie(this.add.text(x + 18, y + 89, '', this.textStyle(10, '#d4dce6', true))); + impact.setDepth(depth + 1); + const detail = this.trackSortie(this.add.text(x + 18, y + 106, '', this.textStyle(9, '#9fb0bf'))); + detail.setDepth(depth + 1); + + this.sortieComparisonPanelView = { + background: bg, + title, + summary, + headline, + metrics, + impact, + detail + }; + this.refreshCampaignSortieComparisonPanel(); + } + + private refreshCampaignSortieComparisonPanel() { + const view = this.sortieComparisonPanelView; + if (!view) { return; } - const preview = this.sortieUnitSynergyPreview(focused); - const previewColor = preview.mode === 'remove' - ? '#ff9d7d' - : preview.mode === 'add' || preview.mode === 'current' - ? '#a8ffd0' + + const preview = this.sortieFormationComparisonPreview(); + const current = preview?.current ?? this.sortieSynergySnapshot(); + const projected = preview?.projected ?? current; + const showMetricTransition = Boolean(preview?.projected); + const comparison = preview?.comparison; + const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap'; + const lostRole = Boolean(comparison?.lostRoles.length); + const completeFormation = projected.coveredRoleCount === coreSortieSynergyRoles.length; + view.background.setStrokeStyle( + lostRole ? 2 : 1, + lostRole ? palette.red : isHoverPreview ? palette.gold : completeFormation ? palette.green : palette.gold, + lostRole ? 0.78 : isHoverPreview ? 0.72 : 0.58 + ); + + if (!preview) { + view.title.setText('편성 변화'); + view.summary.setText(`공명 ${current.activeBondCount} · 역할 ${current.coveredRoleCount}/3 · 지형 ${current.terrainGrade}`); + view.headline.setText('출전 무장을 클릭해 교체 기준으로 고정하십시오.').setColor('#9fb0bf'); + view.metrics.forEach((metric) => { + metric.background.setFillStyle(0x111922, 0.9).setStrokeStyle(1, 0x53606c, 0.34); + metric.value.setText('-').setColor('#77818c'); + }); + view.impact.setText('후보 초상에 마우스를 올리면 역할·공명 변화가 표시됩니다.').setColor('#d4dce6'); + view.detail.setText('미리보기는 실제 출전 명단과 보급을 변경하지 않습니다.').setColor('#9fb0bf'); + return; + } + + const title = preview.source === 'hover-swap' + ? `가상 교체 · ${preview.outgoingUnitName} → ${preview.incomingUnitName}` + : preview.source === 'hover-add' + ? `가상 합류 · ${preview.incomingUnitName}` + : `편성 변화 · ${this.unitName(preview.unitId)}`; + view.title.setText(this.compactText(title, 28)); + view.summary.setText( + this.compactText( + `공명 ${current.activeBondCount}→${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`, + 38 + ) + ); + const headlineColor = preview.source === 'hover-swap' || preview.mode === 'add' + ? '#a8ffd0' + : preview.mode === 'remove' + ? '#ff9d7d' : preview.mode === 'waiting' ? '#ffdf7b' - : '#87919c'; - this.trackSortie(this.add.text(x + 18, y + 39, this.compactText(preview.headline, 54), this.textStyle(12, previewColor, true))).setDepth(depth + 1); - this.trackSortie(this.add.text(x + 18, y + 62, this.compactText(preview.detail, 66), this.textStyle(10, '#c8d2dd'))).setDepth(depth + 1); - const feedback = this.sortiePlanFeedback || plan.warningLine; - this.trackSortie( - this.add.text( - x + 18, - y + height - 27, - this.compactText(feedback, 64), - this.textStyle(10, this.sortiePlanFeedback ? '#a8ffd0' : plan.warnings.length > 0 ? '#ffdf7b' : '#9fb0bf', true) + : preview.mode === 'blocked' + ? '#87919c' + : '#d4dce6'; + view.headline.setText(this.compactText(preview.headline, 64)).setColor(headlineColor); + + view.metrics.forEach((metricView, index) => { + const metric = preview.metrics[index]; + if (!metric) { + metricView.background.setFillStyle(0x111922, 0.9).setStrokeStyle(1, 0x53606c, 0.34); + metricView.value.setText('-').setColor('#77818c'); + return; + } + const positive = metric.delta > 0; + const negative = metric.delta < 0; + const tone = positive ? palette.green : negative ? palette.red : 0x53606c; + const fill = positive ? 0x172a22 : negative ? 0x2a1b18 : 0x111922; + const color = positive ? '#a8ffd0' : negative ? '#ff9d7d' : '#c8d2dd'; + const delta = metric.delta > 0 ? `+${metric.delta}` : `${metric.delta}`; + metricView.label.setText(metric.label); + metricView.background.setFillStyle(fill, 0.92).setStrokeStyle(1, tone, positive || negative ? 0.58 : 0.34); + metricView.value + .setText(showMetricTransition ? `${metric.current}→${metric.projected} ${delta}` : `${metric.current}`) + .setColor(color); + }); + + if (!comparison) { + view.impact + .setText(this.compactText(preview.detail, 68)) + .setColor(preview.mode === 'blocked' ? '#87919c' : preview.mode === 'waiting' ? '#ffdf7b' : '#d4dce6'); + view.detail + .setText('미리보기 · 실제 출전 명단·역할·보급은 변경되지 않습니다.') + .setColor('#9fb0bf'); + return; + } + + const gainedRoleLabels = comparison?.gainedRoles.map((role) => this.sortieFormationRoleLabel(role)) ?? []; + const lostRoleLabels = comparison?.lostRoles.map((role) => this.sortieFormationRoleLabel(role)) ?? []; + const roleChanges = [ + lostRoleLabels.length > 0 ? `공백 ${lostRoleLabels.join('·')}` : '', + gainedRoleLabels.length > 0 ? `보강 ${gainedRoleLabels.join('·')}` : '' + ].filter(Boolean); + const roleChange = roleChanges.join(' · ') || '역할 유지'; + const bondDelta = comparison?.activeBondDelta ?? 0; + const signedBondDelta = bondDelta > 0 ? `+${bondDelta}` : `${bondDelta}`; + view.impact + .setText( + this.compactText( + `역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 공명 ${current.activeBondCount}→${projected.activeBondCount} (${signedBondDelta}) · ${roleChange}`, + 68 + ) ) - ).setDepth(depth + 1); + .setColor(lostRoleLabels.length > 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || bondDelta > 0 ? '#a8ffd0' : '#d4dce6'); + + const gainedBonds = comparison?.gainedBonds.map((bond) => `+${bond.title}`) ?? []; + const lostBonds = comparison?.lostBonds.map((bond) => `-${bond.title}`) ?? []; + const bondLine = [gainedBonds[0], lostBonds[0], ...gainedBonds.slice(1), ...lostBonds.slice(1)] + .filter((bond): bond is string => Boolean(bond)) + .slice(0, 2) + .join(' · ') || '공명 변화 없음'; + const terrainDelta = projected.terrainScore - current.terrainScore; + const signedTerrainDelta = terrainDelta > 0 ? `+${terrainDelta}` : `${terrainDelta}`; + view.detail + .setText( + this.compactText( + `${bondLine} · 지형 ${current.terrainGrade} ${current.terrainScore}→${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · 실제 편성 미변경`, + 82 + ) + ) + .setColor(isHoverPreview ? '#ffdf7b' : '#9fb0bf'); } private renderFirstSortieRoleDiagram(x: number, y: number, width: number, height: number, depth: number) { @@ -13965,6 +14133,160 @@ export class CampScene extends Phaser.Scene { }); } + private sortieFormationComparisonPreview(): SortieFormationComparisonPreview | undefined { + const scenario = this.nextSortieScenario(); + const selectedUnitIds = [...this.selectedSortieUnitIds]; + const selectedIds = new Set(selectedUnitIds); + const current = this.sortieSynergySnapshot(selectedUnitIds, scenario); + const focused = this.sortieFocusedUnit(); + const hovered = this.sortieHoveredUnitId + ? this.sortieRosterUnits().find((unit) => unit.id === this.sortieHoveredUnitId) + : undefined; + + if (hovered && hovered.id !== focused?.id && !selectedIds.has(hovered.id)) { + const availability = this.sortieUnitAvailability(hovered, scenario); + if (!availability.available) { + return { + source: 'hover-blocked', + mode: 'blocked', + unitId: hovered.id, + incomingUnitId: hovered.id, + incomingUnitName: hovered.name, + outgoingUnitId: null, + outgoingUnitName: null, + selectedUnitIds, + projectedSelectedUnitIds: selectedUnitIds, + headline: `${hovered.name} 편성 불가 · ${availability.reason}`, + detail: '현재 출전 명단은 유지됩니다.', + metrics: this.sortieComparisonMetrics(hovered, hovered, scenario), + current + }; + } + + if (selectedUnitIds.length < this.sortieMaxUnits(scenario)) { + const projectedSelectedUnitIds = [...selectedUnitIds, hovered.id]; + const projected = this.sortieSynergySnapshot(projectedSelectedUnitIds, scenario); + const comparison = compareSortieSynergy(current, projected); + return { + source: 'hover-add', + mode: 'add', + unitId: hovered.id, + incomingUnitId: hovered.id, + incomingUnitName: hovered.name, + outgoingUnitId: null, + outgoingUnitName: null, + selectedUnitIds, + projectedSelectedUnitIds, + headline: `IN ${hovered.name} · ${this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario))} 합류`, + detail: `빈 출전 슬롯에 합류했을 때의 가상 결과입니다.`, + metrics: this.sortieComparisonMetrics(undefined, hovered, scenario), + current, + projected, + comparison + }; + } + + const outgoing = focused && selectedIds.has(focused.id) && !this.isRequiredSortieUnit(focused.id) + ? focused + : undefined; + if (!outgoing) { + return { + source: 'hover-blocked', + mode: 'waiting', + unitId: hovered.id, + incomingUnitId: hovered.id, + incomingUnitName: hovered.name, + outgoingUnitId: null, + outgoingUnitName: null, + selectedUnitIds, + projectedSelectedUnitIds: selectedUnitIds, + headline: `교체 기준 필요 · 출전 중인 비필수 무장을 먼저 클릭하세요.`, + detail: `${hovered.name} 후보는 아직 실제 편성에 반영되지 않았습니다.`, + metrics: this.sortieComparisonMetrics(hovered, hovered, scenario), + current + }; + } + + const projectedSelectedUnitIds = selectedUnitIds.map((unitId) => unitId === outgoing.id ? hovered.id : unitId); + const projected = this.sortieSynergySnapshot(projectedSelectedUnitIds, scenario); + const comparison = compareSortieSynergy(current, projected); + const outgoingRole = this.sortieFormationRoleLabel(this.sortieFormationRole(outgoing, scenario)); + const incomingRole = this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario)); + return { + source: 'hover-swap', + mode: 'swap', + unitId: hovered.id, + incomingUnitId: hovered.id, + incomingUnitName: hovered.name, + outgoingUnitId: outgoing.id, + outgoingUnitName: outgoing.name, + selectedUnitIds, + projectedSelectedUnitIds, + headline: `OUT ${outgoing.name} ${outgoingRole} · IN ${hovered.name} ${incomingRole}`, + detail: `${outgoing.name} 대신 ${hovered.name}을 투입했을 때의 가상 결과입니다.`, + metrics: this.sortieComparisonMetrics(outgoing, hovered, scenario), + current, + projected, + comparison + }; + } + + if (!focused) { + return undefined; + } + + const focusedPreview = this.sortieUnitSynergyPreview(focused); + const focusedSelected = selectedIds.has(focused.id); + const projected = focusedPreview.projected; + return { + source: 'focus', + mode: focusedPreview.mode, + unitId: focused.id, + incomingUnitId: focusedSelected ? null : focused.id, + incomingUnitName: focusedSelected ? null : focused.name, + outgoingUnitId: focusedSelected && !this.isRequiredSortieUnit(focused.id) ? focused.id : null, + outgoingUnitName: focusedSelected && !this.isRequiredSortieUnit(focused.id) ? focused.name : null, + selectedUnitIds, + projectedSelectedUnitIds: projected?.selectedUnitIds ?? selectedUnitIds, + headline: focusedPreview.headline, + detail: focusedPreview.detail, + metrics: focusedPreview.mode === 'add' + ? this.sortieComparisonMetrics(undefined, focused, scenario) + : focusedPreview.mode === 'remove' + ? this.sortieComparisonMetrics(focused, undefined, scenario) + : this.sortieComparisonMetrics(focused, focused, scenario), + current, + projected, + comparison: focusedPreview.comparison + }; + } + + private sortieComparisonMetrics( + currentUnit: UnitData | undefined, + projectedUnit: UnitData | undefined, + scenario = this.nextSortieScenario() + ): SortieComparisonMetric[] { + const currentAttack = currentUnit ? currentUnit.attack + this.equipmentAttackBonus(currentUnit) : 0; + const projectedAttack = projectedUnit ? projectedUnit.attack + this.equipmentAttackBonus(projectedUnit) : 0; + const values: Omit[] = [ + { key: 'attack', label: '공격', current: currentAttack, projected: projectedAttack }, + { + key: 'intelligence', + label: '지력', + current: currentUnit?.stats.intelligence ?? 0, + projected: projectedUnit?.stats.intelligence ?? 0 + }, + { key: 'move', label: '기동', current: currentUnit?.move ?? 0, projected: projectedUnit?.move ?? 0 }, + { + key: 'terrain', + label: '지형', + current: currentUnit ? this.sortieTerrainScore(currentUnit, scenario) : 0, + projected: projectedUnit ? this.sortieTerrainScore(projectedUnit, scenario) : 0 + } + ]; + return values.map((metric) => ({ ...metric, delta: metric.projected - metric.current })); + } + private sortieUnitSynergyPreview(unit: UnitData): SortieUnitSynergyPreview { const scenario = this.nextSortieScenario(); const current = this.sortieSynergySnapshot(this.selectedSortieUnitIds, scenario); @@ -15075,6 +15397,7 @@ export class CampScene extends Phaser.Scene { this.sortieObjects.forEach((object) => object.destroy()); this.sortieObjects = []; this.sortieHoveredUnitId = undefined; + this.sortieComparisonPanelView = undefined; } private trackSortie(object: T) { @@ -16894,6 +17217,7 @@ export class CampScene extends Phaser.Scene { sortieDeploymentPreview: this.sortieDeploymentPreviewDebug(), sortieFocusedUnitId: this.sortieFocusedUnitId, sortieHoveredUnitId: this.sortieHoveredUnitId ?? null, + sortieComparisonPreview: this.sortieFormationComparisonPreview() ?? null, sortieFocusedUnit: this.sortieFocusedUnitSummary(), sortiePlanFeedback: this.sortiePlanFeedback, sortieChecklist,