diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index 47198b1..3477fea 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -35,13 +35,14 @@ try { validateNumericProperties('gold', { min: 1, max: 20000 }); validateNumericProperties('bondExp', { min: 0, max: 200 }); validateNumericProperties('rewardExp', { min: 1, max: 200 }); + const interactionUxGuardCount = validateCampInteractionUxGuards(); if (errors.length > 0) { throw new Error(`Camp reward data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); } console.log( - `Verified ${dialogueEvents.length} camp dialogues, ${visitEvents.length} camp visits, ${campBattleIdEntries.length} camp battle ids, ${campaignStepReferences} campaign-step references, ${campBondReferences} camp bond references, ${rewardBondLinks} reward-bond links, ${itemRewardArrays.length} camp item reward lists, and camp reward numeric fields.` + `Verified ${dialogueEvents.length} camp dialogues, ${visitEvents.length} camp visits, ${campBattleIdEntries.length} camp battle ids, ${campaignStepReferences} campaign-step references, ${campBondReferences} camp bond references, ${rewardBondLinks} reward-bond links, ${itemRewardArrays.length} camp item reward lists, camp reward numeric fields, and ${interactionUxGuardCount} camp interaction UX guards.` ); } finally { await server.close(); @@ -87,6 +88,125 @@ function validateNumericProperties(propertyName, { min, max }) { } } +function validateCampInteractionUxGuards() { + const visitRewardMethod = extractPrivateMethod(source, 'showVisitReward'); + const dialogueRewardMethod = extractPrivateMethod(source, 'showDialogueReward'); + const noticeMethod = extractPrivateMethod(source, 'showCampNotice'); + const noticeDurationMethod = extractPrivateMethod(source, 'campNoticeHoldDuration'); + const enterKeyMethod = extractPrivateMethod(source, 'handleSortieEnterKey'); + const navigationMethod = extractPrivateMethod(source, 'startCampNavigation'); + const steppedNavigationMethod = extractPrivateMethod(source, 'startCampNavigationWithCampaignStep'); + const startStoryMethod = extractPrivateMethod(source, 'startVictoryStory'); + const saveMethod = extractPrivateMethod(source, 'saveCampToSlot'); + let checkedGuardCount = 0; + + expectCampUx( + visitRewardMethod.includes('획득 내역') && + visitRewardMethod.includes('${this.visitRewardText(choice)}') && + visitRewardMethod.includes('응답 · ${choice.response}') && + !visitRewardMethod.includes('delayedCall'), + 'visit rewards must display the acquisition details and response together without delayed replacement' + ); + checkedGuardCount += 1; + + expectCampUx( + dialogueRewardMethod.includes('획득 내역') && + dialogueRewardMethod.includes('공명 +${rewardExp}') && + dialogueRewardMethod.includes('응답 · ${choice.response}') && + !dialogueRewardMethod.includes('delayedCall'), + 'dialogue rewards must display the acquisition details and response together without delayed replacement' + ); + checkedGuardCount += 1; + + expectCampUx( + noticeMethod.includes('delay: this.campNoticeHoldDuration(message)') && + noticeMethod.includes('text.height + 28'), + 'camp notices must size multi-line content and use a message-aware hold duration' + ); + checkedGuardCount += 1; + + const minimumHoldMs = Number(/const campNoticeMinimumHoldMs = (\d+);/.exec(source)?.[1] ?? 0); + expectCampUx( + minimumHoldMs >= 2500 && + noticeDurationMethod.includes('readableCharacterCount * campNoticeCharacterHoldMs') && + noticeDurationMethod.includes('campNoticeMaximumHoldMs'), + `camp notice timing must retain a readable minimum and scale with body length (found ${minimumHoldMs}ms)` + ); + checkedGuardCount += 1; + + expectCampUx( + source.includes('private navigationPending = false;') && + enterKeyMethod.includes('event.repeat || this.navigationPending'), + 'sortie Enter handling must ignore key-repeat events and pending navigation' + ); + checkedGuardCount += 1; + + expectCampUx( + navigationMethod.includes('if (this.navigationPending)') && + navigationMethod.includes('this.navigationPending = true;') && + navigationMethod.includes('startLazyScene(this, key, data)') && + navigationMethod.includes('this.navigationPending = false;'), + 'lazy camp navigation must lock before loading and unlock after a failed transition' + ); + checkedGuardCount += 1; + + expectCampUx( + startStoryMethod.includes('if (this.navigationPending)') && + !startStoryMethod.includes('startLazyScene(') && + ((startStoryMethod.match(/this\.startCampNavigation\(/g)?.length ?? 0) + + (startStoryMethod.match(/this\.startCampNavigationWithCampaignStep\(/g)?.length ?? 0)) >= 4, + 'every camp story, battle, and ending transition must route through the scene navigation lock' + ); + checkedGuardCount += 1; + + expectCampUx( + steppedNavigationMethod.includes('const previousStep = getCampaignState().step') && + steppedNavigationMethod.includes('markCampaignStep(step)') && + steppedNavigationMethod.includes('getCampaignState().step === step') && + steppedNavigationMethod.includes('markCampaignStep(previousStep)') && + navigationMethod.includes('recoverState?.()'), + 'campaign-step navigation must restore the previous step when lazy scene loading fails' + ); + checkedGuardCount += 1; + + const transientUndoFields = [ + 'sortieSwapUndoState', + 'sortieRecommendationUndoState', + 'sortiePresetUndoState', + 'sortieGuidedImprovementUndoState', + 'reportFormationHistoryUndoState' + ]; + const clearedUndoField = transientUndoFields.find((field) => new RegExp(`this\\.${field}\\s*=\\s*undefined`).test(saveMethod)); + expectCampUx( + !clearedUndoField && !saveMethod.includes('persistSortieSelection('), + `manual camp saves must preserve applicable undo state${clearedUndoField ? ` (cleared ${clearedUndoField})` : ''}` + ); + checkedGuardCount += 1; + + return checkedGuardCount; +} + +function expectCampUx(condition, message) { + if (!condition) { + errors.push(`${sourcePath}: ${message}`); + } +} + +function extractPrivateMethod(text, methodName) { + const match = new RegExp(`\\bprivate\\s+${methodName}\\s*\\(`).exec(text); + if (!match) { + errors.push(`${sourcePath}: cannot find private method ${methodName}`); + return ''; + } + const bodyStart = text.indexOf('{', match.index + match[0].length); + if (bodyStart < 0) { + errors.push(`${sourcePath}:${lineNumber(match.index)} cannot find body for private method ${methodName}`); + return ''; + } + const bodyEnd = findMatchingDelimiter(text, bodyStart, '{', '}'); + return text.slice(bodyStart, bodyEnd + 1); +} + function collectCampBattleIdEntries(text, defaultBattleScenarioId) { const objectSource = extractConstObject(text, 'campBattleIds'); const objectOffset = text.indexOf(objectSource); diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 272a418..eaa166d 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -810,13 +810,13 @@ try { throw new Error(`Expected positional bond bonuses and reduced counter damage: ${JSON.stringify(combatMechanicsProbe)}`); } - const moveWaitTurnPromptStates = await page.evaluate(() => { + const finalAllyActionPromptStates = await page.evaluate(async () => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const lastAlly = state?.units?.find((unit) => unit.id === 'zhang-fei'); if (!scene || !state || !lastAlly) { - throw new Error('Expected Zhang Fei and battle scene for move-wait turn prompt test.'); + throw new Error('Expected Zhang Fei and battle scene for final ally action prompt test.'); } scene.hideTurnEndPrompt(); @@ -829,31 +829,79 @@ try { const liveLastAlly = scene.debugUnitById(lastAlly.id); if (!liveLastAlly) { - throw new Error('Expected live Zhang Fei unit for move-wait turn prompt test.'); + throw new Error('Expected live Zhang Fei unit for final ally action prompt test.'); + } + + const originalPosition = { x: liveLastAlly.x, y: liveLastAlly.y }; + const destination = scene.reachableTiles(liveLastAlly).find((tile) => tile.x !== liveLastAlly.x || tile.y !== liveLastAlly.y); + if (!destination) { + throw new Error('Expected a non-origin reachable tile for final ally movement timing test.'); } scene.selectUnit(liveLastAlly); - scene.moveSelectedUnit(liveLastAlly.x, liveLastAlly.y); + const movement = scene.moveSelectedUnit(destination.x, destination.y); + const duringMoveState = window.__HEROS_DEBUG__?.battle(); + await movement; const postMoveState = window.__HEROS_DEBUG__?.battle(); scene.completeUnitAction(liveLastAlly, 'wait'); const postWaitState = window.__HEROS_DEBUG__?.battle(); - return { postMoveState, postWaitState }; + scene.input.keyboard?.emit('keydown-ENTER', { repeat: true }); + const postRepeatedEnterState = window.__HEROS_DEBUG__?.battle(); + const modalBlockerActive = scene.turnPromptObjects.some((object) => object.depth === 39 && object.input?.enabled === true); + const otherAlly = state.units.find((unit) => unit.faction === 'ally' && unit.hp > 0 && unit.id !== liveLastAlly.id); + const liveOtherAlly = otherAlly ? scene.debugUnitById(otherAlly.id) : undefined; + if (liveOtherAlly) { + scene.selectUnit(liveOtherAlly); + } + const postBlockedSelectionState = window.__HEROS_DEBUG__?.battle(); + liveLastAlly.x = originalPosition.x; + liveLastAlly.y = originalPosition.y; + scene.positionUnitView(liveLastAlly); + return { + duringMoveState, + postMoveState, + postWaitState, + postRepeatedEnterState, + postBlockedSelectionState, + modalBlockerActive + }; }); if ( - !moveWaitTurnPromptStates?.postMoveState?.turnPromptVisible || - moveWaitTurnPromptStates.postMoveState.turnPromptMode !== 'post-move' || - moveWaitTurnPromptStates.postMoveState.phase !== 'command' || - moveWaitTurnPromptStates.postMoveState.pendingMove?.unitId !== 'zhang-fei' || - moveWaitTurnPromptStates.postMoveState.actedUnitIds?.includes('zhang-fei') + finalAllyActionPromptStates?.duringMoveState?.phase !== 'animating' || + finalAllyActionPromptStates.duringMoveState.commandMenuVisible || + finalAllyActionPromptStates.duringMoveState.turnPromptVisible ) { - throw new Error(`Expected final ally move to show post-move turn prompt: ${JSON.stringify(moveWaitTurnPromptStates)}`); + throw new Error(`Expected movement animation to lock command input until arrival: ${JSON.stringify(finalAllyActionPromptStates)}`); } if ( - !moveWaitTurnPromptStates.postWaitState?.turnPromptVisible || - moveWaitTurnPromptStates.postWaitState.turnPromptMode !== 'turn-end' || - moveWaitTurnPromptStates.postWaitState.phase !== 'idle' + finalAllyActionPromptStates?.postMoveState?.turnPromptVisible || + finalAllyActionPromptStates.postMoveState.turnPromptMode !== null || + !finalAllyActionPromptStates.postMoveState.commandMenuVisible || + finalAllyActionPromptStates.postMoveState.phase !== 'command' || + finalAllyActionPromptStates.postMoveState.pendingMove?.unitId !== 'zhang-fei' || + finalAllyActionPromptStates.postMoveState.actedUnitIds?.includes('zhang-fei') ) { - throw new Error(`Expected move-wait completion to show turn-end prompt: ${JSON.stringify(moveWaitTurnPromptStates)}`); + throw new Error(`Expected final ally move to keep command selection open without a turn prompt: ${JSON.stringify(finalAllyActionPromptStates)}`); + } + if ( + !finalAllyActionPromptStates.postWaitState?.turnPromptVisible || + finalAllyActionPromptStates.postWaitState.turnPromptMode !== 'turn-end' || + finalAllyActionPromptStates.postWaitState.commandMenuVisible || + finalAllyActionPromptStates.postWaitState.phase !== 'idle' || + !finalAllyActionPromptStates.postWaitState.actedUnitIds?.includes('zhang-fei') + ) { + throw new Error(`Expected final ally action completion to show the turn-end prompt: ${JSON.stringify(finalAllyActionPromptStates)}`); + } + if ( + !finalAllyActionPromptStates.postRepeatedEnterState?.turnPromptVisible || + finalAllyActionPromptStates.postRepeatedEnterState.turnPromptMode !== 'turn-end' || + finalAllyActionPromptStates.postRepeatedEnterState.activeFaction !== 'ally' || + !finalAllyActionPromptStates.modalBlockerActive || + !finalAllyActionPromptStates.postBlockedSelectionState?.turnPromptVisible || + finalAllyActionPromptStates.postBlockedSelectionState.turnPromptMode !== 'turn-end' || + finalAllyActionPromptStates.postBlockedSelectionState.selectedUnitId + ) { + throw new Error(`Expected repeated Enter input and underlying battlefield selection to leave the turn-end prompt open: ${JSON.stringify(finalAllyActionPromptStates)}`); } await page.evaluate(() => { @@ -1028,6 +1076,36 @@ try { throw new Error(`Expected leader objective to resolve after victory: ${JSON.stringify(victoryState.objectives)}`); } + const resultDoubleActivationState = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene) { + throw new Error('Expected BattleScene for result double-activation guard test.'); + } + scene.resultSettlementStatus = 'pending'; + scene.refreshResultContinueCta(); + scene.handleResultContinue(); + scene.handleResultContinue(); + return { + activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + battle: window.__HEROS_DEBUG__?.battle() + }; + }); + const guardedContinueAction = resultDoubleActivationState.battle?.resultActions?.find((action) => action.kind === 'continue'); + if ( + resultDoubleActivationState.battle?.resultSettlement?.status !== 'complete' || + resultDoubleActivationState.battle?.resultVisible !== true || + !resultDoubleActivationState.activeScenes.includes('BattleScene') || + resultDoubleActivationState.activeScenes.includes('StoryScene') || + resultDoubleActivationState.activeScenes.includes('CampScene') || + guardedContinueAction?.interactive !== false + ) { + throw new Error(`Expected rapid result activation to finish settlement without leaving the result screen: ${JSON.stringify(resultDoubleActivationState)}`); + } + await page.waitForFunction(() => { + const continueAction = window.__HEROS_DEBUG__?.battle()?.resultActions?.find((action) => action.kind === 'continue'); + return continueAction?.interactive === true; + }); + await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true }); await page.mouse.click(738, 642); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 45f94e3..cdf5d52 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1398,7 +1398,7 @@ type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type BattleOutcome = 'victory' | 'defeat'; type SaveSlotMode = 'save' | 'load'; -type TurnPromptMode = 'turn-end' | 'post-move' | 'load-confirm' | 'save-overwrite-confirm'; +type TurnPromptMode = 'turn-end' | 'load-confirm' | 'save-overwrite-confirm'; const sideQuickTabDefinitions: ReadonlyArray<{ id: SideQuickTabId; @@ -3531,6 +3531,7 @@ export class BattleScene extends Phaser.Scene { private scenarioCombatAssetSettlementCount = 0; private resumeBattleSaveSlot?: number; private pendingDeploymentConfirmation = false; + private pendingDeploymentSignature?: string; private turnNumber = 1; private activeFaction: ActiveFaction = 'ally'; private selectedUnit?: UnitData; @@ -3638,6 +3639,8 @@ export class BattleScene extends Phaser.Scene { private resultSettlementTweens: Phaser.Tweens.Tween[] = []; private resultSettlementTransientObjects: Phaser.GameObjects.GameObject[] = []; private resultContinueButton?: ResultButtonView; + private resultContinueLockedUntil = 0; + private resultNavigationPending = false; private resultCoreResonanceSummaryText?: Phaser.GameObjects.Text; private resultFormationReviewObjects: Phaser.GameObjects.GameObject[] = []; private resultFormationReviewVisible = false; @@ -3735,6 +3738,7 @@ export class BattleScene extends Phaser.Scene { this.scenarioCombatAssetLoadCompletedAt = undefined; this.scenarioCombatAssetSettlementCount = 0; this.pendingDeploymentConfirmation = false; + this.pendingDeploymentSignature = undefined; this.rosterTab = 'ally'; this.rosterPage = 0; this.bondPage = 0; @@ -3934,7 +3938,10 @@ export class BattleScene extends Phaser.Scene { this.keyboardBattleCursor = undefined; this.updatePointerFeedback(pointer); }); - this.input.keyboard?.on('keydown-ENTER', () => { + this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => { + if (event.repeat) { + return; + } if (this.tacticalCommandCutInObjects.length > 0) { return; } @@ -4223,8 +4230,15 @@ export class BattleScene extends Phaser.Scene { this.scenarioCombatAssetSettlementCount += 1; this.flushScenarioCombatAssetCallbacks(); if (this.pendingDeploymentConfirmation && this.phase === 'deployment') { + const deploymentUnchanged = this.pendingDeploymentSignature === this.deploymentSignature(); this.pendingDeploymentConfirmation = false; - this.confirmPreBattleDeployment(); + this.pendingDeploymentSignature = undefined; + if (deploymentUnchanged) { + this.confirmPreBattleDeployment(); + return; + } + this.deploymentNotice = '준비 중 배치가 바뀌었습니다. 현재 배치를 확인한 뒤 전투 시작을 다시 눌러주세요.'; + this.renderDeploymentPanel(); } }, watchdogMs, generation); }, { watchdogMs }); @@ -7307,7 +7321,7 @@ export class BattleScene extends Phaser.Scene { } if (cursor.mode === 'move' && this.phase === 'moving') { - this.moveSelectedUnit(cursor.x, cursor.y); + void this.moveSelectedUnit(cursor.x, cursor.y); return true; } if (this.phase !== 'targeting' || !cursor.targetId || !this.targetingAction) { @@ -7343,6 +7357,10 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.turnPromptMode) { + return; + } + const tutorialPath = this.firstBattleTutorialPath; const tutorialStep = this.firstBattleTutorialStep; const completingTutorialSelect = Boolean( @@ -7385,7 +7403,7 @@ export class BattleScene extends Phaser.Scene { } if (this.phase === 'moving' && this.selectedUnit?.id === unit.id) { - this.moveSelectedUnit(unit.x, unit.y); + void this.moveSelectedUnit(unit.x, unit.y); return; } @@ -7494,14 +7512,14 @@ export class BattleScene extends Phaser.Scene { marker.on('pointerout', () => marker.setFillStyle(palette.blue, fillAlpha)); marker.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (pointer.leftButtonDown()) { - this.moveSelectedUnit(x, y, pointer); + void this.moveSelectedUnit(x, y, pointer); } }); this.markers.push(marker); }); } - private moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) { + private async moveSelectedUnit(x: number, y: number, pointer?: Phaser.Input.Pointer) { if (this.isFirstBattleTutorialActive()) { const path = this.firstBattleTutorialPath!; if (this.firstBattleTutorialStep !== 'move') { @@ -7513,7 +7531,7 @@ export class BattleScene extends Phaser.Scene { return; } } - if (!this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) { + if (this.phase !== 'moving' || !this.selectedUnit || !this.canMoveTo(this.selectedUnit, x, y)) { return; } @@ -7530,7 +7548,7 @@ export class BattleScene extends Phaser.Scene { const stayingInPlace = fromX === x && fromY === y; unit.x = x; unit.y = y; - this.phase = 'command'; + this.phase = stayingInPlace ? 'command' : 'animating'; this.clearMarkers(); this.updateMiniMap(); this.refreshEnemyIntentForecast(); @@ -7550,18 +7568,14 @@ export class BattleScene extends Phaser.Scene { const targetX = this.tileCenterX(x); const targetY = this.tileCenterY(y); if (!stayingInPlace) { - this.moveUnitView(unit, x, y, view, 260, fromX, fromY); + await this.moveUnitViewAsync(unit, x, y, view, 260, fromX, fromY); + if (this.battleOutcome || this.selectedUnit?.id !== unit.id || this.pendingMove?.unit.id !== unit.id) { + return; + } + this.phase = 'command'; } const commandX = pointer?.x ?? targetX; const commandY = pointer?.y ?? targetY; - const isFinalAlly = this.remainingAllyCount() === 1; - - if (isFinalAlly) { - this.hideCommandMenu(); - this.renderUnitDetail(unit, '마지막 장수 · 명령 또는 턴 종료'); - this.showPostMoveTurnEndPrompt(unit, commandX, commandY); - return; - } this.showCommandMenu(unit, commandX, commandY); this.renderUnitDetail(unit, '명령 선택 · 우클릭은 이동 취소'); @@ -7572,41 +7586,6 @@ export class BattleScene extends Phaser.Scene { } } - private showPostMoveTurnEndPrompt(unit: UnitData, commandX = this.tileCenterX(unit.x), commandY = this.tileCenterY(unit.y)) { - this.hideCommandMenu(); - this.showTurnEndPrompt(undefined, { - mode: 'post-move', - title: '마지막 장수 이동 완료', - body: this.pendingMove?.unit.id === unit.id - ? `이동 결과 · ${this.moveIntentRiskCompactLine(this.pendingMove.intentRisk)}\n${this.enemyIntentTurnEndBody()}` - : this.enemyIntentTurnEndBody(), - primaryLabel: '대기 후 턴 종료', - secondaryLabel: '명령 선택', - primaryAction: () => { - if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { - this.hideTurnEndPrompt(); - return; - } - - this.completeUnitAction(unit, 'wait'); - if (this.activeFaction === 'ally' && !this.battleOutcome) { - this.endAllyTurn(); - } - }, - secondaryAction: () => { - if (this.phase !== 'command' || this.selectedUnit?.id !== unit.id || this.actedUnitIds.has(unit.id)) { - this.hideTurnEndPrompt(); - return; - } - - this.suppressNextLeftClick = true; - this.hideTurnEndPrompt(); - this.showCommandMenu(unit, commandX, commandY); - this.renderUnitDetail(unit, '공격, 책략, 도구, 대기 중 하나를 선택하세요.\n우클릭하면 이동을 취소합니다.'); - } - }); - } - private canMoveTo(unit: UnitData, x: number, y: number) { return this.reachableTiles(unit).some((tile) => tile.x === x && tile.y === y); } @@ -8386,7 +8365,14 @@ export class BattleScene extends Phaser.Scene { roleBounds.push({ x: left, y: rowTop, width, height: roleRowHeight }); }); - this.renderDeploymentButton(left, startButtonTop, width, '전투 시작', palette.gold, () => this.confirmPreBattleDeployment()); + this.renderDeploymentButton( + left, + startButtonTop, + width, + this.pendingDeploymentConfirmation ? '전투 준비 중' : '전투 시작', + palette.gold, + () => this.confirmPreBattleDeployment() + ); this.renderDeploymentButton( left, restoreButtonTop, @@ -8625,7 +8611,8 @@ export class BattleScene extends Phaser.Scene { if (this.scenarioCombatAssetStatus !== 'ready' && this.scenarioCombatAssetStatus !== 'degraded') { this.pendingDeploymentConfirmation = true; - this.deploymentNotice = '\uC804\uD22C \uB3D9\uC791\uC744 \uC900\uBE44\uD558\uACE0 \uC788\uC2B5\uB2C8\uB2E4. \uC900\uBE44\uAC00 \uB05D\uB098\uBA74 \uC790\uB3D9\uC73C\uB85C \uC804\uD22C\uB97C \uC2DC\uC791\uD569\uB2C8\uB2E4.'; + this.pendingDeploymentSignature = this.deploymentSignature(); + this.deploymentNotice = '전투 동작을 준비하고 있습니다. 배치를 바꾸면 준비 완료 후 현재 배치를 다시 확인합니다.'; this.renderDeploymentPanel(); this.ensureScenarioCombatAssets(); return; @@ -8646,6 +8633,13 @@ export class BattleScene extends Phaser.Scene { soundDirector.playSelect(); } + private deploymentSignature() { + return this.deploymentEligibleUnits() + .map((unit) => `${unit.id}:${unit.x},${unit.y}`) + .sort() + .join('|'); + } + private updateDeploymentPointerFeedback(tile: { x: number; y: number }, force: boolean) { const selected = this.deploymentSelectedUnit(); const occupant = this.unitAtTile(tile.x, tile.y); @@ -11509,7 +11503,8 @@ export class BattleScene extends Phaser.Scene { await this.presentCombatResult(result.counter); } await this.showCombatExchangeMapResults(result); - await this.delay(result.counter ? 220 : 160); + // Keep the result anchored on its unit long enough to read before auto-focusing the next ally. + await this.delay(620); this.finishUnitAction(attacker, this.formatCombatResult(result), { defeatedEnemyIds: result.defeated ? [result.defender.id] : [] }); @@ -11537,7 +11532,8 @@ export class BattleScene extends Phaser.Scene { this.clearMarkers(); await this.presentSupportResult(result); this.showSupportMapResult(result); - await this.delay(160); + // Keep the support result visible before the camera moves to the next actionable ally. + await this.delay(620); this.finishUnitAction(user, this.formatSupportResult(result)); } @@ -12686,6 +12682,8 @@ export class BattleScene extends Phaser.Scene { this.hideBattleResult(); this.resultAnimationToken += 1; this.resultGaugeAnimations = []; + this.resultContinueLockedUntil = 0; + this.resultNavigationPending = false; const animationToken = this.resultAnimationToken; const depth = 120; @@ -12914,21 +12912,61 @@ export class BattleScene extends Phaser.Scene { } private handleResultContinue() { + if (this.resultNavigationPending) { + return; + } + soundDirector.playSelect(); if (this.resultSettlementStatus !== 'complete') { this.completeResultSettlement(true); + this.resultContinueLockedUntil = this.time.now + 420; + const button = this.resultContinueButton; + if (button) { + button.background.disableInteractive(); + button.background.setAlpha(0.72); + button.label.setAlpha(0.72); + this.time.delayedCall(420, () => { + if (!button.background.active || this.resultContinueButton !== button || this.resultNavigationPending) { + return; + } + button.background.setInteractive({ useHandCursor: true }); + button.background.setAlpha(1); + button.label.setAlpha(1); + }); + } + return; + } + + if (this.time.now < this.resultContinueLockedUntil) { return; } const { destination } = this.resultContinueRoute(); + this.resultNavigationPending = true; + this.resultActionButtons.forEach((button) => { + button.background.disableInteractive(); + button.background.setAlpha(0.72); + button.label.setAlpha(0.72); + }); + const recoverNavigation = () => { + this.resultNavigationPending = false; + this.resultActionButtons.forEach((button) => { + if (!button.background.active) { + return; + } + button.background.setInteractive({ useHandCursor: true }); + button.background.setAlpha(1); + button.label.setAlpha(1); + }); + }; if (destination === 'victory-story') { void startLazyScene(this, 'StoryScene', { pages: firstBattleVictoryPages, nextScene: 'CampScene' - }); + }).catch(recoverNavigation); return; } - void startLazyScene(this, 'CampScene'); + void startLazyScene(this, 'CampScene').catch(recoverNavigation); } private hideBattleResult() { @@ -12957,6 +12995,8 @@ export class BattleScene extends Phaser.Scene { this.resultSettlementSkipped = false; this.resultSettlementProgress = 0; this.resultContinueButton = undefined; + this.resultContinueLockedUntil = 0; + this.resultNavigationPending = false; this.resultCoreResonanceSummaryText = undefined; } @@ -24111,6 +24151,21 @@ export class BattleScene extends Phaser.Scene { this.hideTurnEndPrompt(); this.turnPromptMode = options.mode ?? 'turn-end'; + const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x03070b, 0.46); + shade.setOrigin(0); + shade.setDepth(39); + shade.setInteractive(); + shade.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event?: { stopPropagation: () => void } + ) => event?.stopPropagation() + ); + this.turnPromptObjects.push(shade); + const width = this.battleUiLength(388); const height = this.battleUiLength(162); const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2; @@ -24988,6 +25043,14 @@ export class BattleScene extends Phaser.Scene { fontStyle: '700' })); + if (this.activeFaction === 'ally' && !this.battleOutcome && this.remainingAllyCount() === 0) { + this.renderSituationTurnEndButton( + left + width - this.battleUiLength(92), + top, + this.battleUiLength(92) + ); + } + this.renderSituationLine('현재 턴', `${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`, left, top + this.battleUiLength(32), width, battleFhdUiScale); this.renderSituationLine('아군 행동', `${actedCount} / ${allyCount}`, left, top + this.battleUiLength(60), width, battleFhdUiScale); this.renderSituationLine('남은 적', `${enemyCount}`, left, top + this.battleUiLength(88), width, battleFhdUiScale); @@ -25015,6 +25078,38 @@ export class BattleScene extends Phaser.Scene { this.playSidePanelTransition(transition); } + private renderSituationTurnEndButton(x: number, y: number, width: number) { + const height = this.battleUiLength(24); + const button = this.trackSideObject(this.add.rectangle(x, y, width, height, 0x4a371d, 0.98)); + button.setOrigin(0); + button.setDepth(32); + button.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.9); + + const text = this.trackSideObject(this.add.text(x + width / 2, y + height / 2, '턴 종료', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(11), + color: '#fff2b8', + fontStyle: '700' + })); + text.setOrigin(0.5); + text.setDepth(33); + + const activate = () => { + if (this.activeFaction !== 'ally' || this.battleOutcome || this.remainingAllyCount() > 0) { + return; + } + this.suppressNextLeftClick = true; + soundDirector.playSelect(); + this.showTurnEndPrompt(); + }; + [button, text].forEach((object) => { + object.setInteractive({ useHandCursor: true }); + object.on('pointerdown', activate); + }); + button.on('pointerover', () => button.setFillStyle(0x654c25, 0.99)); + button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); + } + private compactSituationMessage(message: string) { return message .split('\n') diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index a73e520..0d12fc6 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -204,11 +204,16 @@ import { import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; -import { startLazyScene } from './lazyScenes'; +import { startLazyScene, type LazySceneKey } from './lazyScenes'; const campLegacyCanvasWidth = 1280; const campLegacyCanvasHeight = 720; const campFhdUiScale = 1.5; +const campNoticeMinimumHoldMs = 2600; +const campNoticeMaximumHoldMs = 9000; +const campNoticeBaseHoldMs = 1800; +const campNoticeCharacterHoldMs = 55; +const campNoticeParagraphPauseMs = 500; type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'equipment' | 'progress'; @@ -11383,6 +11388,7 @@ export class CampScene extends Phaser.Scene { private openSortieImprovementOnCreate = false; private retrySortieBattleId?: BattleScenarioId; private skipIntroStoryForSortie = false; + private navigationPending = false; constructor() { super('CampScene'); @@ -11398,6 +11404,7 @@ export class CampScene extends Phaser.Scene { create() { this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.releaseOwnedCampTextures()); + this.navigationPending = false; this.contentObjects = []; this.campRosterPage = 0; this.campRosterLayout = undefined; @@ -12958,11 +12965,8 @@ export class CampScene extends Phaser.Scene { private saveCampToSlot(slot: number) { soundDirector.playSelect(); const returnToSortiePrep = this.sortieObjects.length > 0; - this.sortieSwapUndoState = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; - this.sortieRecommendationUndoState = undefined; this.closeSortieRecommendationBrowserState(); - this.sortiePresetUndoState = undefined; this.closeSortiePresetBrowserState(); this.hideCampSaveSlotPanel(); clearCampaignBattleSavesForSlot(slot); @@ -13536,6 +13540,9 @@ export class CampScene extends Phaser.Scene { } private handleSortieEnterKey(event: KeyboardEvent) { + if (event.repeat || this.navigationPending) { + return; + } if ( this.sortieObjects.length === 0 || this.victoryRewardObjects.length > 0 || @@ -19757,6 +19764,9 @@ export class CampScene extends Phaser.Scene { } private startVictoryStory() { + if (this.navigationPending) { + return; + } const flow = this.currentSortieFlow(); if (flow.nextBattleId && !this.hasCurrentSortieOrderSelection()) { this.sortiePrepStep = 'formation'; @@ -19785,7 +19795,7 @@ export class CampScene extends Phaser.Scene { if (this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep) { this.hideSortiePrep(); - void startLazyScene(this, 'EndingScene'); + this.startCampNavigation('EndingScene'); return; } @@ -19796,16 +19806,14 @@ export class CampScene extends Phaser.Scene { this.showCampNotice(flow.unavailableNotice ?? '현재 군영 정비를 먼저 마치세요. 대화, 보급, 편성을 점검할 수 있습니다.'); return; } - markCampaignStep(flow.campaignStep); - this.campaign = getCampaignState(); - void startLazyScene(this, 'StoryScene', { + this.startCampNavigationWithCampaignStep(flow.campaignStep, 'StoryScene', { pages: flow.pages, nextScene: flow.campaignStep === 'ending-complete' ? 'EndingScene' : 'CampScene' }); return; } if (flow.pages.length > 0) { - void startLazyScene(this, 'StoryScene', { + this.startCampNavigation('StoryScene', { pages: flow.pages, nextScene: 'CampScene' }); @@ -19823,7 +19831,6 @@ export class CampScene extends Phaser.Scene { return; } - markCampaignStep(flow.campaignStep); const selectedSortieUnitIds = [...this.selectedSortieUnitIds]; const sortieFormationAssignments = { ...this.sortieFormationAssignments }; const sortieItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); @@ -19833,7 +19840,7 @@ export class CampScene extends Phaser.Scene { : undefined; const sortieRecommendation = this.currentSortieRecommendationSelection(); if (this.skipIntroStoryForSortie) { - void startLazyScene(this, 'BattleScene', { + this.startCampNavigationWithCampaignStep(flow.campaignStep, 'BattleScene', { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments, @@ -19845,7 +19852,7 @@ export class CampScene extends Phaser.Scene { return; } - void startLazyScene(this, 'StoryScene', { + this.startCampNavigationWithCampaignStep(flow.campaignStep, 'StoryScene', { pages: flow.pages, nextScene: 'BattleScene', nextSceneData: { @@ -19860,6 +19867,46 @@ export class CampScene extends Phaser.Scene { }); } + private startCampNavigationWithCampaignStep( + step: CampaignStep, + key: LazySceneKey, + data?: Record + ) { + if (this.navigationPending) { + return; + } + + const previousStep = getCampaignState().step; + markCampaignStep(step); + this.campaign = getCampaignState(); + this.startCampNavigation(key, data, () => { + if (getCampaignState().step === step) { + markCampaignStep(previousStep); + } + this.campaign = getCampaignState(); + }); + } + + private startCampNavigation( + key: LazySceneKey, + data?: Record, + recoverState?: () => void + ) { + if (this.navigationPending) { + return; + } + + this.navigationPending = true; + void startLazyScene(this, key, data).catch((error: unknown) => { + this.navigationPending = false; + recoverState?.(); + console.error(`[CampScene] ${key} 전환에 실패했습니다.`, error); + if (this.sys.isActive()) { + this.showCampNotice('다음 장면을 불러오지 못했습니다. 잠시 후 다시 시도하십시오.'); + } + }); + } + private isFinalEpilogueFlow(flow = this.currentSortieFlow()) { return flow.campaignStep === 'ending-complete' && !flow.nextBattleId; } @@ -22881,8 +22928,7 @@ export class CampScene extends Phaser.Scene { } private showVisitReward(choice: CampVisitChoice) { - this.showCampNotice(`${choice.label} · ${this.visitRewardText(choice)}`); - this.time.delayedCall(120, () => this.showCampNotice(choice.response)); + this.showCampNotice(`획득 내역 · ${choice.label} · ${this.visitRewardText(choice)}\n응답 · ${choice.response}`); } private visitRewardText(choice: CampVisitChoice) { @@ -22919,8 +22965,7 @@ export class CampScene extends Phaser.Scene { } private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) { - this.showCampNotice(`${choice.label} · 공명 +${rewardExp}`); - this.time.delayedCall(120, () => this.showCampNotice(choice.response)); + this.showCampNotice(`획득 내역 · ${choice.label} · 공명 +${rewardExp}\n응답 · ${choice.response}`); } private showCampNotice(message: string) { @@ -22928,10 +22973,8 @@ export class CampScene extends Phaser.Scene { this.dialogueObjects.forEach((object) => object.active && object.destroy()); this.dialogueObjects = []; const depth = this.sortieObjects.length > 0 ? 72 : 30; - const noticeWidth = Math.min(640, Math.max(360, message.length * 13)); - const box = this.scaleLegacyCampUi(this.add.rectangle(campLegacyCanvasWidth / 2, 104, noticeWidth, 50, 0x101820, 0.96)); - box.setStrokeStyle(2, palette.gold, 0.84); - box.setDepth(depth); + const longestLineLength = Math.max(...message.split('\n').map((line) => line.length)); + const noticeWidth = Math.min(760, Math.max(420, longestLineLength * 13)); const text = this.scaleLegacyCampUi(this.add.text(campLegacyCanvasWidth / 2, 104, message, { ...this.textStyle(14, '#f2e3bf', true), align: 'center', @@ -22939,12 +22982,17 @@ export class CampScene extends Phaser.Scene { })); text.setOrigin(0.5); text.setDepth(depth + 1); + text.setLineSpacing(5); + const noticeHeight = Math.max(56, text.height + 28); + const box = this.scaleLegacyCampUi(this.add.rectangle(campLegacyCanvasWidth / 2, 104, noticeWidth, noticeHeight, 0x101820, 0.96)); + box.setStrokeStyle(2, palette.gold, 0.84); + box.setDepth(depth); const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text]; this.dialogueObjects = noticeObjects; this.tweens.add({ targets: noticeObjects, alpha: 0, - delay: 1300, + delay: this.campNoticeHoldDuration(message), duration: 320, onComplete: () => { noticeObjects.forEach((object) => object.active && object.destroy()); @@ -22955,6 +23003,18 @@ export class CampScene extends Phaser.Scene { }); } + private campNoticeHoldDuration(message: string) { + const readableCharacterCount = message.replace(/\s/g, '').length; + const paragraphCount = Math.max(1, message.split('\n').filter((line) => line.trim().length > 0).length); + return Phaser.Math.Clamp( + campNoticeBaseHoldMs + + readableCharacterCount * campNoticeCharacterHoldMs + + (paragraphCount - 1) * campNoticeParagraphPauseMs, + campNoticeMinimumHoldMs, + campNoticeMaximumHoldMs + ); + } + private selectedUnit() { return this.currentUnits().find((unit) => unit.id === this.selectedUnitId); }