diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index d9d4a43..9d5b9c5 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -73,12 +73,22 @@ try { assert(firstBattleProbe.battleId === 'first-battle-zhuo-commandery', `Expected first battle: ${JSON.stringify(firstBattleProbe)}`); assert(firstBattleProbe.objectiveText?.startsWith('승리 목표:'), `Expected clear victory objective label: ${JSON.stringify(firstBattleProbe)}`); assert(firstBattleProbe.objectiveSubText?.includes('패배 조건:'), `Expected clear defeat condition label: ${JSON.stringify(firstBattleProbe)}`); - assert(firstBattleProbe.objectiveSubText?.includes('진군:'), `Expected tactical route in objective tracker: ${JSON.stringify(firstBattleProbe)}`); + assert( + firstBattleProbe.objectiveSubText?.startsWith('패배 조건:') && + !firstBattleProbe.objectiveSubText.includes('진군:') && + !firstBattleProbe.objectiveSubText.includes('보조:'), + `Expected the compact objective subtext to retain only the defeat condition without duplicate route or bonus guidance: ${JSON.stringify(firstBattleProbe)}` + ); assert( firstBattleProbe.sideTexts.some((text) => text.includes('출진 군세')) && !firstBattleProbe.sideTexts.some((text) => text.includes('...')), `Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` ); + await assertLiveSortieOrderHud(page, { + battleId: 'first-battle-zhuo-commandery', + orderId: 'elite', + context: 'first battle' + }); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await waitForBattleOutcome(page, 'victory'); @@ -860,6 +870,11 @@ try { secondBattleProbe?.sortieOperationOrder?.selectedId === 'elite' && secondBattleProbe.sortieOperationOrder.result === null, `Expected the explicitly declared elite order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}` ); + await assertLiveSortieOrderHud(page, { + battleId: 'second-battle-yellow-turban-pursuit', + orderId: 'elite', + context: 'second battle' + }); assertSameMembers( secondBattleProbe?.selectedSortieUnitIds, firstCampSelectedSortieUnitIds, @@ -2363,6 +2378,108 @@ async function startDeploymentIfNeeded(page, battleId) { }, battleId, { timeout: 30000 }); } +async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) { + const probe = await page.evaluate(({ expectedBattleId, expectedOrderId }) => { + const state = window.__HEROS_DEBUG__?.battle(); + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const operationOrder = state?.sortieOperationOrder; + const rawHud = operationOrder?.live ?? operationOrder?.hud; + const criteria = rawHud?.criteria ?? rawHud?.progress ?? []; + return { + battleId: state?.battleId ?? null, + phase: state?.phase ?? null, + battleOutcome: state?.battleOutcome ?? null, + resultVisible: state?.resultVisible ?? false, + selectedId: operationOrder?.selectedId ?? null, + result: operationOrder?.result ?? null, + sceneBounds: scene + ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } + : null, + panelBounds: scene?.layout + ? { + x: scene.layout.panelX, + y: scene.layout.panelY, + width: scene.layout.panelWidth, + height: scene.layout.panelHeight + } + : null, + hud: rawHud + ? { + visible: rawHud.visible, + bounds: rawHud.bounds ?? null, + orderId: rawHud.orderId ?? null, + label: rawHud.label ?? '', + victoryLabel: rawHud.victoryLabel ?? '', + victoryTone: rawHud.victoryTone ?? rawHud.victoryStatus ?? '', + status: rawHud.status ?? '', + tone: rawHud.tone ?? rawHud.status ?? '', + projectedScore: rawHud.projectedScore, + criteria: criteria.map((entry) => ({ + id: entry?.id ?? null, + label: entry?.label ?? '', + value: entry?.value, + tone: entry?.tone ?? entry?.status ?? '', + achieved: entry?.achieved, + bounds: entry?.bounds ?? null + })), + stampedIds: Array.isArray(rawHud.stampedIds) ? [...rawHud.stampedIds] : rawHud.stampedIds, + animationCount: rawHud.animationCount + } + : null, + expectedBattleId, + expectedOrderId + }; + }, { expectedBattleId: battleId, expectedOrderId: orderId }); + + const hud = probe.hud; + const requiredCriteriaIds = ['elite-grade', 'elite-survival']; + const criterionIds = hud?.criteria?.map((entry) => entry.id) ?? []; + assert( + probe.battleId === battleId && + probe.phase !== 'resolved' && + probe.battleOutcome === null && + probe.resultVisible === false && + probe.selectedId === orderId && + probe.result === null, + `Expected ${context} to expose the selected ${orderId} order before its result: ${JSON.stringify(probe)}` + ); + assert( + hud?.visible === true && + hud.orderId === orderId && + typeof hud.label === 'string' && hud.label.includes('정예') && + typeof hud.victoryLabel === 'string' && hud.victoryLabel.length > 0 && + typeof hud.victoryTone === 'string' && hud.victoryTone.length > 0 && + typeof hud.status === 'string' && hud.status.length > 0 && + typeof hud.tone === 'string' && hud.tone.length > 0 && + Number.isFinite(hud.projectedScore), + `Expected ${context} to show a fully labelled live ${orderId} HUD with a finite projected score: ${JSON.stringify(probe)}` + ); + assert( + isFiniteBounds(probe.sceneBounds) && + isFiniteBounds(probe.panelBounds) && + isFiniteBounds(hud.bounds) && + boundsInside(hud.bounds, probe.sceneBounds) && + boundsInside(hud.bounds, probe.panelBounds) && + requiredCriteriaIds.every((id) => criterionIds.includes(id)) && + hud.criteria.length >= requiredCriteriaIds.length && + hud.criteria.every((entry) => ( + typeof entry.id === 'string' && entry.id.length > 0 && + typeof entry.label === 'string' && entry.label.length > 0 && + Object.prototype.hasOwnProperty.call(entry, 'value') && + typeof entry.tone === 'string' && entry.tone.length > 0 && + typeof entry.achieved === 'boolean' && + isFiniteBounds(entry.bounds) && + boundsInside(entry.bounds, hud.bounds) + )), + `Expected ${context} live order criteria and bounds to remain inside the visible HUD panel: ${JSON.stringify(probe)}` + ); + assert( + Array.isArray(hud.stampedIds) && hud.stampedIds.length === 0 && hud.animationCount === 0, + `Expected ${context} live order HUD to start without stamped criteria or animations: ${JSON.stringify(probe)}` + ); + return probe; +} + async function waitForBattleOutcome(page, outcome) { await page.waitForFunction((expectedOutcome) => { const state = window.__HEROS_DEBUG__?.battle(); @@ -3018,6 +3135,26 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function isFiniteBounds(bounds) { + return Boolean( + bounds && + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) && + bounds.width > 0 && + bounds.height > 0 + ); +} + +function boundsInside(inner, outer, tolerance = 1) { + return isFiniteBounds(inner) && isFiniteBounds(outer) && + inner.x >= outer.x - tolerance && + inner.y >= outer.y - tolerance && + inner.x + inner.width <= outer.x + outer.width + tolerance && + inner.y + inner.height <= outer.y + outer.height + tolerance; +} + function assert(condition, message) { if (!condition) { throw new Error(message); diff --git a/scripts/verify-sortie-synergy.mjs b/scripts/verify-sortie-synergy.mjs index 022c28d..1f34e32 100644 --- a/scripts/verify-sortie-synergy.mjs +++ b/scripts/verify-sortie-synergy.mjs @@ -17,6 +17,7 @@ try { } = await server.ssrLoadModule('/src/game/data/sortieSynergy.ts'); const { evaluateSortieOrder, + evaluateSortieOrderProgress, sortieOrderDefinitions, sortieOrderRewardClaimId, summarizeSortieOrderHistory @@ -229,6 +230,65 @@ try { `Expected siege order to prefer terrain objectives and require front/support contributions: ${JSON.stringify({ siegeBoundary, siegeBonusFallback, siegeTerrainPrecedence, siegeWithoutFront, siegeWithoutSupport })}` ); + const siegeExtendedSupportInput = orderInput({ + performance: { + ...orderPerformance, + unitStats: orderPerformance.unitStats.map((stats) => stats.unitId === 'support-unit' + ? { + ...stats, + sortieBonusDamage: 0, + sortieBonusHealing: 0, + sortieExtendedBondTriggers: 1 + } + : { ...stats }) + } + }); + const progressBoundaryCases = [ + ['elite exact grade', 'elite', orderInput()], + ['elite below grade', 'elite', orderInput({ score: 74 })], + ['elite empty deployment', 'elite', orderInput({ survivorCount: 0, deployedCount: 0 })], + ['mobile exact turn', 'mobile', orderInput()], + ['mobile late turn', 'mobile', orderInput({ turnNumber: 13 })], + ['mobile invalid zero limit', 'mobile', orderInput({ turnNumber: 0, turnLimit: 0 })], + ['mobile intent only', 'mobile', orderInput({ performance: intentOnlyPerformance })], + ['siege terrain boundary', 'siege', orderInput()], + ['siege terrain precedence', 'siege', orderInput({ + terrainObjectiveTotal: 1, + terrainObjectiveAchieved: 0, + bonusObjectiveAchieved: 2 + })], + ['siege bonus fallback', 'siege', orderInput({ + terrainObjectiveTotal: 0, + terrainObjectiveAchieved: 0, + bonusObjectiveAchieved: 1 + })], + ['siege extended support only', 'siege', siegeExtendedSupportInput] + ]; + progressBoundaryCases.forEach(([label, orderId, input]) => { + const progressEvaluation = evaluateSortieOrderProgress(orderId, input); + const finalEvaluation = evaluateSortieOrder(orderId, input); + const finalProgressEvaluation = { + orderId: finalEvaluation.orderId, + score: finalEvaluation.score, + achieved: finalEvaluation.achieved, + progress: finalEvaluation.progress + }; + assert( + JSON.stringify(progressEvaluation) === JSON.stringify(finalProgressEvaluation), + `Expected public progress helper and final evaluator parity at ${label}: ${JSON.stringify({ progressEvaluation, finalProgressEvaluation })}` + ); + }); + + const emptyEliteProgress = evaluateSortieOrderProgress('elite', orderInput({ survivorCount: 0, deployedCount: 0 })); + const zeroLimitMobileProgress = evaluateSortieOrderProgress('mobile', orderInput({ turnNumber: 0, turnLimit: 0 })); + const extendedSupportSiegeProgress = evaluateSortieOrderProgress('siege', siegeExtendedSupportInput); + assert( + emptyEliteProgress.progress.find((entry) => entry.id === 'elite-survival')?.achieved === false && + zeroLimitMobileProgress.progress.find((entry) => entry.id === 'mobile-quick')?.achieved === false && + extendedSupportSiegeProgress.progress.find((entry) => entry.id === 'siege-support')?.achieved === true, + `Expected progress helper to retain empty-deployment, zero-limit, and extended-support boundaries: ${JSON.stringify({ emptyEliteProgress, zeroLimitMobileProgress, extendedSupportSiegeProgress })}` + ); + const rewardByOrder = Object.fromEntries(sortieOrderDefinitions.map((definition) => [definition.id, definition])); assert( rewardByOrder.elite?.rewardGold === 180 && JSON.stringify(rewardByOrder.elite.rewardItems) === JSON.stringify(['상처약 1']) && diff --git a/src/game/data/sortieOrders.ts b/src/game/data/sortieOrders.ts index 3565908..1b4c92d 100644 --- a/src/game/data/sortieOrders.ts +++ b/src/game/data/sortieOrders.ts @@ -33,6 +33,11 @@ export type CampaignSortieOrderResultSnapshot = { }; export type SortieOrderResultSnapshot = CampaignSortieOrderResultSnapshot; +export type SortieOrderProgressEvaluationSnapshot = Pick< + CampaignSortieOrderResultSnapshot, + 'orderId' | 'score' | 'achieved' | 'progress' +>; + export type CampaignSortieOrderSelection = { battleId: string; orderId: CampaignSortieOrderId; @@ -133,6 +138,19 @@ export function evaluateSortieOrder( orderId: CampaignSortieOrderId, rawInput: SortieOrderEvaluationInput ): CampaignSortieOrderResultSnapshot { + const evaluation = evaluateSortieOrderProgress(orderId, rawInput); + + return { + version: 1, + ...evaluation, + rewardGranted: false + }; +} + +export function evaluateSortieOrderProgress( + orderId: CampaignSortieOrderId, + rawInput: SortieOrderEvaluationInput +): SortieOrderProgressEvaluationSnapshot { const input = normalizeEvaluationInput(rawInput); const victory = progress('victory', input.victory ? 1 : 0, 1, input.victory); let orderProgress: CampaignSortieOrderProgressSnapshot[]; @@ -183,12 +201,10 @@ export function evaluateSortieOrder( } return { - version: 1, orderId, score: input.score, achieved: orderProgress.every((entry) => entry.achieved), - progress: orderProgress, - rewardGranted: false + progress: orderProgress }; } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 32f4476..cc6914a 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -56,7 +56,17 @@ import { sortiePerformanceRoleContributionLine, type SortiePerformanceReview } from '../data/sortiePerformanceReview'; -import { evaluateSortieOrder, sortieOrderDefinition, sortieOrderRewardClaimId, type SortieOrderId } from '../data/sortieOrders'; +import { + evaluateSortieOrder, + evaluateSortieOrderProgress, + sortieOrderDefinition, + sortieOrderRewardClaimId, + type CampaignSortieOrderProgressId, + type CampaignSortieOrderProgressSnapshot, + type SortieOrderEvaluationInput, + type SortieOrderId, + type SortieOrderProgressEvaluationSnapshot +} from '../data/sortieOrders'; import { campaignSortiePresetIds, campaignSaveSlotCount, @@ -1296,6 +1306,35 @@ type ResultSortieOrderView = { closeButton: Phaser.GameObjects.Rectangle; }; +type SortieOrderHudStatus = 'pending' | 'on-track' | 'warning' | 'failed' | 'complete'; + +type SortieOrderHudBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +type SortieOrderHudCriterion = { + id: CampaignSortieOrderProgressId; + label: string; + value: string; + status: SortieOrderHudStatus; + achieved: boolean; + current: number; + target: number; +}; + +type SortieOrderHudSnapshot = { + orderId: SortieOrderId; + label: string; + status: SortieOrderHudStatus; + victoryLabel: string; + victoryStatus: SortieOrderHudStatus; + projectedScore: number; + criteria: SortieOrderHudCriterion[]; +}; + type ResultButtonView = { background: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text; @@ -3217,6 +3256,16 @@ export class BattleScene extends Phaser.Scene { private battleSpeedControlObjects: Phaser.GameObjects.GameObject[] = []; private tacticalInitiativeChipObjects: Phaser.GameObjects.GameObject[] = []; private tacticalInitiativePanelObjects: Phaser.GameObjects.GameObject[] = []; + private sortieOrderHudObjects: Phaser.GameObjects.GameObject[] = []; + private sortieOrderHudStampObjects: Phaser.GameObjects.GameObject[] = []; + private sortieOrderHudBounds?: SortieOrderHudBounds; + private sortieOrderHudCriterionBounds: Partial> = {}; + private sortieOrderHudSnapshot?: SortieOrderHudSnapshot; + private sortieOrderHudPreviousAchieved = new Map(); + private sortieOrderHudStampedIds = new Set(); + private sortieOrderHudLastStampedId?: CampaignSortieOrderProgressId; + private sortieOrderHudAnimationCount = 0; + private sortieOrderHudSuppressNextAnimation = true; private alertObjects: Phaser.GameObjects.GameObject[] = []; private alertDismissEvent?: Phaser.Time.TimerEvent; private battleEventObjects: Phaser.GameObjects.GameObject[] = []; @@ -3308,6 +3357,7 @@ export class BattleScene extends Phaser.Scene { this.resultFormationReviewFeedback = ''; this.resultPresetOverwriteConfirmId = undefined; this.resultSortieOrder = undefined; + this.resetSortieOrderHudState(); const campaign = getCampaignState(); this.resetBattleData(campaign); if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') { @@ -4421,6 +4471,216 @@ export class BattleScene extends Phaser.Scene { this.battleSpeedControlObjects.push(button, text); } + private resetSortieOrderHudState() { + this.clearSortieOrderHud(true); + this.sortieOrderHudSnapshot = undefined; + this.sortieOrderHudPreviousAchieved.clear(); + this.sortieOrderHudStampedIds.clear(); + this.sortieOrderHudLastStampedId = undefined; + this.sortieOrderHudAnimationCount = 0; + this.sortieOrderHudSuppressNextAnimation = true; + } + + private clearSortieOrderHud(clearStamps = false) { + this.sortieOrderHudObjects.forEach((object) => object.destroy()); + this.sortieOrderHudObjects = []; + this.sortieOrderHudBounds = undefined; + this.sortieOrderHudCriterionBounds = {}; + if (!clearStamps) { + return; + } + this.tweens.killTweensOf(this.sortieOrderHudStampObjects); + this.sortieOrderHudStampObjects.forEach((object) => object.destroy()); + this.sortieOrderHudStampObjects = []; + } + + private trackSortieOrderHudObject(object: T) { + this.sortieOrderHudObjects.push(object); + return object; + } + + private renderSortieOrderHud() { + const evaluation = this.liveSortieOrderEvaluation(); + if (!evaluation || this.resultObjects.length > 0) { + this.clearSortieOrderHud(true); + this.sortieOrderHudSnapshot = undefined; + return; + } + + const snapshot = this.sortieOrderHudSnapshotFor(evaluation); + const suppressAnimation = this.sortieOrderHudSuppressNextAnimation || this.sortieOrderHudPreviousAchieved.size === 0; + const newlyAchieved = snapshot.criteria.filter((criterion) => ( + !suppressAnimation && + criterion.achieved && + this.sortieOrderHudPreviousAchieved.get(criterion.id) === false && + !this.sortieOrderHudStampedIds.has(criterion.id) + )); + snapshot.criteria.forEach((criterion) => this.sortieOrderHudPreviousAchieved.set(criterion.id, criterion.achieved)); + this.sortieOrderHudSuppressNextAnimation = false; + + this.clearSortieOrderHud(); + this.sortieOrderHudSnapshot = snapshot; + const { panelX, panelWidth } = this.layout; + const left = panelX + 24; + const width = panelWidth - 48; + const height = 36; + const trackerBottom = Math.max( + this.objectiveTrackerText?.active ? this.objectiveTrackerText.y + this.objectiveTrackerText.height : 0, + this.objectiveTrackerSubText?.active ? this.objectiveTrackerSubText.y + this.objectiveTrackerSubText.height : 0 + ); + const top = Math.ceil(Math.max(this.layout.panelY + 140, trackerBottom) + 6); + const depth = 15; + const outer = this.trackSortieOrderHudObject(this.add.rectangle( + left, + top, + width, + height, + this.sortieOrderHudFill(snapshot.status), + 0.98 + )); + outer.setOrigin(0); + outer.setDepth(depth); + outer.setStrokeStyle(1, this.sortieOrderHudTone(snapshot.status), snapshot.status === 'warning' || snapshot.status === 'failed' ? 0.96 : 0.74); + + const headerWidth = 68; + const header = this.trackSortieOrderHudObject(this.add.rectangle(left, top, headerWidth, height, 0x241f18, 0.98)); + header.setOrigin(0); + header.setDepth(depth + 1); + header.setStrokeStyle(1, this.sortieOrderHudTone(snapshot.victoryStatus), 0.7); + this.trackSortieOrderHudObject(this.add.text(left + 8, top + 4, `${snapshot.label} 군령`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#f2e3bf', + fontStyle: '700' + })).setDepth(depth + 2); + this.trackSortieOrderHudObject(this.add.text(left + 8, top + 20, snapshot.victoryLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '9px', + color: this.sortieOrderHudTextColor(snapshot.victoryStatus), + fontStyle: '700' + })).setDepth(depth + 2); + + const criterionWidth = (width - headerWidth) / Math.max(1, snapshot.criteria.length); + snapshot.criteria.forEach((criterion, index) => { + const x = left + headerWidth + index * criterionWidth; + const bounds = { x, y: top, width: criterionWidth, height }; + this.sortieOrderHudCriterionBounds[criterion.id] = bounds; + if (index > 0) { + this.trackSortieOrderHudObject(this.add.rectangle(x, top + 5, 1, height - 10, 0x647485, 0.42)) + .setOrigin(0) + .setDepth(depth + 1); + } + const dot = this.trackSortieOrderHudObject(this.add.circle(x + 8, top + 26, 3, this.sortieOrderHudTone(criterion.status), 0.96)); + dot.setDepth(depth + 2); + this.trackSortieOrderHudObject(this.add.text(x + criterionWidth / 2, top + 3, criterion.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: snapshot.criteria.length >= 3 ? '9px' : '10px', + color: '#aeb7c2', + fontStyle: '700' + })).setOrigin(0.5, 0).setDepth(depth + 2); + this.trackSortieOrderHudObject(this.add.text(x + criterionWidth / 2, top + 19, criterion.value, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: snapshot.criteria.length >= 3 ? '10px' : '11px', + color: this.sortieOrderHudTextColor(criterion.status), + fontStyle: '700' + })).setOrigin(0.5, 0).setDepth(depth + 2); + }); + this.sortieOrderHudBounds = { x: left, y: top, width, height }; + + newlyAchieved.forEach((criterion) => { + const bounds = this.sortieOrderHudCriterionBounds[criterion.id]; + if (bounds) { + this.showSortieOrderHudStamp(criterion.id, bounds); + } + }); + } + + private sortieOrderHudFill(status: SortieOrderHudStatus) { + if (status === 'failed') { + return 0x2d1b19; + } + if (status === 'warning') { + return 0x2d2718; + } + if (status === 'complete') { + return 0x14281f; + } + return 0x101820; + } + + private sortieOrderHudTone(status: SortieOrderHudStatus) { + if (status === 'failed') { + return 0xb86b55; + } + if (status === 'warning') { + return palette.gold; + } + if (status === 'complete') { + return 0x59d18c; + } + if (status === 'on-track') { + return 0x72a7d8; + } + return 0x647485; + } + + private sortieOrderHudTextColor(status: SortieOrderHudStatus) { + if (status === 'failed') { + return '#ffb6a6'; + } + if (status === 'warning') { + return '#ffdf7b'; + } + if (status === 'complete') { + return '#a8ffd0'; + } + if (status === 'on-track') { + return '#b9d7ff'; + } + return '#9fb0bf'; + } + + private showSortieOrderHudStamp(id: CampaignSortieOrderProgressId, bounds: SortieOrderHudBounds) { + this.sortieOrderHudStampedIds.add(id); + this.sortieOrderHudLastStampedId = id; + this.sortieOrderHudAnimationCount += 1; + const x = bounds.x + bounds.width / 2; + const y = bounds.y + bounds.height / 2; + const depth = 38; + const seal = this.add.circle(x, y, 15, 0x2b100c, 0.96); + seal.setDepth(depth); + seal.setStrokeStyle(2, 0xd46a54, 0.98); + const label = this.add.text(x, y - 1, '달성', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: '#ffd2c6', + fontStyle: '700', + stroke: '#4b140d', + strokeThickness: 2 + }); + label.setOrigin(0.5); + label.setDepth(depth + 1); + seal.setAngle(-8); + label.setAngle(-8); + seal.setScale(1.5).setAlpha(0); + label.setScale(1.5).setAlpha(0); + this.sortieOrderHudStampObjects.push(seal, label); + this.tweens.add({ + targets: [seal, label], + alpha: 1, + scale: 1, + duration: 220, + ease: 'Back.Out', + hold: 460, + yoyo: true, + onComplete: () => { + seal.destroy(); + label.destroy(); + this.sortieOrderHudStampObjects = this.sortieOrderHudStampObjects.filter((object) => object !== seal && object !== label); + } + }); + } + private renderTacticalInitiativeChip() { this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); this.tacticalInitiativeChipObjects = []; @@ -10504,6 +10764,8 @@ export class BattleScene extends Phaser.Scene { private hideBattleResult() { this.resultAnimationToken += 1; + this.clearSortieOrderHud(true); + this.sortieOrderHudSnapshot = undefined; this.hideResultSortieOrder(); this.resultFormationReviewObjects.forEach((object) => object.destroy()); this.resultFormationReviewObjects = []; @@ -10838,11 +11100,23 @@ export class BattleScene extends Phaser.Scene { performance: CampaignSortiePerformanceSnapshot, review: SortiePerformanceReview ) { + return evaluateSortieOrder( + this.launchSortieOrderId, + this.sortieOrderEvaluationInput(outcome === 'victory', objectives, performance, review) + ); + } + + private sortieOrderEvaluationInput( + victory: boolean, + objectives: BattleObjectiveResult[], + performance: CampaignSortiePerformanceSnapshot, + review: SortiePerformanceReview + ): SortieOrderEvaluationInput { const objectiveById = new Map(objectives.map((objective) => [objective.id, objective] as const)); const terrainObjectives = battleScenario.objectives.filter((objective) => objective.kind === 'secure-terrain'); const bonusObjectives = objectives.filter((objective) => objective.category === 'bonus'); - return evaluateSortieOrder(this.launchSortieOrderId, { - victory: outcome === 'victory', + return { + victory, score: review.score, turnNumber: this.turnNumber, turnLimit: battleScenario.quickVictoryTurnLimit, @@ -10853,7 +11127,101 @@ export class BattleScene extends Phaser.Scene { terrainObjectiveTotal: terrainObjectives.length, terrainObjectiveAchieved: terrainObjectives.filter((objective) => objectiveById.get(objective.id)?.achieved).length, performance - }); + }; + } + + private liveSortieOrderEvaluation(): SortieOrderProgressEvaluationSnapshot | undefined { + const performance = this.resultSortiePerformanceSnapshot(); + if (!performance) { + return undefined; + } + const projectedOutcome: BattleOutcome = this.battleOutcome ?? 'victory'; + const objectives = this.resultObjectives(projectedOutcome); + const review = this.resultFormationReview(projectedOutcome, performance); + return evaluateSortieOrderProgress( + this.launchSortieOrderId, + this.sortieOrderEvaluationInput(this.battleOutcome === 'victory', objectives, performance, review) + ); + } + + private sortieOrderHudSnapshotFor(evaluation: SortieOrderProgressEvaluationSnapshot): SortieOrderHudSnapshot { + const criteria = evaluation.progress + .filter((progress) => progress.id !== 'victory') + .map((progress) => this.sortieOrderHudCriterion(progress)); + const victoryStatus: SortieOrderHudStatus = this.battleOutcome === 'victory' + ? 'complete' + : this.battleOutcome === 'defeat' + ? 'failed' + : 'pending'; + const victoryLabel = this.battleOutcome === 'victory' + ? '승리 완료' + : this.battleOutcome === 'defeat' + ? '패배' + : '승리 대기'; + const status: SortieOrderHudStatus = victoryStatus === 'failed' || criteria.some((criterion) => criterion.status === 'failed') + ? 'failed' + : criteria.some((criterion) => criterion.status === 'warning') + ? 'warning' + : victoryStatus === 'complete' && criteria.every((criterion) => criterion.achieved) + ? 'complete' + : criteria.every((criterion) => criterion.achieved) + ? 'on-track' + : 'pending'; + return { + orderId: evaluation.orderId, + label: this.sortieOrderLabel(evaluation.orderId), + status, + victoryLabel, + victoryStatus, + projectedScore: evaluation.score, + criteria + }; + } + + private sortieOrderHudCriterion(progress: CampaignSortieOrderProgressSnapshot): SortieOrderHudCriterion { + let label = this.sortieOrderProgressLabel(progress.id); + let value = progress.achieved ? '충족' : `${progress.value}/${progress.target}`; + let status: SortieOrderHudStatus = progress.achieved ? 'complete' : 'pending'; + + if (progress.id === 'elite-grade') { + label = '예상 평가'; + value = `${progress.value}/${progress.target}점`; + status = progress.achieved ? 'on-track' : 'warning'; + } else if (progress.id === 'elite-survival') { + label = '생존'; + value = progress.achieved ? `${progress.value}/${progress.target}명` : `퇴각 ${Math.max(1, progress.target - progress.value)}명`; + status = progress.achieved ? 'on-track' : 'failed'; + } else if (progress.id === 'mobile-quick') { + label = '남은 턴'; + const remainingTurns = Math.max(0, progress.target - progress.value + 1); + value = progress.achieved ? `${remainingTurns}턴` : `제한 초과`; + status = !progress.achieved ? 'failed' : remainingTurns <= 2 ? 'warning' : 'on-track'; + } else if (progress.id === 'mobile-breakthrough') { + label = '돌파·파훼'; + value = progress.achieved ? '확보' : '미확보'; + } else if (progress.id === 'siege-objective') { + label = '추가 목표'; + value = progress.achieved ? '확보' : '미확보'; + } else if (progress.id === 'siege-front') { + label = '전열 기여'; + value = progress.achieved ? '확보' : '미확보'; + } else if (progress.id === 'siege-support') { + label = '후원 기여'; + value = progress.achieved ? '확보' : '미확보'; + } + if (this.battleOutcome) { + status = progress.achieved ? 'complete' : 'failed'; + } + + return { + id: progress.id, + label, + value, + status, + achieved: progress.achieved, + current: progress.value, + target: progress.target + }; } private resultFormationSnapshotMatchesPreset(presetId: CampaignSortiePresetId, snapshot: CampaignSortiePerformanceSnapshot) { @@ -14223,6 +14591,7 @@ export class BattleScene extends Phaser.Scene { this.hideTacticalInitiativePanel(); this.hideCombatCutIn(); this.hideBattleResult(); + this.resetSortieOrderHudState(); this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; @@ -19334,7 +19703,10 @@ export class BattleScene extends Phaser.Scene { const objectiveSubBottom = this.objectiveTrackerSubText?.visible ? this.objectiveTrackerSubText.y + this.objectiveTrackerSubText.height : fallbackBottom; - return Math.max(fallbackBottom, objectiveBottom, objectiveSubBottom); + const sortieOrderBottom = this.sortieOrderHudBounds + ? this.sortieOrderHudBounds.y + this.sortieOrderHudBounds.height + : fallbackBottom; + return Math.max(fallbackBottom, objectiveBottom, objectiveSubBottom, sortieOrderBottom); } private sideContentTop() { @@ -19596,7 +19968,12 @@ export class BattleScene extends Phaser.Scene { const routeText = battleScenario.tacticalGuide?.route; this.objectiveTrackerText.setText(victoryText); - this.objectiveTrackerSubText.setText(routeText ? `진군: ${routeText}\n${defeatText}${bonusText}` : `${defeatText}${bonusText}`); + this.objectiveTrackerSubText.setText( + battleScenario.sortie + ? defeatText + : routeText ? `진군: ${routeText}\n${defeatText}${bonusText}` : `${defeatText}${bonusText}` + ); + this.renderSortieOrderHud(); } private renderObjectiveStateGroups(objectives: BattleObjectiveState[], x: number, y: number, width: number, maxHeight: number) { @@ -20595,10 +20972,43 @@ export class BattleScene extends Phaser.Scene { return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } + private sortieOrderHudDebugState() { + const snapshot = this.sortieOrderHudSnapshot; + if (!snapshot) { + return null; + } + return { + visible: this.sortieOrderHudObjects.some((object) => object.active), + orderId: snapshot.orderId, + label: snapshot.label, + status: snapshot.status, + tone: snapshot.status, + victoryLabel: snapshot.victoryLabel, + victoryStatus: snapshot.victoryStatus, + victoryTone: snapshot.victoryStatus, + projectedScore: snapshot.projectedScore, + bounds: this.sortieOrderHudBounds ? { ...this.sortieOrderHudBounds } : null, + criteria: snapshot.criteria.map((criterion) => ({ + ...criterion, + tone: criterion.status, + bounds: this.sortieOrderHudCriterionBounds[criterion.id] + ? { ...this.sortieOrderHudCriterionBounds[criterion.id]! } + : null + })), + animationCount: this.sortieOrderHudAnimationCount, + stampedIds: Array.from(this.sortieOrderHudStampedIds), + lastStampedId: this.sortieOrderHudLastStampedId ?? null, + animationVisible: this.sortieOrderHudStampObjects.some((object) => object.active) + }; + } + private resultSortieOrderDebugState() { + const live = this.sortieOrderHudDebugState(); return { available: true, selectedId: this.launchSortieOrderId, + live, + hud: live, open: this.resultSortieOrderVisible, result: this.resultSortieOrder ? {