diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 73df4be..b67cd17 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -51,6 +51,77 @@ try { } }; assert(isValidBattleSaveState(validSortieStatsState, options), 'Expected sortie and counterplay contribution stats to pass validation.'); + const tacticalOptions = { + ...options, + expectedBattleId: 'second-battle-yellow-turban-pursuit', + allowTacticalCommand: true + }; + const validTacticalBaseState = { + ...validState, + battleId: tacticalOptions.expectedBattleId, + campaignStep: 'second-battle', + enemyUsableUseKeys: [], + battleStats: { + ...validState.battleStats, + 'liu-bei': { + ...validState.battleStats['liu-bei'], + intentDefeats: 3 + } + } + }; + const validFrontCommandState = { + ...validTacticalBaseState, + tacticalCommand: { + role: 'front', + actorId: 'guan-yu', + usedTurn: 2, + effectPending: true, + effectValue: 0, + statusesCleared: 0, + effectLost: false + } + }; + const validFlankCommandState = { + ...validFrontCommandState, + tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'flank', effectPending: false, effectValue: 4 } + }; + const validSupportCommandState = { + ...validFrontCommandState, + tacticalCommand: { + ...validFrontCommandState.tacticalCommand, + role: 'support', + actorId: 'liu-bei', + effectPending: false, + effectValue: 6, + statusesCleared: 1 + } + }; + const validLostFrontCommandState = { + ...validFrontCommandState, + units: validFrontCommandState.units.map((unit) => (unit.id === 'guan-yu' ? { ...unit, hp: 0 } : unit)), + tacticalCommand: { + ...validFrontCommandState.tacticalCommand, + effectPending: false, + effectLost: true + } + }; + assert(isValidBattleSaveState(validFrontCommandState, tacticalOptions), 'Expected pending front tactical command to pass validation.'); + assert(isValidBattleSaveState(validFlankCommandState, tacticalOptions), 'Expected resolved flank tactical command to pass validation.'); + assert(isValidBattleSaveState(validSupportCommandState, tacticalOptions), 'Expected immediate support tactical command to pass validation.'); + assert(isValidBattleSaveState(validLostFrontCommandState, tacticalOptions), 'Expected a lost tactical command to retain its result record.'); + assert(!isValidBattleSaveState(validFrontCommandState, options), 'Expected tactical commands to be rejected when disabled for the battle.'); + const tacticalRoleOptions = { + ...tacticalOptions, + validTacticalCommandRolesByActor: { 'guan-yu': 'front', 'liu-bei': 'support' } + }; + assert(isValidBattleSaveState(validFrontCommandState, tacticalRoleOptions), 'Expected the assigned front actor to pass validation.'); + assert(isValidBattleSaveState(validSupportCommandState, tacticalRoleOptions), 'Expected the assigned support actor to pass validation.'); + assert(!isValidBattleSaveState(validFlankCommandState, tacticalRoleOptions), 'Expected a role and actor assignment mismatch to be rejected.'); + const parsedTacticalCommand = parseBattleSaveState(JSON.stringify(validFrontCommandState), tacticalOptions)?.tacticalCommand; + assert( + JSON.stringify(parsedTacticalCommand) === JSON.stringify(validFrontCommandState.tacticalCommand), + 'Expected every tactical command field to round-trip.' + ); assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.'); assert(parseBattleSaveState('{', options) === undefined, 'Expected malformed JSON to be ignored.'); assert(parseBattleSaveState('', options) === undefined, 'Expected empty save payload to be ignored.'); @@ -162,6 +233,34 @@ try { assert(!isValidBattleSaveState(candidate, options), `Expected invalid battle save to be rejected: ${label}`); }); + const tacticalRejectedCases = [ + ['invalid tactical command shape', { tacticalCommand: 'front' }], + ['invalid tactical command role', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'reserve' } }], + ['unknown tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'ghost-unit' } }], + ['enemy tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'rebel-1' } }], + ['invalid tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 0 } }], + ['future tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 3 } }], + ['fractional tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 1.5 } }], + ['invalid tactical command pending flag', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectPending: 'yes' } }], + ['invalid tactical command lost flag', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectLost: 'yes' } }], + ['support tactical command cannot remain pending', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, effectPending: true } }], + ['invalid tactical command effect value', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectValue: -1 } }], + ['pending tactical command cannot have effect value', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectValue: 1 } }], + ['pending tactical command cannot be lost', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectLost: true } }], + ['front tactical command cannot clear statuses', { tacticalCommand: { ...validFlankCommandState.tacticalCommand, role: 'front', statusesCleared: 1 } }], + ['lost tactical command cannot retain effect value', { tacticalCommand: { ...validFlankCommandState.tacticalCommand, effectLost: true } }], + ['support tactical command heal exceeds limit', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, effectValue: 7 } }], + ['support tactical command statuses exceed limit', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, statusesCleared: 3 } }], + ['invalid tactical command cleared status count', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, statusesCleared: 1.5 } }], + ['pending tactical command actor defeated', { units: validFrontCommandState.units.map((unit) => (unit.id === 'guan-yu' ? { ...unit, hp: 0 } : unit)) }], + ['tactical command without enough counterplay', { battleStats: validState.battleStats }] + ]; + + tacticalRejectedCases.forEach(([label, patch]) => { + const candidate = { ...validFrontCommandState, ...patch }; + assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid tactical command save to be rejected: ${label}`); + }); + assert( !isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1', 'zhang-fei']) }), 'Expected save missing a current battle unit to be rejected.' diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 877f406..534d67e 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -49,6 +49,7 @@ import { loadCampaignState, markCampaignStep, normalizeCampaignSortieItemAssignments, + readCampaignSaveState, saveCampaignState, setFirstBattleReport, type CampaignRewardSnapshot, @@ -60,6 +61,8 @@ import { normalizeBattleSaveSlot, parseBattleSaveState, type BattleSaveState, + type BattleSaveTacticalCommandRole, + type BattleSaveTacticalCommandState, type BattleSaveUnitStats } from '../state/battleSaveState'; import { palette } from '../ui/palette'; @@ -1139,6 +1142,11 @@ const battleSpeedStorageKey = 'heros-web:battle-speed'; const firstPursuitRoleEffectBattleId = 'second-battle-yellow-turban-pursuit'; const firstPursuitBrotherUnitIds = ['liu-bei', 'guan-yu', 'zhang-fei'] as const; const firstPursuitRoleOrder: SortieFormationRole[] = ['front', 'flank', 'support']; +const tacticalInitiativeThreshold = 3; +const tacticalInitiativeFrontReduction = 50; +const tacticalInitiativeFlankDamageBonus = 30; +const tacticalInitiativeFlankHitBonus = 20; +const tacticalInitiativeSupportHeal = 6; const firstPursuitRoleEffects: Partial(); private enemyIntentVisuals: EnemyIntentVisual[] = []; private intentCounterplayEvents: IntentCounterplayEvent[] = []; + private tacticalCommand?: BattleSaveTacticalCommandState; private battleLog: string[] = []; private enemyHomeTiles = new Map(); private battleStats = new Map(); @@ -3234,7 +3264,9 @@ export class BattleScene extends Phaser.Scene { this.battleStatuses.clear(); this.applyDebugStatusUiSetup(); this.enemyHomeTiles = this.createEnemyHomeTiles(); + this.tacticalCommand = undefined; this.battleStats = this.createBattleStats(); + this.applyDebugTacticalInitiativeSetup(); this.enemyUsableUseKeys.clear(); this.triggeredBattleEvents.clear(); this.battleLog = []; @@ -3267,6 +3299,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.tacticalInitiativePanelObjects.length > 0) { + return; + } if (this.turnPromptObjects.length > 0) { this.activateTurnPromptPrimaryAction(); return; @@ -3286,6 +3321,12 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.tacticalInitiativePanelObjects.length > 0) { + soundDirector.playSelect(); + this.hideTacticalInitiativePanel(); + return; + } + if (this.mapMenuObjects.length > 0) { soundDirector.playSelect(); this.hideMapMenu(); @@ -3314,7 +3355,10 @@ export class BattleScene extends Phaser.Scene { } }); this.input.keyboard?.on('keydown', (event: KeyboardEvent) => { - this.handleSaveSlotPanelKey(event); + if (this.handleSaveSlotPanelKey(event)) { + return; + } + this.handleTacticalInitiativeKey(event); }); this.input.keyboard?.on('keydown-SPACE', (event: KeyboardEvent) => { if (this.activeFaction !== 'enemy' || this.fastForwardHeld) { @@ -3332,6 +3376,9 @@ export class BattleScene extends Phaser.Scene { this.renderBattleSpeedControl(); }); this.input.keyboard?.on('keydown-PERIOD', () => { + if (this.tacticalInitiativePanelObjects.length > 0) { + return; + } this.cycleBattleSpeed(); }); this.installDebugHotkeys(); @@ -3576,6 +3623,21 @@ export class BattleScene extends Phaser.Scene { } } + private applyDebugTacticalInitiativeSetup() { + if ( + !this.isFirstPursuitRoleEffectBattle() || + !this.debugToolsEnabled() || + typeof window === 'undefined' || + new URLSearchParams(window.location.search).get('debugInitiativeReady') !== '1' + ) { + return; + } + const actor = this.firstPursuitRoleAssignments()[0]?.unit; + if (actor) { + this.statsFor(actor.id).intentDefeats = tacticalInitiativeThreshold; + } + } + private debugBattleSetupKey() { if (!this.debugToolsEnabled() || typeof window === 'undefined') { return undefined; @@ -3757,6 +3819,174 @@ export class BattleScene extends Phaser.Scene { return !ignoreSortieEffects && usable.effect === 'heal' && this.firstPursuitRoleForUnit(user) === 'support' ? 2 : 0; } + private tacticalInitiativeCounterplayCount() { + if (!this.isFirstPursuitRoleEffectBattle()) { + return 0; + } + return this.firstPursuitRoleAssignments().reduce( + (total, { unit }) => total + this.intentCounterplayTotal(this.statsFor(unit.id)), + 0 + ); + } + + private tacticalInitiativePoints() { + return this.tacticalCommand ? 0 : Math.min(tacticalInitiativeThreshold, this.tacticalInitiativeCounterplayCount()); + } + + private tacticalInitiativeReady() { + return ( + this.isFirstPursuitRoleEffectBattle() && + !this.tacticalCommand && + this.tacticalInitiativePoints() >= tacticalInitiativeThreshold + ); + } + + private tacticalInitiativeActor(role: TacticalInitiativeRole) { + return this.firstPursuitRoleAssignments().find((entry) => entry.role === role && entry.unit.hp > 0)?.unit; + } + + private tacticalInitiativeActorRolesForCampaign(campaign?: CampaignState) { + if (!this.isFirstPursuitRoleEffectBattle() || !campaign) { + return undefined; + } + const assignments = { + ...this.scenarioRecommendedSortieFormationAssignments(), + ...campaign.sortieFormationAssignments + }; + return firstPursuitBrotherUnitIds.reduce>((roles, unitId) => { + const unit = battleUnits.find((candidate) => candidate.id === unitId && candidate.faction === 'ally'); + if (!unit) { + return roles; + } + const role = assignments[unit.id] ?? this.recommendedSortieFormationRole(unit); + if (role === 'front' || role === 'flank' || role === 'support') { + roles[unit.id] = role; + } + return roles; + }, {}); + } + + private tacticalInitiativeCommandActorMatchesRole(command: BattleSaveTacticalCommandState) { + const actor = battleUnits.find((unit) => unit.id === command.actorId && unit.faction === 'ally'); + return Boolean(actor && this.firstPursuitRoleForUnit(actor) === command.role); + } + + private tacticalInitiativeCommandLabel(role: TacticalInitiativeRole, compact = false) { + if (role === 'front') { + return compact ? '철벽' : '철벽 호령'; + } + if (role === 'flank') { + return compact ? '맹진' : '맹진 호령'; + } + return '재정비'; + } + + private tacticalInitiativeCommandDescription(role: TacticalInitiativeRole) { + if (role === 'front') { + return `전열 담당의 다음 적 일반 공격 피해 ${tacticalInitiativeFrontReduction}% 감소`; + } + if (role === 'flank') { + return `돌파 담당의 다음 일반 공격 피해 +${tacticalInitiativeFlankDamageBonus}% · 명중 +${tacticalInitiativeFlankHitBonus}%`; + } + return `병력 비율 최저 아군 최대 +${tacticalInitiativeSupportHeal} · 화상·혼란 해제`; + } + + private tacticalInitiativeSupportTarget() { + return battleUnits + .filter((unit) => unit.faction === 'ally' && unit.hp > 0) + .map((unit) => ({ + unit, + hpRatio: unit.maxHp > 0 ? unit.hp / unit.maxHp : 1, + statusCount: this.battleStatuses.get(unit.id)?.length ?? 0 + })) + .filter(({ unit, statusCount }) => unit.hp < unit.maxHp || statusCount > 0) + .sort((left, right) => left.hpRatio - right.hpRatio || right.statusCount - left.statusCount)[0]?.unit; + } + + private tacticalInitiativeSupportPreview(target: UnitData) { + const healAmount = Math.min(tacticalInitiativeSupportHeal, target.maxHp - target.hp); + const statusesCleared = this.battleStatuses.get(target.id)?.length ?? 0; + const parts = [`병력 ${target.hp}→${target.hp + healAmount}`]; + if (statusesCleared > 0) { + parts.push(`상태 ${statusesCleared}개 해제`); + } + return parts.join(' · '); + } + + private canUseTacticalInitiativeCommand(role: TacticalInitiativeRole) { + if (!this.tacticalInitiativeReady() || !this.tacticalInitiativeActor(role)) { + return false; + } + return role !== 'support' || Boolean(this.tacticalInitiativeSupportTarget()); + } + + private tacticalInitiativeCombatEffect( + attacker: UnitData, + defender: UnitData, + action: DamageCommand, + isCounter = false, + ignoreInitiative = false + ): TacticalInitiativeCombatEffect { + const effect: TacticalInitiativeCombatEffect = { + damageBonus: 0, + damageReduction: 0, + hitBonus: 0, + labels: [] + }; + const command = this.tacticalCommand; + if ( + !this.isFirstPursuitRoleEffectBattle() || + ignoreInitiative || + !command?.effectPending || + !this.tacticalInitiativeCommandActorMatchesRole(command) || + isCounter || + action !== 'attack' + ) { + return effect; + } + if (command.role === 'front' && attacker.faction === 'enemy' && defender.id === command.actorId) { + effect.role = 'front'; + effect.damageReduction = tacticalInitiativeFrontReduction; + effect.labels.push(`철벽 호령 -${tacticalInitiativeFrontReduction}%`); + } + if (command.role === 'flank' && attacker.faction === 'ally' && attacker.id === command.actorId) { + effect.role = 'flank'; + effect.damageBonus = tacticalInitiativeFlankDamageBonus; + effect.hitBonus = tacticalInitiativeFlankHitBonus; + effect.labels.push(`맹진 호령 +${tacticalInitiativeFlankDamageBonus}% · 명중 +${tacticalInitiativeFlankHitBonus}%`); + } + return effect; + } + + private resolveTacticalInitiativeCombatEffect( + preview: CombatPreview, + hit: boolean, + bonusDamage: number, + preventedDamage: number + ) { + const command = this.tacticalCommand; + if (!hit || !command?.effectPending || preview.initiativeRole !== command.role) { + return; + } + command.effectPending = false; + command.effectLost = false; + command.effectValue += command.role === 'front' ? preventedDamage : bonusDamage; + this.renderTacticalInitiativeChip(); + } + + private losePendingTacticalInitiativeEffect(unit: UnitData) { + const command = this.tacticalCommand; + if (!command?.effectPending || command.actorId !== unit.id) { + return; + } + command.effectPending = false; + command.effectLost = true; + command.effectValue = 0; + const message = `전술 명령 · ${this.tacticalInitiativeCommandLabel(command.role)} 효과 상실 · ${unit.name} 퇴각`; + this.pushBattleLog(message); + this.renderTacticalInitiativeChip(); + } + private firstPursuitContributionLine() { if (!this.isFirstPursuitRoleEffectBattle()) { return undefined; @@ -3793,6 +4023,25 @@ export class BattleScene extends Phaser.Scene { ); } + private tacticalInitiativeResultText() { + const command = this.tacticalCommand; + if (!command) { + return this.tacticalInitiativeReady() ? '명령 미사용(준비)' : '명령 미사용'; + } + const actor = this.unitName(command.actorId); + const label = this.tacticalInitiativeCommandLabel(command.role, true); + const effect = command.effectLost + ? '상실' + : command.effectPending + ? '대기' + : command.role === 'front' + ? `-${command.effectValue}` + : command.role === 'flank' + ? `+${command.effectValue}` + : `회복${command.effectValue}${command.statusesCleared > 0 ? `/해제${command.statusesCleared}` : ''}`; + return `명령 ${label}(${actor}·${command.usedTurn}턴·${effect})`; + } + private firstPursuitUnitContributionLine(unit: UnitData) { const role = this.firstPursuitRoleForUnit(unit); if (!role) { @@ -4057,6 +4306,391 @@ export class BattleScene extends Phaser.Scene { this.battleSpeedControlObjects.push(button, text); } + private renderTacticalInitiativeChip() { + this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); + this.tacticalInitiativeChipObjects = []; + if ( + !this.isFirstPursuitRoleEffectBattle() || + this.phase === 'deployment' || + this.phase === 'resolved' || + this.battleOutcome + ) { + return; + } + + const { panelX, panelY, panelWidth } = this.layout; + const width = 82; + const height = 30; + const left = panelX + panelWidth - width - 24; + const top = panelY + 26; + const points = this.tacticalInitiativePoints(); + const ready = this.tacticalInitiativeReady(); + const used = Boolean(this.tacticalCommand); + const pending = Boolean(this.tacticalCommand?.effectPending); + const lost = Boolean(this.tacticalCommand?.effectLost); + const diamonds = Array.from({ length: tacticalInitiativeThreshold }, (_, index) => index < points ? '◆' : '◇').join(''); + const label = lost + ? '명령 상실' + : pending + ? this.tacticalCommand?.role === 'front' + ? '철벽 대기' + : '맹진 대기' + : used + ? '명령 완료' + : ready + ? '전술 명령' + : `주도 ${diamonds}`; + const tone = lost ? 0xc46b55 : pending ? palette.gold : used ? 0x647485 : ready ? palette.gold : palette.blue; + const canOpen = + this.activeFaction === 'ally' && + (this.phase === 'idle' || this.phase === 'moving') && + this.saveSlotPanelObjects.length === 0 && + this.turnPromptObjects.length === 0 && + this.alertObjects.length === 0 && + this.battleEventObjects.length === 0 && + this.combatCutInObjects.length === 0 && + this.resultObjects.length === 0; + + const highlighted = ready || pending; + const button = this.add.rectangle(left, top, width, height, highlighted ? 0x3a2d14 : 0x17232e, 0.98); + button.setOrigin(0); + button.setDepth(16); + button.setStrokeStyle(1, tone, highlighted ? 1 : 0.82); + const text = this.add.text(left + width / 2, top + height / 2 - 1, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: used || ready ? '13px' : '12px', + color: lost ? '#ffb6a6' : pending || ready ? '#fff2b8' : used ? '#aeb7c2' : '#d9f1ff', + fontStyle: '700' + }); + text.setOrigin(0.5); + text.setDepth(17); + + if (canOpen) { + const open = () => { + this.suppressNextLeftClick = true; + this.showTacticalInitiativePanel(); + }; + button.setInteractive({ useHandCursor: true }); + text.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(highlighted ? 0x594417 : 0x243746, 1)); + button.on('pointerout', () => button.setFillStyle(highlighted ? 0x3a2d14 : 0x17232e, 0.98)); + button.on('pointerdown', open); + text.on('pointerdown', open); + } + + this.tacticalInitiativeChipObjects.push(button, text); + } + + private showTacticalInitiativePanel() { + if ( + !this.isFirstPursuitRoleEffectBattle() || + this.activeFaction !== 'ally' || + (this.phase !== 'idle' && this.phase !== 'moving') || + this.saveSlotPanelObjects.length > 0 || + this.turnPromptObjects.length > 0 || + this.alertObjects.length > 0 || + this.battleEventObjects.length > 0 || + this.combatCutInObjects.length > 0 || + this.resultObjects.length > 0 || + this.battleOutcome + ) { + return; + } + this.hideMapMenu(); + this.hideTurnEndPrompt(); + this.clearPointerFeedback(); + this.hideTacticalInitiativePanel(); + + const panelWidth = 612; + const panelHeight = 248; + const left = Math.floor(this.layout.mapX + (this.layout.mapWidth - panelWidth) / 2); + const top = Math.floor(this.layout.mapY + (this.layout.mapHeight - panelHeight) / 2); + const depth = 48; + const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.58); + shade.setOrigin(0); + shade.setDepth(depth); + shade.setInteractive(); + shade.on('pointerdown', () => { + this.suppressNextLeftClick = true; + soundDirector.playSelect(); + this.hideTacticalInitiativePanel(); + }); + this.trackTacticalInitiativePanelObject(shade); + + const panel = this.add.rectangle(left, top, panelWidth, panelHeight, 0x101821, 0.99); + panel.setOrigin(0); + panel.setDepth(depth + 1); + panel.setStrokeStyle(2, this.tacticalInitiativeReady() ? palette.gold : 0x647485, 0.92); + panel.setInteractive(); + this.trackTacticalInitiativePanelObject(panel); + + const panelTitle = this.tacticalCommand + ? '전술 명령 · 전황 기록' + : this.tacticalInitiativeReady() + ? '전술 명령 · 하나 선택' + : '전술 명령 · 주도권'; + const title = this.add.text(left + 20, top + 14, panelTitle, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '22px', + color: '#f4dfad', + fontStyle: '700' + }); + title.setDepth(depth + 2); + this.trackTacticalInitiativePanelObject(title); + + const subtitleText = this.tacticalCommand?.effectLost + ? `효과 상실 · ${this.tacticalInitiativeCommandLabel(this.tacticalCommand.role)} · ${this.unitName(this.tacticalCommand.actorId)}` + : this.tacticalCommand?.effectPending + ? `발동 대기 · ${this.tacticalInitiativeCommandLabel(this.tacticalCommand.role)} · ${this.unitName(this.tacticalCommand.actorId)}` + : this.tacticalCommand + ? `사용 완료 · ${this.tacticalInitiativeCommandLabel(this.tacticalCommand.role)} · ${this.unitName(this.tacticalCommand.actorId)}` + : this.tacticalInitiativeReady() + ? `주도권 ${tacticalInitiativeThreshold}/${tacticalInitiativeThreshold} · 전투당 한 번` + : `주도권 ${this.tacticalInitiativePoints()}/${tacticalInitiativeThreshold} · 파훼 ${tacticalInitiativeThreshold - this.tacticalInitiativePoints()}회 더 필요`; + const subtitle = this.add.text(left + panelWidth - 20, top + 19, subtitleText, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: this.tacticalInitiativeReady() ? '#fff2b8' : '#9fb0bf', + fontStyle: '700' + }); + subtitle.setOrigin(1, 0); + subtitle.setDepth(depth + 2); + this.trackTacticalInitiativePanelObject(subtitle); + + (['front', 'flank', 'support'] as TacticalInitiativeRole[]).forEach((role, index) => { + this.renderTacticalInitiativeCard(role, left + 18 + index * 195, top + 62, 185, 142, depth + 2); + }); + + const footerHint = !this.tacticalCommand && this.tacticalInitiativeReady() + ? '1/2/3 선택 · Esc/우클릭 취소' + : 'Esc/우클릭 닫기'; + const cancel = this.add.text(left + panelWidth / 2, top + panelHeight - 18, footerHint, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#9fb0bf', + fontStyle: '700' + }); + cancel.setOrigin(0.5); + cancel.setDepth(depth + 2); + cancel.setInteractive({ useHandCursor: true }); + cancel.on('pointerdown', () => { + this.suppressNextLeftClick = true; + soundDirector.playSelect(); + this.hideTacticalInitiativePanel(); + }); + this.trackTacticalInitiativePanelObject(cancel); + } + + private renderTacticalInitiativeCard( + role: TacticalInitiativeRole, + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const selected = this.tacticalCommand?.role === role; + const assignedActor = this.tacticalInitiativeActor(role); + const recordedActor = selected ? battleUnits.find((unit) => unit.id === this.tacticalCommand?.actorId) : undefined; + const actor = recordedActor ?? assignedActor; + const available = this.canUseTacticalInitiativeCommand(role); + const definition = firstPursuitRoleEffects[role]; + const supportTarget = role === 'support' ? this.tacticalInitiativeSupportTarget() : undefined; + const bg = this.add.rectangle(x, y, width, height, selected ? 0x3a2d14 : available ? 0x172b35 : 0x111820, 0.98); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, selected ? palette.gold : available ? definition?.tone ?? palette.blue : 0x53606c, selected ? 0.96 : 0.68); + this.trackTacticalInitiativePanelObject(bg); + + const iconFrame = this.add.rectangle(x + 29, y + 29, 44, 44, 0x0a0f14, 0.94); + iconFrame.setDepth(depth + 1); + iconFrame.setStrokeStyle(1, definition?.tone ?? 0x647485, 0.72); + this.trackTacticalInitiativePanelObject(iconFrame); + const icon = this.createBattleUiIcon(x + 29, y + 29, definition?.icon ?? 'leadership', 38); + icon.setDepth(depth + 2); + this.trackTacticalInitiativePanelObject(icon); + + const roleText = this.add.text(x + 58, y + 10, this.deploymentRoleLabel(role), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#9fb0bf', + fontStyle: '700' + }); + roleText.setDepth(depth + 1); + this.trackTacticalInitiativePanelObject(roleText); + const commandText = this.add.text(x + 58, y + 27, this.tacticalInitiativeCommandLabel(role), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: available || selected ? '#f4dfad' : '#858d96', + fontStyle: '700' + }); + commandText.setDepth(depth + 1); + this.trackTacticalInitiativePanelObject(commandText); + const actorLabel = actor + ? role === 'support' && supportTarget && !selected + ? `담당 ${actor.name} · 대상 ${supportTarget.name}` + : `담당 ${actor.name}` + : '담당 없음 · 사용 불가'; + const actorText = this.add.text(x + 12, y + 56, actorLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: actor ? '#d4dce6' : '#ffb6a6', + fontStyle: '700' + }); + actorText.setDepth(depth + 1); + this.trackTacticalInitiativePanelObject(actorText); + const descriptionText = role === 'support' && supportTarget && !selected + ? this.tacticalInitiativeSupportPreview(supportTarget) + : this.tacticalInitiativeCommandDescription(role); + const description = this.add.text(x + 12, y + 79, descriptionText, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#aeb7c2', + wordWrap: { width: width - 24, useAdvancedWrap: true }, + lineSpacing: 2, + maxLines: 2 + }); + description.setDepth(depth + 1); + this.trackTacticalInitiativePanelObject(description); + + const status = selected + ? this.tacticalCommand?.effectLost + ? '효과 상실' + : this.tacticalCommand?.effectPending + ? role === 'front' + ? '피격 대기' + : '공격 대기' + : '선택 완료' + : this.tacticalCommand + ? '선택 종료' + : !this.tacticalInitiativeReady() + ? '주도권 부족' + : !actor + ? '담당 퇴각' + : role === 'support' && !supportTarget + ? '회복 대상 없음' + : '선택'; + const statusText = this.add.text(x + width - 12, y + height - 12, status, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: selected ? (this.tacticalCommand?.effectLost ? '#ffb6a6' : '#fff2b8') : available ? '#a8ffd0' : '#8c959f', + fontStyle: '700' + }); + statusText.setOrigin(1, 1); + statusText.setDepth(depth + 1); + this.trackTacticalInitiativePanelObject(statusText); + + if (available) { + const select = () => { + this.suppressNextLeftClick = true; + this.activateTacticalInitiativeCommand(role); + }; + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setFillStyle(0x254451, 1)); + bg.on('pointerout', () => bg.setFillStyle(0x172b35, 0.98)); + bg.on('pointerdown', select); + statusText.setInteractive({ useHandCursor: true }); + statusText.on('pointerdown', select); + } + } + + private activateTacticalInitiativeCommand(role: TacticalInitiativeRole) { + const actor = this.tacticalInitiativeActor(role); + if (!actor || !this.canUseTacticalInitiativeCommand(role)) { + return; + } + const supportTarget = role === 'support' ? this.tacticalInitiativeSupportTarget() : undefined; + if (role === 'support' && !supportTarget) { + return; + } + + this.tacticalCommand = { + role, + actorId: actor.id, + usedTurn: this.turnNumber, + effectPending: role !== 'support', + effectValue: 0, + statusesCleared: 0, + effectLost: false + }; + + let effectText = this.tacticalInitiativeCommandDescription(role); + if (role === 'support' && supportTarget) { + const healAmount = Math.min(tacticalInitiativeSupportHeal, supportTarget.maxHp - supportTarget.hp); + const statusesCleared = this.battleStatuses.get(supportTarget.id)?.length ?? 0; + supportTarget.hp += healAmount; + this.battleStatuses.delete(supportTarget.id); + this.tacticalCommand.effectValue = healAmount; + this.tacticalCommand.statusesCleared = statusesCleared; + this.tacticalCommand.effectPending = false; + this.syncUnitMotion(supportTarget); + this.updateMiniMap(); + 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 message = `전술 명령 · ${this.tacticalInitiativeCommandLabel(role)} · ${actor.name}\n${effectText}`; + this.hideTacticalInitiativePanel(); + soundDirector.playStrategyPulse(); + this.pushBattleLog(message); + this.renderTacticalInitiativeChip(); + this.refreshEnemyIntentForecast(); + if (this.phase === 'moving' && this.selectedUnit) { + this.renderUnitDetail(this.selectedUnit, `${message}\n이동할 파란 칸을 선택하세요.`); + } else { + this.renderSituationPanel(message); + } + } + + private hideTacticalInitiativePanel() { + this.tacticalInitiativePanelObjects.forEach((object) => object.destroy()); + this.tacticalInitiativePanelObjects = []; + } + + private trackTacticalInitiativePanelObject(object: T) { + this.tacticalInitiativePanelObjects.push(object); + return object; + } + + private handleTacticalInitiativeKey(event: KeyboardEvent) { + if (this.tacticalInitiativePanelObjects.length > 0) { + const role = ({ '1': 'front', '2': 'flank', '3': 'support' } as const)[event.key as '1' | '2' | '3']; + if (!role) { + return false; + } + event.preventDefault(); + if (this.canUseTacticalInitiativeCommand(role)) { + this.activateTacticalInitiativeCommand(role); + } + return true; + } + + if (event.key.toLowerCase() !== 't' || !this.isFirstPursuitRoleEffectBattle()) { + return false; + } + event.preventDefault(); + this.showTacticalInitiativePanel(); + return true; + } + private isBattleSpeed(value: string | null): value is BattleSpeed { return battleSpeedOptions.includes(value as BattleSpeed); } @@ -4229,7 +4863,14 @@ export class BattleScene extends Phaser.Scene { } private handleMiniMapPointer(pointer: Phaser.Input.Pointer) { - if (!this.miniMapLayout || this.battleOutcome || this.phase === 'animating' || this.saveSlotPanelObjects.length > 0 || this.turnPromptObjects.length > 0) { + if ( + !this.miniMapLayout || + this.battleOutcome || + this.phase === 'animating' || + this.tacticalInitiativePanelObjects.length > 0 || + this.saveSlotPanelObjects.length > 0 || + this.turnPromptObjects.length > 0 + ) { return; } @@ -4254,6 +4895,7 @@ export class BattleScene extends Phaser.Scene { this.phase === 'resolved' || this.commandMenuObjects.length > 0 || this.mapMenuObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || this.saveSlotPanelObjects.length > 0 || this.turnPromptObjects.length > 0 || this.combatCutInObjects.length > 0 || @@ -4535,6 +5177,7 @@ export class BattleScene extends Phaser.Scene { this.phase === 'resolved' || this.commandMenuObjects.length > 0 || this.mapMenuObjects.length > 0 || + this.tacticalInitiativePanelObjects.length > 0 || this.saveSlotPanelObjects.length > 0 || this.turnPromptObjects.length > 0 || this.combatCutInObjects.length > 0 || @@ -5235,6 +5878,7 @@ export class BattleScene extends Phaser.Scene { private showPreBattleDeployment() { this.phase = 'deployment'; + this.renderTacticalInitiativeChip(); this.activeFaction = 'ally'; this.selectedUnit = undefined; this.pendingMove = undefined; @@ -5942,6 +6586,7 @@ export class BattleScene extends Phaser.Scene { this.selectedUnit = undefined; this.pendingMove = undefined; this.phase = 'idle'; + this.renderTacticalInitiativeChip(); this.refreshUnitLegibilityStyles(); this.renderRosterPanel('ally', '출전 위치 확정. 행동할 장수를 선택하세요.'); this.refreshEnemyIntentForecast(); @@ -6486,17 +7131,28 @@ export class BattleScene extends Phaser.Scene { return undefined; } + const wasInitiativeReady = this.tacticalInitiativeReady(); const stats = this.statsFor(actor.id); const defeats = events.filter((event) => event.kind === 'defeat').length; const lineBreaks = events.filter((event) => event.kind === 'line-break').length; stats.intentDefeats += defeats; stats.intentLineBreaks += lineBreaks; this.intentCounterplayEvents = [...this.intentCounterplayEvents, ...events].slice(-16); + const initiativeReadied = !wasInitiativeReady && this.tacticalInitiativeReady(); + this.renderTacticalInitiativeChip(); - const detail = [defeats > 0 ? `선제 ${defeats}` : '', lineBreaks > 0 ? `차단 ${lineBreaks}` : ''] + const detail = [ + defeats > 0 ? `선제 ${defeats}` : '', + lineBreaks > 0 ? `차단 ${lineBreaks}` : '', + initiativeReadied ? '주도권 준비' : '' + ] .filter(Boolean) .join(' · '); - const compactDetail = [defeats > 0 ? `선${defeats}` : '', lineBreaks > 0 ? `차${lineBreaks}` : ''] + const compactDetail = [ + defeats > 0 ? `선${defeats}` : '', + lineBreaks > 0 ? `차${lineBreaks}` : '', + initiativeReadied ? '명령 준비' : '' + ] .filter(Boolean) .join('/'); soundDirector.playGrowthTick(); @@ -6517,8 +7173,9 @@ export class BattleScene extends Phaser.Scene { attacker.faction !== 'enemy' || defender.faction !== 'ally' ) { - return; + return false; } + const wasInitiativeReady = this.tacticalInitiativeReady(); this.statsFor(defender.id).intentGuardSuccesses += 1; this.intentCounterplayEvents = [ ...this.intentCounterplayEvents, @@ -6530,6 +7187,9 @@ export class BattleScene extends Phaser.Scene { preventedDamage } ].slice(-16); + const initiativeReadied = !wasInitiativeReady && this.tacticalInitiativeReady(); + this.renderTacticalInitiativeChip(); + return initiativeReadied; } private isIntentGuardCounterplayResult(result: CombatResult) { @@ -6593,6 +7253,18 @@ export class BattleScene extends Phaser.Scene { }; } + private tacticalInitiativeDebugState() { + return { + active: this.isFirstPursuitRoleEffectBattle(), + points: this.tacticalInitiativePoints(), + threshold: tacticalInitiativeThreshold, + ready: this.tacticalInitiativeReady(), + totalCounterplays: this.tacticalInitiativeCounterplayCount(), + command: this.tacticalCommand ? { ...this.tacticalCommand } : null, + panelOpen: this.tacticalInitiativePanelObjects.length > 0 + }; + } + private commandAccentColor(command: BattleCommand) { if (command === 'attack') { return 0xd8b15f; @@ -8544,6 +9216,17 @@ export class BattleScene extends Phaser.Scene { }); } } + if (preview.initiativeRole && preview.initiativeEffectLabels.length > 0) { + const definition = firstPursuitRoleEffects[preview.initiativeRole]; + summaries.push({ + icon: definition?.icon ?? 'leadership', + label: this.tacticalInitiativeCommandLabel(preview.initiativeRole), + value: preview.initiativeRole === 'front' + ? `피해 -${tacticalInitiativeFrontReduction}%` + : `피해 +${tacticalInitiativeFlankDamageBonus}% · 명중 +${tacticalInitiativeFlankHitBonus}%`, + tone: palette.gold + }); + } this.unitStatuses(preview.attacker).forEach((status) => { summaries.push({ icon: this.statusEffectIcon(status.kind), @@ -9304,6 +9987,7 @@ export class BattleScene extends Phaser.Scene { this.hideCommandMenu(); this.hideTurnEndPrompt(); this.applyActedStyle(unit); + this.renderTacticalInitiativeChip(); soundDirector.playSelect(); this.pushBattleLog(resolvedMessage); this.checkBattleEvents(); @@ -9427,6 +10111,9 @@ export class BattleScene extends Phaser.Scene { this.hideCommandMenu(); this.hideMapMenu(); this.hideTurnEndPrompt(); + this.hideTacticalInitiativePanel(); + this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); + this.tacticalInitiativeChipObjects = []; this.hideBattleAlert(); this.hideBattleEventBanner(); this.refreshUnitLegibilityStyles(); @@ -9530,7 +10217,7 @@ export class BattleScene extends Phaser.Scene { const contributionSummary = this.trackResultObject(this.add.text( left + panelWidth - 44, top + 358, - `전술 기여 · 파훼 ${totals.counterplays} · 추가 피해 +${totals.bonusDamage} · 피해 감소 ${totals.preventedDamage} · 회복 +${totals.bonusHealing} · 2칸 공명 ${totals.extendedBondTriggers}회`, + `전술 기여 · 파훼 ${totals.counterplays} · ${this.tacticalInitiativeResultText()} · 편성 +${totals.bonusDamage}/-${totals.preventedDamage}/회복${totals.bonusHealing} · 공명 ${totals.extendedBondTriggers}회`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', @@ -10765,7 +11452,8 @@ export class BattleScene extends Phaser.Scene { action: DamageCommand, usable?: BattleUsable, isCounter = false, - ignoreSortieEffects = false + ignoreSortieEffects = false, + ignoreTacticalInitiative = false ): CombatPreview { const terrain = battleMap.terrain[defender.y][defender.x]; const terrainRule = getTerrainRule(terrain); @@ -10774,6 +11462,13 @@ export class BattleScene extends Phaser.Scene { const bondBonus = this.activeBondCombatBonus(attacker, defender, action, isCounter); const equipmentEffect = this.combatEquipmentEffect(attacker, defender, action, usable, bondBonus); const sortieEffect = this.firstPursuitRoleCombatEffect(attacker, defender, action, isCounter, ignoreSortieEffects); + const initiativeEffect = this.tacticalInitiativeCombatEffect( + attacker, + defender, + action, + isCounter, + ignoreTacticalInitiative + ); const totalDamageBonusWithoutSortie = bondBonus.damageBonus + equipmentEffect.damageBonus - @@ -10796,12 +11491,41 @@ export class BattleScene extends Phaser.Scene { const baselineDamage = isCounter ? Phaser.Math.Clamp(Math.round(baselineBaseDamage * 0.45), 1, defender.maxHp) : baselineBaseDamage; - let damage = isCounter ? Phaser.Math.Clamp(Math.round(baseDamage * 0.45), 1, defender.maxHp) : baseDamage; + let damageBeforeInitiative = isCounter ? Phaser.Math.Clamp(Math.round(baseDamage * 0.45), 1, defender.maxHp) : baseDamage; if (sortieEffect.damageBonus > 0 && baselineDamage < defender.maxHp) { - damage = Math.max(damage, baselineDamage + 1); + damageBeforeInitiative = Math.max(damageBeforeInitiative, baselineDamage + 1); } if (sortieEffect.damageReduction > 0 && baselineDamage > 1) { - damage = Math.min(damage, baselineDamage - 1); + damageBeforeInitiative = Math.min(damageBeforeInitiative, baselineDamage - 1); + } + let damage = damageBeforeInitiative; + if (initiativeEffect.damageBonus > 0) { + const initiativeUnreducedDamage = Phaser.Math.Clamp( + Math.round( + (attackPower - defensePower) * + (1 + (totalDamageBonusBeforeSortieReduction + initiativeEffect.damageBonus) / 100) + ), + 3, + defender.maxHp + ); + const initiativeAfterSortieReduction = sortieEffect.damageReduction > 0 && initiativeUnreducedDamage > 1 + ? Math.min( + initiativeUnreducedDamage - 1, + Math.max(1, Math.round(initiativeUnreducedDamage * (1 - sortieEffect.damageReduction / 100))) + ) + : initiativeUnreducedDamage; + const initiativeDamage = isCounter + ? Phaser.Math.Clamp(Math.round(initiativeAfterSortieReduction * 0.45), 1, defender.maxHp) + : initiativeAfterSortieReduction; + damage = damageBeforeInitiative < defender.maxHp + ? Math.max(initiativeDamage, damageBeforeInitiative + 1) + : initiativeDamage; + } + if (initiativeEffect.damageReduction > 0 && damageBeforeInitiative > 1) { + damage = Math.min( + damageBeforeInitiative - 1, + Math.max(1, Math.round(damageBeforeInitiative * (1 - initiativeEffect.damageReduction / 100))) + ); } const defenderTerrainRating = getUnitClass(defender.classKey).terrainRatings[terrain] ?? 100; const terrainHitPenalty = Math.floor(terrainRule.defenseBonus / 2) + Math.max(0, Math.floor((defenderTerrainRating - 100) / 4)); @@ -10813,6 +11537,7 @@ export class BattleScene extends Phaser.Scene { (usable?.accuracyBonus ?? 0) + equipmentEffect.hitBonus + sortieEffect.hitBonus + + initiativeEffect.hitBonus + this.hitBuff(attacker), 45, 98 @@ -10851,6 +11576,12 @@ export class BattleScene extends Phaser.Scene { sortieHitBonus: sortieEffect.hitBonus, sortieCriticalBonus: sortieEffect.criticalBonus, sortieEffectLabels: sortieEffect.labels, + initiativeRole: initiativeEffect.role, + initiativeDamageBonus: initiativeEffect.damageBonus, + initiativeDamageReduction: initiativeEffect.damageReduction, + initiativeHitBonus: initiativeEffect.hitBonus, + initiativeEffectLabels: initiativeEffect.labels, + damageBeforeInitiative, hitRate, criticalRate, terrainDefenseBonus: terrainRule.defenseBonus, @@ -10911,16 +11642,22 @@ export class BattleScene extends Phaser.Scene { private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false, usable?: BattleUsable): CombatResult { const preview = this.combatPreview(attacker, defender, action, usable, isCounter); - const baselinePreview = this.combatPreview(attacker, defender, action, usable, isCounter, true); + const sortieOnlyPreview = this.combatPreview(attacker, defender, action, usable, isCounter, false, true); + const baselinePreview = this.combatPreview(attacker, defender, action, usable, isCounter, true, true); const previousDefenderHp = defender.hp; const hit = this.debugForceHitEnabled() || this.rollPercent(preview.hitRate); const critical = hit && this.rollPercent(preview.criticalRate); const damage = hit ? Phaser.Math.Clamp(Math.round(preview.damage * (critical ? 1.5 : 1)), 1, defender.hp) : 0; + const sortieOnlyDamage = hit + ? Phaser.Math.Clamp(Math.round(sortieOnlyPreview.damage * (critical ? 1.5 : 1)), 1, previousDefenderHp) + : 0; const baselineDamage = hit ? Phaser.Math.Clamp(Math.round(baselinePreview.damage * (critical ? 1.5 : 1)), 1, previousDefenderHp) : 0; - const sortieBonusDamageAmount = preview.sortieDamageBonus > 0 ? Math.max(0, damage - baselineDamage) : 0; - const sortiePreventedDamageAmount = preview.sortieDamageReduction > 0 ? Math.max(0, baselineDamage - damage) : 0; + const sortieBonusDamageAmount = sortieOnlyPreview.sortieDamageBonus > 0 ? Math.max(0, sortieOnlyDamage - baselineDamage) : 0; + const sortiePreventedDamageAmount = sortieOnlyPreview.sortieDamageReduction > 0 ? Math.max(0, baselineDamage - sortieOnlyDamage) : 0; + const initiativeBonusDamageAmount = preview.initiativeDamageBonus > 0 ? Math.max(0, damage - sortieOnlyDamage) : 0; + const initiativePreventedDamageAmount = preview.initiativeDamageReduction > 0 ? Math.max(0, sortieOnlyDamage - damage) : 0; const sortieExtendedBondTriggered = preview.bondExtendedRange && preview.bondDamageBonus > 0 && !isCounter; const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action)); const armorGrowth = this.awardEquipmentExp(defender, 'armor', this.defenderArmorExpGain(defender, hit, preview)); @@ -10947,7 +11684,13 @@ export class BattleScene extends Phaser.Scene { sortiePreventedDamageAmount, sortieExtendedBondTriggered ); - this.recordIntentGuardCounterplay(attacker, defender, isCounter, sortiePreventedDamageAmount); + const initiativeReadied = this.recordIntentGuardCounterplay(attacker, defender, isCounter, sortiePreventedDamageAmount); + this.resolveTacticalInitiativeCombatEffect( + preview, + hit, + initiativeBonusDamageAmount, + initiativePreventedDamageAmount + ); this.faceUnitToward(attacker, defender); if (hit) { this.flashDamage(defender, damage, critical, previousDefenderHp); @@ -10978,7 +11721,10 @@ export class BattleScene extends Phaser.Scene { bondGrowth, statusApplied, sortieBonusDamageAmount, - sortiePreventedDamageAmount + sortiePreventedDamageAmount, + initiativeBonusDamageAmount, + initiativePreventedDamageAmount, + initiativeReadied }; } @@ -11256,10 +12002,12 @@ export class BattleScene extends Phaser.Scene { const summarySortie = result.sortieEffectLabels.length > 0 || sortieContribution ? `\n편성 효과: ${[...result.sortieEffectLabels, sortieContribution].filter(Boolean).join(' · ')}` : ''; + const initiativeContribution = this.combatInitiativeContributionText(result); + const summaryInitiative = initiativeContribution ? `\n전술 명령: ${initiativeContribution}` : ''; const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : ''; const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; - return `${headline}\n${resolution}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`; + return `${headline}\n${resolution}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`; } @@ -11363,7 +12111,23 @@ export class BattleScene extends Phaser.Scene { return parts.join(' · '); } + private combatInitiativeContributionText(result: CombatResult) { + if (result.initiativePreventedDamageAmount > 0) { + return `철벽 호령 ${result.initiativePreventedDamageAmount} 감소`; + } + if (result.initiativeBonusDamageAmount > 0) { + return `맹진 호령 +${result.initiativeBonusDamageAmount}`; + } + return ''; + } + private combatSortieRibbonText(result: CombatResult) { + if (result.initiativePreventedDamageAmount > 0) { + return ` · 철벽 -${result.initiativePreventedDamageAmount}`; + } + if (result.initiativeBonusDamageAmount > 0) { + return ` · 맹진 +${result.initiativeBonusDamageAmount}`; + } if (result.sortiePreventedDamageAmount > 0) { return ` · 방호 -${result.sortiePreventedDamageAmount}`; } @@ -11513,7 +12277,7 @@ export class BattleScene extends Phaser.Scene { const title = this.firstPursuitTrinityConfigured() ? '도원 삼재진 발동' : '편성 전술 발동'; this.triggerBattleEvent('opening', title, [ ...roleLines, - '적 의도 · 검=공격 · 발=추격 · 공/추=표적 수 · 대응=파훼', + `적 의도 · 검=공격 · 발=추격 · 파훼 ${tacticalInitiativeThreshold}회 → 전술 명령`, `작전 목표 · ${objectiveLines[0]}` ]); return; @@ -11829,6 +12593,12 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.tacticalInitiativePanelObjects.length > 0) { + soundDirector.playSelect(); + this.hideTacticalInitiativePanel(); + return; + } + if (this.phase === 'animating') { return; } @@ -11877,6 +12647,10 @@ export class BattleScene extends Phaser.Scene { return; } + if (this.tacticalInitiativePanelObjects.length > 0) { + return; + } + if (this.saveSlotPanelObjects.length > 0 || this.turnPromptObjects.length > 0) { return; } @@ -12354,13 +13128,16 @@ export class BattleScene extends Phaser.Scene { const raw = window.localStorage.getItem(this.battleSaveStorageKeyForSlot(normalizedSlot)) ?? (normalizedSlot === 1 ? window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey) : undefined); + const savedCampaign = readCampaignSaveState(normalizedSlot); return parseBattleSaveState(raw, { expectedBattleId: battleScenario.id, + allowTacticalCommand: this.isFirstPursuitRoleEffectBattle(), mapWidth: battleMap.width, mapHeight: battleMap.height, validUnitIds: new Set(battleUnits.map((unit) => unit.id)), validAllyUnitIds: new Set(battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)), validEnemyUnitIds: new Set(battleUnits.filter((unit) => unit.faction === 'enemy').map((unit) => unit.id)), + validTacticalCommandRolesByActor: this.tacticalInitiativeActorRolesForCampaign(savedCampaign), validUsableIds: new Set(Object.keys(usableCatalog)), validItemIds: new Set( Object.values(usableCatalog) @@ -12454,6 +13231,7 @@ export class BattleScene extends Phaser.Scene { battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), battleStatuses: this.serializeBattleStatuses(), battleStats: this.serializeBattleStats(), + tacticalCommand: this.isFirstPursuitRoleEffectBattle() && this.tacticalCommand ? { ...this.tacticalCommand } : undefined, enemyUsableUseKeys: Array.from(this.enemyUsableUseKeys), triggeredBattleEvents: Array.from(this.triggeredBattleEvents) }; @@ -12463,6 +13241,7 @@ export class BattleScene extends Phaser.Scene { this.clearMarkers(); this.hideCommandMenu(); this.hideTurnEndPrompt(); + this.hideTacticalInitiativePanel(); this.hideCombatCutIn(); this.hideBattleResult(); this.selectedUnit = undefined; @@ -12494,6 +13273,11 @@ export class BattleScene extends Phaser.Scene { this.battleStatuses = this.deserializeBattleStatuses(state.battleStatuses); this.battleStats = this.deserializeBattleStats(state.battleStats); this.intentCounterplayEvents = []; + this.tacticalCommand = this.isFirstPursuitRoleEffectBattle() && + state.tacticalCommand && + this.tacticalInitiativeCommandActorMatchesRole(state.tacticalCommand) + ? { ...state.tacticalCommand } + : undefined; this.enemyUsableUseKeys = new Set(state.enemyUsableUseKeys ?? []); this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []); @@ -13302,11 +14086,13 @@ export class BattleScene extends Phaser.Scene { private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) { const sortieContribution = this.combatSortieContributionText(result); const sortie = sortieContribution ? ` · ${sortieContribution}` : ''; + const initiativeContribution = this.combatInitiativeContributionText(result); + const initiative = initiativeContribution ? ` · ${initiativeContribution}` : ''; const counter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; const counterplay = this.isIntentGuardCounterplayResult(result) - ? `방호 성공 · ${result.defender.name} · 파훼 +1\n` + ? `방호 성공 · ${result.defender.name} · 파훼 +1${result.initiativeReadied ? ' · 명령 준비' : ''}\n` : ''; - return `${counterplay}${this.combatResultSummaryLine(result)} · ${this.enemyBehaviorResultLabel(behavior)}${sortie}${counter}`; + return `${counterplay}${this.combatResultSummaryLine(result)} · ${this.enemyBehaviorResultLabel(behavior)}${sortie}${initiative}${counter}`; } private enemyBehaviorResultLabel(behavior: EnemyAiBehavior) { @@ -15390,6 +16176,9 @@ export class BattleScene extends Phaser.Scene { } private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) { + if (result.initiativeBonusDamageAmount > 0) { + return this.showSortieCombatCue('flank', `맹진 호령 +${result.initiativeBonusDamageAmount}`, x, y, depth); + } if (result.sortieBonusDamageAmount <= 0 || !result.sortieRole) { return Promise.resolve(); } @@ -15402,6 +16191,11 @@ export class BattleScene extends Phaser.Scene { } private showSortieDefenseCombatEffect(result: CombatResult, x: number, y: number, depth: number) { + if (result.initiativePreventedDamageAmount > 0) { + const counterplay = this.isIntentGuardCounterplayResult(result) ? ' · 파훼 +1' : ''; + soundDirector.playGrowthTick(); + return this.showSortieCombatCue('front', `철벽 호령 -${result.initiativePreventedDamageAmount}${counterplay}`, x, y, depth); + } if (result.sortiePreventedDamageAmount <= 0) { return Promise.resolve(); } @@ -15849,6 +16643,7 @@ export class BattleScene extends Phaser.Scene { private updateTurnText() { this.turnText?.setText(`${this.turnNumber}턴 / ${factionLabels[this.activeFaction]}`); this.updateObjectiveTracker(); + this.renderTacticalInitiativeChip(); } private resetActedStyles() { @@ -17015,6 +17810,7 @@ export class BattleScene extends Phaser.Scene { } private applyDefeatedStyle(unit: UnitData, view = this.unitViews.get(unit.id)) { + this.losePendingTacticalInitiativeEffect(unit); this.battleBuffs.delete(unit.id); this.battleStatuses.delete(unit.id); if (!view) { @@ -17114,7 +17910,11 @@ export class BattleScene extends Phaser.Scene { return; } - const effectLine = [this.statusEffectMapText(result.statusApplied), this.combatSortieContributionText(result)] + const effectLine = [ + this.statusEffectMapText(result.statusApplied), + this.combatSortieContributionText(result), + this.combatInitiativeContributionText(result) + ] .filter(Boolean) .join(' · '); const lines = [ @@ -18324,6 +19124,22 @@ export class BattleScene extends Phaser.Scene { }); } + const pendingCommand = this.tacticalCommand?.effectPending && this.tacticalCommand.actorId === unit.id + ? this.tacticalCommand + : undefined; + if (pendingCommand) { + const definition = firstPursuitRoleEffects[pendingCommand.role]; + effects.push({ + icon: definition?.icon ?? 'leadership', + label: this.tacticalInitiativeCommandLabel(pendingCommand.role), + value: pendingCommand.role === 'front' + ? `적 일반 공격 피해 -${tacticalInitiativeFrontReduction}% 대기` + : `일반 공격 +${tacticalInitiativeFlankDamageBonus}% · 명중 +${tacticalInitiativeFlankHitBonus}%`, + tone: palette.gold, + polarity: 'buff' + }); + } + effects.push({ icon: 'terrain', label: `${terrainRule.label} 지형`, @@ -18792,6 +19608,7 @@ export class BattleScene extends Phaser.Scene { enemyIntentPreviews: Array.from(this.enemyIntentForecasts.values()).map((plan) => this.enemyActionPlanDebugValue(plan)), enemyIntentVisualCount: this.enemyIntentVisuals.length, counterplayFeedback: this.intentCounterplayDebugState(), + tacticalInitiative: this.tacticalInitiativeDebugState(), camera: { x: this.cameraTileX, y: this.cameraTileY, @@ -19120,6 +19937,7 @@ export class BattleScene extends Phaser.Scene { })); } console.info('[Counterplay Debug]', JSON.stringify(state.counterplayFeedback)); + console.info('[Tactical Initiative Debug]', JSON.stringify(state.tacticalInitiative)); this.updateDebugOverlay(); }); this.input.on('pointermove', () => this.updateDebugOverlay()); @@ -19152,6 +19970,7 @@ export class BattleScene extends Phaser.Scene { const sortieProbe = this.debugFirstPursuitRoleMechanicsProbe(); const sortieTotals = this.isFirstPursuitRoleEffectBattle() ? this.firstPursuitContributionTotals() : undefined; const counterplay = this.intentCounterplayDebugState(); + const initiative = this.tacticalInitiativeDebugState(); const intentCounts = Array.from(this.enemyIntentForecasts.values()).reduce( (counts, plan) => { counts[plan.kind] += 1; @@ -19166,7 +19985,8 @@ export class BattleScene extends Phaser.Scene { `role support strategy=${sortieProbe.support.strategyDamage}/${sortieProbe.support.strategyBaseline} heal=${sortieProbe.support.healAmount}/${sortieProbe.support.healBaseline}`, `trinity d2=${sortieProbe.trinity.distanceTwoCanJoin}/${sortieProbe.trinity.distanceTwoExtended} d3=${sortieProbe.trinity.distanceThreeCanJoin}`, `contrib dmg+${sortieTotals?.bonusDamage ?? 0} guard=${sortieTotals?.preventedDamage ?? 0} heal+${sortieTotals?.bonusHealing ?? 0} d2=${sortieTotals?.extendedBondTriggers ?? 0}`, - `counterplay break=${counterplay.totals.intentDefeats} block=${counterplay.totals.intentLineBreaks} guard=${counterplay.totals.intentGuardSuccesses} events=${counterplay.recentEvents.length}` + `counterplay break=${counterplay.totals.intentDefeats} block=${counterplay.totals.intentLineBreaks} guard=${counterplay.totals.intentGuardSuccesses} events=${counterplay.recentEvents.length}`, + `initiative ${initiative.points}/${initiative.threshold} ready=${initiative.ready} command=${initiative.command?.role ?? 'none'} pending=${initiative.command?.effectPending ?? false}` ] : []; diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 41d93d0..7298691 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -54,6 +54,18 @@ export type BattleSaveUnitStats = { intentGuardSuccesses?: number; }; +export type BattleSaveTacticalCommandRole = 'front' | 'flank' | 'support'; + +export type BattleSaveTacticalCommandState = { + role: BattleSaveTacticalCommandRole; + actorId: string; + usedTurn: number; + effectPending: boolean; + effectValue: number; + statusesCleared: number; + effectLost?: boolean; +}; + export type BattleSaveState = { version: 1; battleId: string; @@ -71,17 +83,20 @@ export type BattleSaveState = { battleBuffs?: BattleSaveBuffState[]; battleStatuses?: BattleSaveStatusState[]; battleStats?: Record; + tacticalCommand?: BattleSaveTacticalCommandState; enemyUsableUseKeys?: string[]; triggeredBattleEvents?: string[]; }; type BattleSaveValidationOptions = { expectedBattleId?: string; + allowTacticalCommand?: boolean; mapWidth?: number; mapHeight?: number; validUnitIds?: ReadonlySet; validAllyUnitIds?: ReadonlySet; validEnemyUnitIds?: ReadonlySet; + validTacticalCommandRolesByActor?: Readonly>; validUsableIds?: ReadonlySet; validItemIds?: ReadonlySet; validEquipmentItemIds?: ReadonlySet; @@ -106,6 +121,8 @@ const maxBattleUnitAttack = 9999; const maxBattleUnitMove = 99; const maxBattleBondBattleExp = 999999; const maxStatusKindsPerUnit = 2; +const tacticalCommandCounterplayThreshold = 3; +const tacticalCommandSupportHealLimit = 6; const maxBattleItemStockById: Record = { bean: 3, salve: 2, @@ -181,6 +198,13 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida !isOptionalBuffArray(state.battleBuffs, options) || !isOptionalStatusArray(state.battleStatuses, options) || !isOptionalStatsRecord(state.battleStats, options) || + !isOptionalTacticalCommandState( + state.tacticalCommand, + Number(state.turnNumber), + units, + state.battleStats, + options + ) || !isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) || !isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options) ) { @@ -226,6 +250,10 @@ function isBattleStatusKind(value: unknown): value is BattleSaveStatusKind { return value === 'burn' || value === 'confusion'; } +function isTacticalCommandRole(value: unknown): value is BattleSaveTacticalCommandRole { + return value === 'front' || value === 'flank' || value === 'support'; +} + function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && @@ -534,6 +562,75 @@ function isOptionalBattleUnitStatValue(value: unknown) { return value === undefined || isBattleUnitStatValue(value); } +function isOptionalTacticalCommandState( + value: unknown, + turnNumber: number, + units: unknown[], + battleStats: unknown, + options: BattleSaveValidationOptions +) { + if (value === undefined) { + return true; + } + if (!options.allowTacticalCommand) { + return false; + } + if ( + !isRecord(value) || + !isTacticalCommandRole(value.role) || + typeof value.actorId !== 'string' || + !isKnownUnitId(value.actorId, options) || + (options.validAllyUnitIds && !options.validAllyUnitIds.has(value.actorId)) || + (options.validTacticalCommandRolesByActor && options.validTacticalCommandRolesByActor[value.actorId] !== value.role) || + !isIntegerInRange(value.usedTurn, 1, turnNumber) || + typeof value.effectPending !== 'boolean' || + !isBattleUnitStatValue(value.effectValue) || + !isBattleUnitStatValue(value.statusesCleared) || + (value.effectLost !== undefined && typeof value.effectLost !== 'boolean') || + tacticalCommandCounterplayTotal(battleStats, options) < tacticalCommandCounterplayThreshold + ) { + return false; + } + if (value.role === 'support') { + return ( + !value.effectPending && + !value.effectLost && + Number(value.effectValue) <= tacticalCommandSupportHealLimit && + Number(value.statusesCleared) <= maxStatusKindsPerUnit + ); + } + if (Number(value.statusesCleared) !== 0) { + return false; + } + if (value.effectPending && (Number(value.effectValue) !== 0 || value.effectLost)) { + return false; + } + if (value.effectLost && Number(value.effectValue) !== 0) { + return false; + } + if (!value.effectPending) { + return true; + } + return units.some((unit) => isRecord(unit) && unit.id === value.actorId && Number(unit.hp) > 0); +} + +function tacticalCommandCounterplayTotal(value: unknown, options: BattleSaveValidationOptions) { + if (!isRecord(value)) { + return 0; + } + return Object.entries(value).reduce((total, [unitId, stats]) => { + if ((options.validAllyUnitIds && !options.validAllyUnitIds.has(unitId)) || !isRecord(stats)) { + return total; + } + return ( + total + + Number(stats.intentDefeats ?? 0) + + Number(stats.intentLineBreaks ?? 0) + + Number(stats.intentGuardSuccesses ?? 0) + ); + }, 0); +} + function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) { return ( value === undefined || diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 4cd6cab..15b66c3 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -568,6 +568,11 @@ export function loadCampaignState(slot?: number) { return cloneCampaignState(campaignState); } +export function readCampaignSaveState(slot: number) { + const state = readStoredCampaignState(slot); + return state ? cloneCampaignState(state) : undefined; +} + export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) { campaignState = normalizeCampaignState(state); campaignState.activeSaveSlot = normalizeSlot(slot);