From 055f7d95c9818a320b3bf97823dac870561a4c23 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 11 Jul 2026 03:12:06 +0900 Subject: [PATCH] feat: add tactical command officer cut-ins --- scripts/verify-release-candidate.mjs | 77 ++++++- src/game/scenes/BattleScene.ts | 298 ++++++++++++++++++++++++--- 2 files changed, 347 insertions(+), 28 deletions(-) diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index e9114b5..967b72d 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2613,17 +2613,88 @@ async function activateSortieOrderCommand(page, role) { await page.mouse.click(rolePoint.x, rolePoint.y); await page.waitForFunction( (expectedRole) => { - const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder; + const state = window.__HEROS_DEBUG__?.battle(); + const order = state?.sortieOperationOrder; return ( order?.commandPanelOpen === false && order?.live?.commandUsed === true && - order?.live?.command?.role === expectedRole + order?.live?.command?.role === expectedRole && + state?.tacticalInitiative?.cutIn?.active === true ); }, role, { timeout: 30000 } ); - return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder); + await page.waitForTimeout(180); + + const activeCutInProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const state = window.__HEROS_DEBUG__?.battle(); + return { + cutIn: state?.tacticalInitiative?.cutIn ?? null, + command: state?.sortieOperationOrder?.live?.command ?? null, + sceneBounds: scene + ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } + : null + }; + }); + assert( + activeCutInProbe.cutIn?.active === true && + activeCutInProbe.cutIn.count === 1 && + activeCutInProbe.cutIn.last?.source === 'sortie-order' && + activeCutInProbe.cutIn.last?.role === role && + typeof activeCutInProbe.cutIn.last?.actorId === 'string' && + activeCutInProbe.cutIn.last.actorId.length > 0 && + activeCutInProbe.cutIn.last.actorId === activeCutInProbe.command?.actorId && + activeCutInProbe.cutIn.last?.label === '\uC7AC\uC815\uBE44' && + isFiniteBounds(activeCutInProbe.cutIn.bounds) && + isFiniteBounds(activeCutInProbe.cutIn.last?.bounds) && + isFiniteBounds(activeCutInProbe.sceneBounds) && + boundsInside(activeCutInProbe.cutIn.bounds, activeCutInProbe.sceneBounds) && + boundsInside(activeCutInProbe.cutIn.last.bounds, activeCutInProbe.sceneBounds), + `Expected the selected order command to show one bounded in-scene cut-in with its source, role, actor, and label: ${JSON.stringify(activeCutInProbe)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-first-battle-order-command-cutin.png`, fullPage: true }); + await assertCanvasPainted(page, 'first battle order command cut-in'); + + await page.waitForFunction( + (expectedRole) => { + const state = window.__HEROS_DEBUG__?.battle(); + const order = state?.sortieOperationOrder; + const cutIn = state?.tacticalInitiative?.cutIn; + return ( + cutIn?.active === false && + cutIn.count === 1 && + cutIn.last?.source === 'sortie-order' && + cutIn.last?.role === expectedRole && + order?.live?.command?.role === expectedRole && + order.live.command.effectPending === false && + order.live.command.effectValue > 0 + ); + }, + role, + { timeout: 30000 } + ); + const settledProbe = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return { + order: state?.sortieOperationOrder ?? null, + cutIn: state?.tacticalInitiative?.cutIn ?? null + }; + }); + assert( + settledProbe.cutIn?.active === false && + settledProbe.cutIn.count === 1 && + settledProbe.cutIn.last?.source === 'sortie-order' && + settledProbe.cutIn.last?.role === role && + settledProbe.cutIn.last?.actorId === settledProbe.order?.live?.command?.actorId && + settledProbe.cutIn.last?.label === '\uC7AC\uC815\uBE44' && + isFiniteBounds(settledProbe.cutIn.last?.bounds) && + settledProbe.order?.live?.command?.effectPending === false && + settledProbe.order?.live?.command?.effectValue > 0, + `Expected the cut-in to finish once while retaining its last payload before resolving the support effect: ${JSON.stringify(settledProbe)}` + ); + return settledProbe.order; } async function readBattleOrderCommandControlPoint(page, control, role) { diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index a198408..1b4d642 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1641,6 +1641,14 @@ type IntentCounterplayActionOutcome = { type TacticalInitiativeRole = BattleSaveTacticalCommandRole; type TacticalCommandSource = NonNullable; +type TacticalCommandCutInSnapshot = { + source: TacticalCommandSource; + role: TacticalInitiativeRole; + actorId: string; + label: string; + bounds: SortieOrderHudBounds; +}; + type TacticalInitiativeCombatEffect = { role?: TacticalInitiativeRole; damageBonus: number; @@ -3257,6 +3265,11 @@ export class BattleScene extends Phaser.Scene { private battleSpeedControlObjects: Phaser.GameObjects.GameObject[] = []; private tacticalInitiativeChipObjects: Phaser.GameObjects.GameObject[] = []; private tacticalInitiativePanelObjects: Phaser.GameObjects.GameObject[] = []; + private tacticalCommandCutInObjects: Phaser.GameObjects.GameObject[] = []; + private tacticalCommandCutInBounds?: SortieOrderHudBounds; + private tacticalCommandCutInCount = 0; + private tacticalCommandCutInLast?: TacticalCommandCutInSnapshot; + private tacticalCommandCutInToken = 0; private sortieOrderHudObjects: Phaser.GameObjects.GameObject[] = []; private sortieOrderHudStampObjects: Phaser.GameObjects.GameObject[] = []; private sortieOrderHudBounds?: SortieOrderHudBounds; @@ -3360,6 +3373,10 @@ export class BattleScene extends Phaser.Scene { create() { this.hideBattleResult(); + this.hideTacticalCommandCutIn(); + this.tacticalCommandCutInCount = 0; + this.tacticalCommandCutInLast = undefined; + this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.hideTacticalCommandCutIn()); this.resultFormationReviewVisible = false; this.resultFormationReviewFeedback = ''; this.resultPresetOverwriteConfirmId = undefined; @@ -3422,6 +3439,9 @@ export class BattleScene extends Phaser.Scene { }); this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => this.updatePointerFeedback(pointer)); this.input.keyboard?.on('keydown-ENTER', () => { + if (this.tacticalCommandCutInObjects.length > 0) { + return; + } if (this.tacticalInitiativePanelObjects.length > 0) { return; } @@ -3433,6 +3453,9 @@ export class BattleScene extends Phaser.Scene { this.confirmLockedTargetPreview(); }); this.input.keyboard?.on('keydown-ESC', () => { + if (this.tacticalCommandCutInObjects.length > 0) { + return; + } if (this.resultSortieOrderVisible) { soundDirector.playSelect(); this.hideResultSortieOrder(); @@ -3511,7 +3534,7 @@ export class BattleScene extends Phaser.Scene { this.renderBattleSpeedControl(); }); this.input.keyboard?.on('keydown-PERIOD', () => { - if (this.tacticalInitiativePanelObjects.length > 0) { + if (this.tacticalInitiativePanelObjects.length > 0 || this.tacticalCommandCutInObjects.length > 0) { return; } this.cycleBattleSpeed(); @@ -4797,6 +4820,7 @@ export class BattleScene extends Phaser.Scene { this.alertObjects.length === 0 && this.battleEventObjects.length === 0 && this.combatCutInObjects.length === 0 && + this.tacticalCommandCutInObjects.length === 0 && this.resultObjects.length === 0; const highlighted = ready || pending; @@ -4839,6 +4863,7 @@ export class BattleScene extends Phaser.Scene { this.alertObjects.length > 0 || this.battleEventObjects.length > 0 || this.combatCutInObjects.length > 0 || + this.tacticalCommandCutInObjects.length > 0 || this.resultObjects.length > 0 || this.battleOutcome ) { @@ -5042,7 +5067,7 @@ export class BattleScene extends Phaser.Scene { if (available) { const select = () => { this.suppressNextLeftClick = true; - this.activateTacticalInitiativeCommand(role); + void this.activateTacticalInitiativeCommand(role); }; bg.setInteractive({ useHandCursor: true }); bg.on('pointerover', () => bg.setFillStyle(0x254451, 1)); @@ -5053,7 +5078,7 @@ export class BattleScene extends Phaser.Scene { } } - private activateTacticalInitiativeCommand(role: TacticalInitiativeRole) { + private async activateTacticalInitiativeCommand(role: TacticalInitiativeRole) { const actor = this.tacticalInitiativeActor(role); if (!actor || !this.canUseTacticalInitiativeCommand(role)) { return; @@ -5063,8 +5088,9 @@ export class BattleScene extends Phaser.Scene { return; } + const source = this.tacticalCommandSource() ?? this.tacticalCommandPanelSource(); this.tacticalCommand = { - source: this.tacticalCommandSource() ?? this.tacticalCommandPanelSource(), + source, role, actorId: actor.id, usedTurn: this.turnNumber, @@ -5088,38 +5114,244 @@ export class BattleScene extends Phaser.Scene { const healText = healAmount > 0 ? `병력 +${healAmount}` : ''; const statusText = statusesCleared > 0 ? `상태 ${statusesCleared}개 해제` : ''; effectText = `${supportTarget.name} · ${[healText, statusText].filter(Boolean).join(' · ')}`; - this.showMapResultPopup( - supportTarget, - [healAmount > 0 ? `재정비 +${healAmount}` : '재정비', statusesCleared > 0 ? `상태 해제 ${statusesCleared}` : ''], - '#a8ffd0', - '#071623', - 18, - 34 - ); - } else { - this.showMapResultPopup( - actor, - [this.tacticalInitiativeCommandLabel(role), role === 'front' ? '적 일반 공격 방호' : '다음 공격 강화'], - '#ffdf7b', - '#2b1606', - 18, - 34 - ); } - const sourceLabel = this.tacticalCommand.source === 'sortie-order' ? '군령 발동' : '전술 주도권'; + const sourceLabel = source === 'sortie-order' ? '군령 발동' : '전술 주도권'; const message = `${sourceLabel} · ${this.tacticalInitiativeCommandLabel(role)} · ${actor.name}\n${effectText}`; + const previousPhase: BattlePhase = this.phase === 'moving' ? 'moving' : 'idle'; + this.phase = 'animating'; this.hideTacticalInitiativePanel(); soundDirector.playStrategyPulse(); this.pushBattleLog(message); this.renderTacticalInitiativeChip(); this.renderSortieOrderHud(); - this.refreshEnemyIntentForecast(); - if (this.phase === 'moving' && this.selectedUnit) { + if (previousPhase === 'moving' && this.selectedUnit) { this.renderUnitDetail(this.selectedUnit, `${message}\n이동할 파란 칸을 선택하세요.`); } else { this.renderSituationPanel(message); } + + try { + const completed = await this.playTacticalCommandCutIn(source, role, actor, effectText); + if (!completed || this.battleOutcome) { + return; + } + if (role === 'support' && supportTarget) { + const healAmount = this.tacticalCommand.effectValue; + const statusesCleared = this.tacticalCommand.statusesCleared; + this.showMapResultPopup( + supportTarget, + [healAmount > 0 ? `재정비 +${healAmount}` : '재정비', statusesCleared > 0 ? `상태 해제 ${statusesCleared}` : ''], + '#a8ffd0', + '#071623', + 18, + 34 + ); + } else { + this.showMapResultPopup( + actor, + [this.tacticalInitiativeCommandLabel(role), role === 'front' ? '적 일반 공격 방호' : '다음 공격 강화'], + '#ffdf7b', + '#2b1606', + 18, + 34 + ); + } + } finally { + if (!this.battleOutcome && this.phase === 'animating') { + this.phase = previousPhase; + this.refreshEnemyIntentForecast(); + this.renderTacticalInitiativeChip(); + this.renderSortieOrderHud(); + if (previousPhase === 'moving' && this.selectedUnit) { + this.renderUnitDetail(this.selectedUnit, `${message}\n이동할 파란 칸을 선택하세요.`); + } + } + } + } + + private async playTacticalCommandCutIn( + source: TacticalCommandSource, + role: TacticalInitiativeRole, + actor: UnitData, + effectText: string + ) { + this.hideTacticalCommandCutIn(); + const token = this.tacticalCommandCutInToken; + const width = Math.min(900, Math.max(560, this.scale.width - 120)); + const height = 206; + const left = Math.floor((this.scale.width - width) / 2); + const top = Math.floor((this.scale.height - height) / 2); + const bounds = { x: left, y: top, width, height }; + const definition = sortieRoleEffects[role]; + const roleTone = definition?.tone ?? palette.gold; + const sourceTone = source === 'sortie-order' ? palette.gold : palette.blue; + const sourceLabel = source === 'sortie-order' ? '군령 발동' : '전술 주도권'; + const commandLabel = this.tacticalInitiativeCommandLabel(role); + const depth = 92; + + this.tacticalCommandCutInBounds = bounds; + this.tacticalCommandCutInCount += 1; + this.tacticalCommandCutInLast = { + source, + role, + actorId: actor.id, + label: commandLabel, + bounds: { ...bounds } + }; + + const dim = this.trackTacticalCommandCutInObject( + this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.7) + ); + dim.setOrigin(0); + dim.setDepth(depth); + dim.setInteractive(); + + const banner = this.trackTacticalCommandCutInObject(this.add.container(left, top)); + banner.setDepth(depth + 1); + const shadow = this.add.rectangle(8, 10, width, height, 0x020406, 0.72); + shadow.setOrigin(0); + const panel = this.add.rectangle(0, 0, width, height, palette.panelDark, 0.99); + panel.setOrigin(0); + panel.setStrokeStyle(3, sourceTone, 0.96); + const roleBand = this.add.rectangle(0, 0, 12, height, roleTone, 0.96); + roleBand.setOrigin(0); + const topRule = this.add.rectangle(12, 0, width - 12, 5, sourceTone, 0.82); + topRule.setOrigin(0); + + const portraitFrame = this.add.rectangle(92, height / 2, 144, 178, 0x0a0f14, 0.98); + portraitFrame.setStrokeStyle(2, roleTone, 0.86); + const portraitKey = this.combatPortraitKey(actor); + const portrait = portraitKey && this.textures.exists(portraitKey) + ? this.add.image(92, height / 2, portraitKey).setDisplaySize(132, 166) + : this.add.sprite(92, height / 2, this.unitActionTexture(actor), this.unitActionFrameIndex('south', 'strategy')) + .setDisplaySize(132, 132); + + const iconFrame = this.add.rectangle(202, 42, 54, 54, 0x0a0f14, 0.96); + iconFrame.setStrokeStyle(1, roleTone, 0.82); + const icon = this.createBattleUiIcon(202, 42, definition?.icon ?? 'leadership', 46); + const sourceText = this.add.text(242, 17, sourceLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: source === 'sortie-order' ? '#f4dfad' : '#d9f1ff', + fontStyle: '700', + letterSpacing: 2 + }); + const commandText = this.add.text(242, 39, commandLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '34px', + color: '#fff2b8', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 4 + }); + const actorText = this.add.text(178, 92, `${actor.name} · ${definition?.label ?? this.deploymentRoleLabel(role)}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f2e3bf', + fontStyle: '700' + }); + const divider = this.add.rectangle(178, 124, width - 204, 1, roleTone, 0.54); + divider.setOrigin(0); + const detailText = this.add.text(178, 139, effectText, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#c8d2dd', + fontStyle: '700', + wordWrap: { width: width - 208, useAdvancedWrap: true }, + maxLines: 2, + lineSpacing: 3 + }); + const roleMark = this.add.text(width - 24, 20, this.deploymentRoleLabel(role), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#9fb0bf', + fontStyle: '700' + }); + roleMark.setOrigin(1, 0); + + banner.add([ + shadow, + panel, + roleBand, + topRule, + portraitFrame, + portrait, + iconFrame, + icon, + sourceText, + commandText, + actorText, + divider, + detailText, + roleMark + ]); + + const reducedMotion = typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const enterDuration = this.scaledBattleDuration(150, 100); + const exitAt = this.scaledBattleDuration(700, 500); + const totalDuration = this.scaledBattleDuration(880, 650); + if (!reducedMotion) { + banner.setX(left + 28); + banner.setAlpha(0); + banner.setScale(0.985); + this.tweens.add({ + targets: banner, + x: left, + alpha: 1, + scale: 1, + duration: enterDuration, + ease: 'Cubic.easeOut' + }); + this.time.delayedCall(exitAt, () => { + if (token !== this.tacticalCommandCutInToken || !banner.active) { + return; + } + this.tweens.add({ + targets: banner, + x: left - 18, + alpha: 0, + duration: enterDuration, + ease: 'Cubic.easeIn' + }); + this.tweens.add({ targets: dim, alpha: 0, duration: enterDuration, ease: 'Sine.easeIn' }); + }); + } else { + this.time.delayedCall(Math.max(0, totalDuration - 90), () => { + if (token !== this.tacticalCommandCutInToken || !banner.active) { + return; + } + this.tweens.add({ targets: [banner, dim], alpha: 0, duration: 90, ease: 'Sine.easeIn' }); + }); + } + + await new Promise((resolve) => { + this.time.delayedCall(totalDuration, resolve); + }); + if (token !== this.tacticalCommandCutInToken) { + return false; + } + this.hideTacticalCommandCutIn(); + return true; + } + + private trackTacticalCommandCutInObject(object: T) { + this.tacticalCommandCutInObjects.push(object); + return object; + } + + private hideTacticalCommandCutIn() { + this.tacticalCommandCutInToken += 1; + this.tacticalCommandCutInObjects.forEach((object) => { + this.tweens?.killTweensOf(object); + if (object.active) { + object.destroy(); + } + }); + this.tacticalCommandCutInObjects = []; + this.tacticalCommandCutInBounds = undefined; } private hideTacticalInitiativePanel() { @@ -5135,6 +5367,9 @@ export class BattleScene extends Phaser.Scene { } private handleTacticalInitiativeKey(event: KeyboardEvent) { + if (this.phase === 'animating' || this.tacticalCommandCutInObjects.length > 0) { + return false; + } if (this.tacticalInitiativePanelObjects.length > 0) { const role = ({ '1': 'front', '2': 'flank', '3': 'support' } as const)[event.key as '1' | '2' | '3']; if (!role) { @@ -5142,7 +5377,7 @@ export class BattleScene extends Phaser.Scene { } event.preventDefault(); if (this.canUseTacticalInitiativeCommand(role)) { - this.activateTacticalInitiativeCommand(role); + void this.activateTacticalInitiativeCommand(role); } return true; } @@ -7792,6 +8027,17 @@ export class BattleScene extends Phaser.Scene { command: this.tacticalCommand ? { ...this.tacticalCommand } : null, panelOpen: this.tacticalInitiativePanelObjects.length > 0, panelBounds: this.tacticalInitiativePanelBounds ? { ...this.tacticalInitiativePanelBounds } : null, + cutIn: { + active: this.tacticalCommandCutInObjects.length > 0, + count: this.tacticalCommandCutInCount, + bounds: this.tacticalCommandCutInBounds ? { ...this.tacticalCommandCutInBounds } : null, + last: this.tacticalCommandCutInLast + ? { + ...this.tacticalCommandCutInLast, + bounds: { ...this.tacticalCommandCutInLast.bounds } + } + : null + }, cards }; } @@ -10668,6 +10914,7 @@ export class BattleScene extends Phaser.Scene { this.hideMapMenu(); this.hideTurnEndPrompt(); this.hideTacticalInitiativePanel(); + this.hideTacticalCommandCutIn(); this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); this.tacticalInitiativeChipObjects = []; this.hideBattleAlert(); @@ -14814,6 +15061,7 @@ export class BattleScene extends Phaser.Scene { this.hideCommandMenu(); this.hideTurnEndPrompt(); this.hideTacticalInitiativePanel(); + this.hideTacticalCommandCutIn(); this.hideCombatCutIn(); this.hideBattleResult(); this.resetSortieOrderHudState();