diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 5e713c4..ad84d4a 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -35,6 +35,19 @@ try { assert(normalizeBattleSaveSlot(Number.NaN, 3) === 1, 'Expected NaN slot to normalize to slot 1.'); assert(normalizeBattleSaveSlot(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.'); assert(isValidBattleSaveState(validState, options), 'Expected valid battle save to pass validation.'); + const validSortieStatsState = { + ...validState, + battleStats: { + 'liu-bei': { + ...validState.battleStats['liu-bei'], + sortieBonusDamage: 4, + sortiePreventedDamage: 3, + sortieBonusHealing: 2, + sortieExtendedBondTriggers: 1 + } + } + }; + assert(isValidBattleSaveState(validSortieStatsState, options), 'Expected sortie contribution stats to pass validation.'); 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.'); @@ -122,6 +135,8 @@ try { ['invalid stats shape', { battleStats: { 'liu-bei': { damageDealt: 10 } } }], ['invalid stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], actions: 1.5 } } }], ['too high stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], damageDealt: 1000000 } } }], + ['invalid sortie stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], sortieBonusDamage: -1 } } }], + ['too high sortie stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], sortieExtendedBondTriggers: 1000000 } } }], ['unknown stats unit id', { battleStats: { 'ghost-unit': validState.battleStats['liu-bei'] } }], ['invalid enemy usable keys', { enemyUsableUseKeys: [1] }], ['malformed enemy usable key', { enemyUsableUseKeys: ['rebel-1:shout'] }], diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index fc79309..60b2b84 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -56,7 +56,12 @@ import { type CampaignSortieItemAssignments, type CampaignStep } from '../state/campaignState'; -import { normalizeBattleSaveSlot, parseBattleSaveState, type BattleSaveState } from '../state/battleSaveState'; +import { + normalizeBattleSaveSlot, + parseBattleSaveState, + type BattleSaveState, + type BattleSaveUnitStats +} from '../state/battleSaveState'; import { palette } from '../ui/palette'; import { startLazyScene } from './lazyScenes'; @@ -1235,6 +1240,10 @@ type UnitBattleStats = { defeats: number; actions: number; support: number; + sortieBonusDamage: number; + sortiePreventedDamage: number; + sortieBonusHealing: number; + sortieExtendedBondTriggers: number; }; type ThreatTile = { @@ -1404,6 +1413,8 @@ type CombatResult = CombatPreview & { bondExp: number; bondGrowth: BondGrowthResult[]; statusApplied?: BattleStatusState; + sortieBonusDamageAmount: number; + sortiePreventedDamageAmount: number; counter?: CombatResult; }; @@ -1464,6 +1475,7 @@ type SupportResult = { target: UnitData; previousTargetHp: number; healAmount: number; + sortieHealBonus: number; buff?: BattleBuffState; characterGrowth: CharacterGrowthResult; equipmentGrowth: EquipmentGrowthResult; @@ -3397,7 +3409,7 @@ export class BattleScene extends Phaser.Scene { initialBattleUnits, effectiveSortieUnitIds, assignments, - (unit) => this.defaultSortieFormationRole(unit), + (unit) => this.recommendedSortieFormationRole(unit), this.isFirstPursuitRoleEffectBattle() ? battleScenario.sortie?.deploymentSlots.map((slot) => ({ x: slot.x, @@ -3416,12 +3428,16 @@ export class BattleScene extends Phaser.Scene { private applyDebugBattleSetup() { const setupKey = this.debugBattleSetupKey(); - if (setupKey !== 'attack-preview' && setupKey !== 'status-ui') { + if (setupKey !== 'attack-preview' && setupKey !== 'status-ui' && setupKey !== 'sortie-feedback') { return; } const attacker = battleUnits.find((unit) => unit.id === 'guan-yu' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0); - const target = battleUnits.find((unit) => unit.id === 'rebel-a' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0); + const target = setupKey === 'sortie-feedback' + ? battleUnits + .filter((unit) => unit.faction === 'enemy' && unit.hp > 0) + .sort((left, right) => right.maxHp - left.maxHp)[0] + : battleUnits.find((unit) => unit.id === 'rebel-a' && unit.hp > 0) ?? battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!attacker || !target) { return; } @@ -3433,7 +3449,12 @@ export class BattleScene extends Phaser.Scene { target.x = targetTile.x; target.y = targetTile.y; - target.hp = Math.max(1, target.hp); + if (setupKey === 'sortie-feedback') { + target.maxHp = Math.max(target.maxHp, 80); + target.hp = target.maxHp; + } else { + target.hp = Math.max(1, target.hp); + } } private applyDebugStatusUiSetup() { @@ -3537,6 +3558,11 @@ export class BattleScene extends Phaser.Scene { return 'reserve'; } + private recommendedSortieFormationRole(unit: UnitData): SortieFormationRole { + return battleScenario.sortie?.recommendedUnits.find((entry) => entry.unitId === unit.id)?.role + ?? this.defaultSortieFormationRole(unit); + } + private isFirstPursuitRoleEffectBattle() { return battleScenario.id === firstPursuitRoleEffectBattleId; } @@ -3550,17 +3576,21 @@ export class BattleScene extends Phaser.Scene { return undefined; } const assignments = this.effectiveSortieFormationAssignments(getCampaignState()); - return assignments[unit.id] ?? this.defaultSortieFormationRole(unit); + return assignments[unit.id] ?? this.recommendedSortieFormationRole(unit); } private firstPursuitRoleAssignments() { - return firstPursuitRoleOrder - .map((role) => { - const unit = battleUnits.find((candidate) => this.firstPursuitRoleForUnit(candidate) === role); + return battleUnits + .map((unit) => { + const role = this.firstPursuitRoleForUnit(unit); + if (!role) { + return undefined; + } const definition = firstPursuitRoleEffects[role]; - return unit && definition ? { role, unit, definition } : undefined; + return definition ? { role, unit, definition } : undefined; }) - .filter((entry): entry is NonNullable => Boolean(entry)); + .filter((entry): entry is NonNullable => Boolean(entry)) + .sort((left, right) => firstPursuitRoleOrder.indexOf(left.role) - firstPursuitRoleOrder.indexOf(right.role)); } private firstPursuitTrinityConfigured() { @@ -3643,12 +3673,12 @@ export class BattleScene extends Phaser.Scene { const parts = this.firstPursuitRoleAssignments().map(({ role, unit }) => { const stats = this.statsFor(unit.id); if (role === 'front') { - return `전열 ${unit.name}: 방호·반격 / 받은 피해 ${stats.damageTaken}`; + return `전열 ${unit.name}: 감소 ${stats.sortiePreventedDamage}·반격 추가 ${stats.sortieBonusDamage}`; } if (role === 'flank') { - return `돌파 ${unit.name}: 초반 강화 / 피해 ${stats.damageDealt}·격파 ${stats.defeats}`; + return `돌파 ${unit.name}: 추가 피해 ${stats.sortieBonusDamage}`; } - return `후원 ${unit.name}: 책략·회복 / 지원 ${stats.support}`; + return `후원 ${unit.name}: 추가 피해 ${stats.sortieBonusDamage}·회복 ${stats.sortieBonusHealing}`; }); if (this.firstPursuitTrinityConfigured()) { parts.push(this.firstPursuitTrinityActive() ? '삼재진: 공명 2칸 유지' : '삼재진: 퇴각으로 해제'); @@ -3656,19 +3686,34 @@ export class BattleScene extends Phaser.Scene { return `편성 기여 · ${parts.join(' · ')}`; } + private firstPursuitContributionTotals() { + return this.firstPursuitRoleAssignments().reduce( + (totals, { unit }) => { + const stats = this.statsFor(unit.id); + totals.bonusDamage += stats.sortieBonusDamage; + totals.preventedDamage += stats.sortiePreventedDamage; + totals.bonusHealing += stats.sortieBonusHealing; + totals.extendedBondTriggers += stats.sortieExtendedBondTriggers; + return totals; + }, + { bonusDamage: 0, preventedDamage: 0, bonusHealing: 0, extendedBondTriggers: 0 } + ); + } + private firstPursuitUnitContributionLine(unit: UnitData) { const role = this.firstPursuitRoleForUnit(unit); if (!role) { return undefined; } const stats = this.statsFor(unit.id); + const extendedBond = stats.sortieExtendedBondTriggers > 0 ? ` · 공명 ${stats.sortieExtendedBondTriggers}회` : ''; if (role === 'front') { - return `편성 전열 · 방호/반격 · 받은 피해 ${stats.damageTaken}`; + return `전열 · 피해 ${stats.sortiePreventedDamage} 감소 · 반격 +${stats.sortieBonusDamage}${extendedBond}`; } if (role === 'flank') { - return `편성 돌파 · 초반 강화 · 피해 ${stats.damageDealt} / 격파 ${stats.defeats}`; + return `돌파 · 추가 피해 +${stats.sortieBonusDamage}${extendedBond}`; } - return `편성 후원 · 책략/회복 · 지원 ${stats.support}`; + return `후원 · 피해 +${stats.sortieBonusDamage} · 회복 +${stats.sortieBonusHealing}${extendedBond}`; } private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) { @@ -8958,7 +9003,24 @@ export class BattleScene extends Phaser.Scene { })); unitTitle.setDepth(depth + 2); - if (hiddenAllyCount > 0) { + if (this.isFirstPursuitRoleEffectBattle()) { + const totals = this.firstPursuitContributionTotals(); + const contributionSummary = this.trackResultObject(this.add.text( + left + panelWidth - 44, + top + 358, + `추가 피해 +${totals.bonusDamage} · 피해 감소 ${totals.preventedDamage} · 추가 회복 +${totals.bonusHealing} · 2칸 공명 ${totals.extendedBondTriggers}회`, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#d8b15f', + fontStyle: '700' + } + )); + contributionSummary.setOrigin(1, 0); + contributionSummary.setDepth(depth + 2); + } + + if (hiddenAllyCount > 0 && !this.isFirstPursuitRoleEffectBattle()) { const hiddenSummary = this.trackResultObject(this.add.text(left + panelWidth - 44, top + 358, `외 ${hiddenAllyCount}명 정산 반영`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', @@ -9604,7 +9666,7 @@ export class BattleScene extends Phaser.Scene { const sortieContribution = this.firstPursuitUnitContributionLine(unit); if (sortieContribution) { - const sortieText = this.trackResultObject(this.add.text(x + 14, y + 31, this.truncateUiText(sortieContribution, 38), { + const sortieText = this.trackResultObject(this.add.text(x + 14, y + 31, this.truncateUiText(sortieContribution, 32), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#d8b15f', @@ -10292,30 +10354,51 @@ export class BattleScene extends Phaser.Scene { return created; } - private recordCombatStats(attacker: UnitData, defender: UnitData, damage: number, defeated: boolean) { + private recordCombatStats( + attacker: UnitData, + defender: UnitData, + damage: number, + defeated: boolean, + sortieBonusDamage: number, + sortiePreventedDamage: number, + sortieExtendedBondTriggered: boolean + ) { const attackerStats = this.statsFor(attacker.id); attackerStats.actions += 1; attackerStats.damageDealt += damage; + attackerStats.sortieBonusDamage += sortieBonusDamage; + if (sortieExtendedBondTriggered) { + attackerStats.sortieExtendedBondTriggers += 1; + } if (defeated) { attackerStats.defeats += 1; } const defenderStats = this.statsFor(defender.id); defenderStats.damageTaken += damage; + defenderStats.sortiePreventedDamage += sortiePreventedDamage; } - private recordSupportStats(user: UnitData, amount: number) { + private recordSupportStats(user: UnitData, amount: number, sortieBonusHealing = 0) { const stats = this.statsFor(user.id); stats.actions += 1; stats.support += amount; + stats.sortieBonusHealing += sortieBonusHealing; } 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 previousDefenderHp = defender.hp; const hit = 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 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 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)); const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical)); @@ -10332,7 +10415,15 @@ export class BattleScene extends Phaser.Scene { usable.statusPower ?? this.defaultStatusPower(usable.statusEffect) ) : undefined; - this.recordCombatStats(attacker, defender, damage, defender.hp <= 0 && previousDefenderHp > 0); + this.recordCombatStats( + attacker, + defender, + damage, + defender.hp <= 0 && previousDefenderHp > 0, + sortieBonusDamageAmount, + sortiePreventedDamageAmount, + sortieExtendedBondTriggered + ); this.faceUnitToward(attacker, defender); if (hit) { this.flashDamage(defender, damage, critical, previousDefenderHp); @@ -10361,7 +10452,9 @@ export class BattleScene extends Phaser.Scene { defenderGrowth: armorGrowth, bondExp, bondGrowth, - statusApplied + statusApplied, + sortieBonusDamageAmount, + sortiePreventedDamageAmount }; } @@ -10396,6 +10489,8 @@ export class BattleScene extends Phaser.Scene { private resolveSupportAction(user: UnitData, target: UnitData, usable: BattleUsable): SupportResult { const previousTargetHp = target.hp; const healAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable) : 0; + const baselineHealAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable, true) : 0; + const sortieHealBonus = Math.max(0, healAmount - baselineHealAmount); let buff: BattleBuffState | undefined; if (healAmount > 0) { @@ -10420,7 +10515,7 @@ export class BattleScene extends Phaser.Scene { const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', this.supportEquipmentExpGain(user, usable)); const characterGrowth = this.awardCharacterExp(user, usable.effect === 'heal' ? 8 : 6); - this.recordSupportStats(user, healAmount); + this.recordSupportStats(user, healAmount, sortieHealBonus); return { usable, @@ -10428,6 +10523,7 @@ export class BattleScene extends Phaser.Scene { target, previousTargetHp, healAmount, + sortieHealBonus, buff, characterGrowth, equipmentGrowth @@ -10624,7 +10720,10 @@ export class BattleScene extends Phaser.Scene { const headline = this.combatResultSummaryLine(result); const resolution = this.combatResolutionLine(result); const summaryBondBonus = result.bondDamageBonus > 0 && result.bondLabel ? `\n공명 효과 ${result.bondLabel}: 피해 +${result.bondDamageBonus}%` : ''; - const summarySortie = result.sortieEffectLabels.length > 0 ? `\n편성 효과: ${result.sortieEffectLabels.join(', ')}` : ''; + const sortieContribution = this.combatSortieContributionText(result); + const summarySortie = result.sortieEffectLabels.length > 0 || sortieContribution + ? `\n편성 효과: ${[...result.sortieEffectLabels, sortieContribution].filter(Boolean).join(' · ')}` + : ''; 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)}` : ''; @@ -10707,7 +10806,42 @@ export class BattleScene extends Phaser.Scene { } private formatCounterLine(result: CombatResult) { - return `반격 결과: ${this.combatResultSummaryLine(result)}`; + const sortieContribution = this.combatSortieContributionText(result); + return `반격 결과: ${this.combatResultSummaryLine(result)}${sortieContribution ? ` · ${sortieContribution}` : ''}`; + } + + private combatSortieContributionText(result: CombatResult) { + const parts: string[] = []; + if (result.sortiePreventedDamageAmount > 0) { + parts.push(`전열 방호 ${result.sortiePreventedDamageAmount} 감소`); + } + if (result.sortieBonusDamageAmount > 0) { + const label = result.sortieRole === 'front' && result.isCounter + ? '전열 반격' + : result.sortieRole === 'flank' + ? '돌파 강화' + : result.sortieRole === 'support' + ? '후원 책략' + : '편성 추가'; + parts.push(`${label} +${result.sortieBonusDamageAmount}`); + } + if (result.bondExtendedRange && result.bondDamageBonus > 0) { + parts.push('삼재진 2칸 공명'); + } + return parts.join(' · '); + } + + private combatSortieRibbonText(result: CombatResult) { + if (result.sortiePreventedDamageAmount > 0) { + return ` · 방호 -${result.sortiePreventedDamageAmount}`; + } + if (result.sortieBonusDamageAmount > 0) { + return ` · 편성 +${result.sortieBonusDamageAmount}`; + } + if (result.bondExtendedRange && result.bondDamageBonus > 0) { + return ' · 공명 2칸'; + } + return ''; } private combatGrowthLines(result: CombatResult) { @@ -10734,13 +10868,14 @@ export class BattleScene extends Phaser.Scene { result.usable.effect === 'heal' ? `결과: 회복 +${result.healAmount} · HP ${result.previousTargetHp}→${result.target.hp}` : `결과: 강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴 · ${this.supportBuffCompactText(result.buff)}`; - return `${this.supportResultSummaryLine(result)}\n${supportEffect}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`; + const sortieContribution = result.sortieHealBonus > 0 ? `\n편성 기여: 후원 회복 +${result.sortieHealBonus}` : ''; + return `${this.supportResultSummaryLine(result)}\n${supportEffect}${sortieContribution}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`; } private supportResultSummaryLine(result: SupportResult) { if (result.usable.effect === 'heal') { - return `${result.user.name} → ${result.target.name} | ${result.usable.name} | 회복 +${result.healAmount} · HP ${result.previousTargetHp}→${result.target.hp}`; + return `${result.user.name} → ${result.target.name} | ${result.usable.name} | 회복 +${result.healAmount}${result.sortieHealBonus > 0 ? ` · 후원 +${result.sortieHealBonus}` : ''} · HP ${result.previousTargetHp}→${result.target.hp}`; } return `${result.user.name} → ${result.target.name} | ${result.usable.name} | 강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴 · ${this.supportBuffCompactText(result.buff)}`; @@ -10827,7 +10962,11 @@ export class BattleScene extends Phaser.Scene { damageTaken: 0, defeats: 0, actions: 0, - support: 0 + support: 0, + sortieBonusDamage: 0, + sortiePreventedDamage: 0, + sortieBonusHealing: 0, + sortieExtendedBondTriggers: 0 }; } @@ -11903,7 +12042,7 @@ export class BattleScene extends Phaser.Scene { return Object.fromEntries(Array.from(this.battleStats.entries()).map(([unitId, stats]) => [unitId, { ...stats }])); } - private deserializeBattleStats(savedStats?: Record) { + private deserializeBattleStats(savedStats?: Record) { const stats = this.createBattleStats(); if (!savedStats) { return stats; @@ -11915,7 +12054,11 @@ export class BattleScene extends Phaser.Scene { damageTaken: value.damageTaken ?? 0, defeats: value.defeats ?? 0, actions: value.actions ?? 0, - support: value.support ?? 0 + support: value.support ?? 0, + sortieBonusDamage: value.sortieBonusDamage ?? 0, + sortiePreventedDamage: value.sortiePreventedDamage ?? 0, + sortieBonusHealing: value.sortieBonusHealing ?? 0, + sortieExtendedBondTriggers: value.sortieExtendedBondTriggers ?? 0 }); }); return stats; @@ -12514,8 +12657,10 @@ export class BattleScene extends Phaser.Scene { } private formatEnemyCombatResult(result: CombatResult, behavior: EnemyAiBehavior) { + const sortieContribution = this.combatSortieContributionText(result); + const sortie = sortieContribution ? ` · ${sortieContribution}` : ''; const counter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; - return `${this.combatResultSummaryLine(result)} · ${this.enemyBehaviorResultLabel(behavior)}${counter}`; + return `${this.combatResultSummaryLine(result)} · ${this.enemyBehaviorResultLabel(behavior)}${sortie}${counter}`; } private enemyBehaviorResultLabel(behavior: EnemyAiBehavior) { @@ -12609,14 +12754,18 @@ export class BattleScene extends Phaser.Scene { attackerStatus.expText.setText(`${result.characterGrowth.previousExp} / ${result.characterGrowth.next}`); const growthEntries = this.combatGrowthEntries(result); - if (result.bondDamageBonus > 0 && result.bondLabel) { - await this.showBondCombatEffect(result, left + panelWidth / 2, top + 140, attackerX, groundY - 70, depth + 15); - } + await Promise.all([ + result.bondDamageBonus > 0 && result.bondLabel + ? this.showBondCombatEffect(result, left + panelWidth / 2, top + 140, attackerX, groundY - 70, depth + 15) + : Promise.resolve(), + this.showSortieAttackCombatEffect(result, attackerX, groundY - 70, depth + 16) + ]); await this.delay(180); await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10, defenderDirection); if (result.hit) { this.showCombatImpact(result, defenderX, groundY - 46, depth + 14); + void this.showSortieDefenseCombatEffect(result, defenderX, groundY - 70, depth + 17); } else { this.showCombatMiss(defenderX, groundY - 70, depth + 14); } @@ -12778,6 +12927,17 @@ export class BattleScene extends Phaser.Scene { if (this.combatCounterWillTrigger(result)) { return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 }; } + if (result.sortiePreventedDamageAmount > 0) { + return { icon: 'leadership', label: `방호 -${result.sortiePreventedDamageAmount}`, tone: 0x59d18c }; + } + if (result.sortieBonusDamageAmount > 0 && result.sortieRole) { + const definition = firstPursuitRoleEffects[result.sortieRole]; + return { + icon: definition?.icon ?? 'leadership', + label: `편성 +${result.sortieBonusDamageAmount}`, + tone: definition?.tone ?? palette.gold + }; + } if (result.bondDamageBonus > 0) { return { icon: 'leadership', label: `공명 +${result.bondDamageBonus}%`, tone: palette.gold }; } @@ -12936,7 +13096,7 @@ export class BattleScene extends Phaser.Scene { })); actionLabel.setDepth(depth + 1); - const damageText = this.trackCombatObject(this.add.text(x + width - 14, y + 8, `피해 ${dealtDamage}`, { + const damageText = this.trackCombatObject(this.add.text(x + width - 14, y + 8, `피해 ${dealtDamage}${this.combatSortieRibbonText(result)}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: result.hit ? '#ffcf91' : '#9fb0bf', @@ -13156,6 +13316,7 @@ export class BattleScene extends Phaser.Scene { const effect = this.trackCombatObject(this.add.circle(left + panelWidth / 2, top + 156, 30, effectColor, 0.76)); effect.setDepth(depth + 4); this.tweens.add({ targets: effect, scale: 1.45, alpha: 0.18, duration: 520, yoyo: true }); + this.showSortieSupportCombatEffect(result, left + panelWidth / 2, top + 156, depth + 6); soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', { volume: result.usable.command === 'strategy' ? 0.38 : 0.28, stopAfterMs: 900 @@ -13337,7 +13498,7 @@ export class BattleScene extends Phaser.Scene { const resultLabel = result.usable.effect === 'heal' - ? `회복 +${result.healAmount}` + ? `회복 +${result.healAmount}${result.sortieHealBonus > 0 ? ` · 편성 +${result.sortieHealBonus}` : ''}` : `강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴`; const resultText = this.trackCombatObject(this.add.text(x + width - 14, y + 8, resultLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -14480,25 +14641,42 @@ export class BattleScene extends Phaser.Scene { return Promise.resolve(); } - soundDirector.playEffect('bond-resonance', { volume: 0.22, rate: 1.04, stopAfterMs: 620 }); + soundDirector.playEffect('bond-resonance', { + volume: result.bondExtendedRange ? 0.28 : 0.22, + rate: result.bondExtendedRange ? 0.94 : 1.04, + stopAfterMs: 620 + }); const objects: Phaser.GameObjects.GameObject[] = []; const line = this.add.graphics(); line.setDepth(34); - line.lineStyle(4, 0xffdf7b, 0.78); visiblePartners.forEach((partner) => { const partnerView = this.unitViews.get(partner.id); if (!partnerView) { return; } + line.lineStyle(result.bondExtendedRange ? 6 : 4, 0xffdf7b, result.bondExtendedRange ? 0.68 : 0.78); line.beginPath(); line.moveTo(attackerView.sprite.x, attackerView.sprite.y - this.layout.tileSize * 0.28); line.lineTo(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.28); line.strokePath(); - objects.push(this.createBondBadge(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.86, `${partner.name} ♥ 공명`, 35)); + if (result.bondExtendedRange) { + line.lineStyle(2, 0x83d6ff, 0.94); + line.beginPath(); + line.moveTo(attackerView.sprite.x, attackerView.sprite.y - this.layout.tileSize * 0.28); + line.lineTo(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.28); + line.strokePath(); + } + const partnerLabel = result.bondExtendedRange ? `${partner.name} · 2칸` : `${partner.name} ♥ 공명`; + objects.push(this.createBondBadge(partnerView.sprite.x, partnerView.sprite.y - this.layout.tileSize * 0.86, partnerLabel, 35)); }); objects.push(line); - objects.push(this.createBondBadge(attackerView.sprite.x, attackerView.sprite.y - this.layout.tileSize * 0.96, '연계 발동', 36)); + objects.push(this.createBondBadge( + attackerView.sprite.x, + attackerView.sprite.y - this.layout.tileSize * 0.96, + result.bondExtendedRange ? '삼재진 · 2칸 공명' : '연계 발동', + 36 + )); this.tweens.add({ targets: objects, alpha: 0, y: '-=12', duration: 620, ease: 'Sine.easeOut' }); return this.delay(660).then(() => objects.forEach((object) => object.destroy())); @@ -14542,7 +14720,8 @@ export class BattleScene extends Phaser.Scene { const spark = this.trackCombatObject(this.add.star(auraX, auraY, 6, 8, 24, 0xfff0b8, 0.86)); spark.setDepth(depth + 1); - const text = this.trackCombatObject(this.add.text(textX, textY, `공명 효과 ${result.bondLabel}\n피해 +${result.bondDamageBonus}%`, { + const bondTitle = result.bondExtendedRange ? '삼재진 2칸 공명' : `공명 효과 ${result.bondLabel}`; + const text = this.trackCombatObject(this.add.text(textX, textY, `${bondTitle}\n피해 +${result.bondDamageBonus}%`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#fff2b8', @@ -14553,7 +14732,7 @@ export class BattleScene extends Phaser.Scene { lineSpacing: 2 })); text.setOrigin(0.5); - text.setText(`공명 효과 ${result.bondLabel}\n피해 +${result.bondDamageBonus}%`); + text.setText(`${bondTitle}\n피해 +${result.bondDamageBonus}%`); text.setDepth(depth + 2); this.tweens.add({ targets: aura, scale: 1.7, alpha: 0, duration: 560, ease: 'Sine.easeOut' }); @@ -14564,6 +14743,87 @@ export class BattleScene extends Phaser.Scene { return this.delay(580); } + private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) { + if (result.sortieBonusDamageAmount <= 0 || !result.sortieRole) { + return Promise.resolve(); + } + const label = result.sortieRole === 'front' && result.isCounter + ? `전열 반격 +${result.sortieBonusDamageAmount}` + : result.sortieRole === 'flank' + ? `돌파 강화 +${result.sortieBonusDamageAmount}` + : `후원 책략 +${result.sortieBonusDamageAmount}`; + return this.showSortieCombatCue(result.sortieRole, label, x, y, depth); + } + + private showSortieDefenseCombatEffect(result: CombatResult, x: number, y: number, depth: number) { + if (result.sortiePreventedDamageAmount <= 0) { + return Promise.resolve(); + } + return this.showSortieCombatCue('front', `전열 방호 -${result.sortiePreventedDamageAmount}`, x, y, depth); + } + + private showSortieCombatCue(role: SortieFormationRole, label: string, x: number, y: number, depth: number) { + const definition = firstPursuitRoleEffects[role]; + if (!definition) { + return Promise.resolve(); + } + const container = this.trackCombatObject(this.add.container(x, y)); + container.setDepth(depth); + container.setAlpha(0); + container.setScale(0.86); + + const outer = this.add.circle(0, 0, role === 'front' ? 42 : 38); + outer.setStrokeStyle(role === 'front' ? 5 : 3, definition.tone, 0.92); + const inner = role === 'front' + ? this.add.rectangle(0, 0, 38, 48, definition.tone, 0.14).setStrokeStyle(2, 0xe9fff2, 0.78) + : this.add.circle(0, 0, 20, definition.tone, 0.22).setStrokeStyle(2, 0xf8edc5, 0.72); + const spark = this.add.star(0, 0, role === 'support' ? 6 : 5, 7, 20, definition.tone, 0.88); + const badgeWidth = Math.max(116, label.length * 13 + 28); + const badge = this.add.rectangle(0, -58, badgeWidth, 28, 0x0b1218, 0.94); + badge.setStrokeStyle(2, definition.tone, 0.88); + const icon = this.createBattleUiIcon(-badgeWidth / 2 + 17, -58, definition.icon, 22); + const text = this.add.text(8, -58, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: '#fff2c8', + fontStyle: '700', + stroke: '#071018', + strokeThickness: 3 + }); + text.setOrigin(0.5); + container.add([outer, inner, spark, badge, icon, text]); + + this.tweens.add({ targets: container, alpha: 1, scale: 1, duration: 120, ease: 'Back.easeOut' }); + this.tweens.add({ targets: outer, scale: 1.55, alpha: 0.08, duration: 360, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: spark, angle: role === 'flank' ? 220 : -180, scale: 1.22, duration: 360, ease: 'Sine.easeInOut' }); + this.tweens.add({ + targets: container, + y: y - 8, + alpha: 0, + delay: 360, + duration: 240, + ease: 'Sine.easeIn', + onComplete: () => container.destroy() + }); + return this.delay(280); + } + + private showSortieSupportCombatEffect(result: SupportResult, x: number, y: number, depth: number) { + if (result.sortieHealBonus <= 0) { + return; + } + const outer = this.trackCombatObject(this.add.circle(x, y, 44)); + outer.setStrokeStyle(4, 0x83d6ff, 0.9); + outer.setDepth(depth); + const inner = this.trackCombatObject(this.add.circle(x, y, 25, 0x83d6ff, 0.18)); + inner.setDepth(depth - 1); + const spark = this.trackCombatObject(this.add.star(x, y, 6, 7, 22, 0xd9f1ff, 0.84)); + spark.setDepth(depth + 1); + this.tweens.add({ targets: outer, scale: 1.55, alpha: 0, duration: 230, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: inner, scale: 1.3, alpha: 0.02, duration: 230, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: spark, angle: 180, scale: 1.18, alpha: 0.08, duration: 230, ease: 'Sine.easeOut' }); + } + private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) { if (this.isRangedAttack(result)) { soundDirector.playArrowImpact(true, result.critical); @@ -14762,6 +15022,7 @@ export class BattleScene extends Phaser.Scene { const direction = this.directionFromDelta(x - fromX, y - fromY); const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y); const movementDuration = this.movementDuration(duration, movementDistance); + this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration); this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]); this.setUnitBasePosition(view, startX, startY); this.resetUnitSpritePose(view); @@ -15512,7 +15773,7 @@ export class BattleScene extends Phaser.Scene { this.refreshUnitLegibilityStyles(); soundDirector.playSelect(); - this.moveUnitView(unit, fromX, fromY, view, 180, toX, toY); + this.moveUnitView(unit, fromX, fromY, view, 180, toX, toY, false); this.clearMarkers(); this.showMoveRange(unit); this.renderUnitDetail( @@ -15529,7 +15790,8 @@ export class BattleScene extends Phaser.Scene { view = this.unitViews.get(unit.id), duration = 260, fromX = unit.x, - fromY = unit.y + fromY = unit.y, + showSortieTrail = true ) { if (!view) { return; @@ -15542,6 +15804,9 @@ export class BattleScene extends Phaser.Scene { const direction = this.directionFromDelta(x - fromX, y - fromY); const movementDistance = this.movementTileDistanceFrom(fromX, fromY, x, y); const movementDuration = this.movementDuration(duration, movementDistance); + if (showSortieTrail) { + this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration); + } this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]); this.setUnitBasePosition(view, startX, startY); this.resetUnitSpritePose(view); @@ -15574,6 +15839,62 @@ export class BattleScene extends Phaser.Scene { return this.movementTileDistanceFrom(unit.x, unit.y, x, y); } + private showSortieFlankMovementTrail( + unit: UnitData, + startX: number, + startY: number, + targetX: number, + targetY: number, + duration: number + ) { + if ( + this.phase === 'deployment' || + this.activeFaction !== 'ally' || + this.turnNumber > 2 || + this.firstPursuitRoleForUnit(unit) !== 'flank' || + (startX === targetX && startY === targetY) + ) { + return; + } + + const trail = this.add.graphics(); + trail.setDepth(7); + trail.lineStyle(7, 0xd8b15f, 0.2); + trail.lineBetween(startX, startY - 5, targetX, targetY - 5); + trail.lineStyle(2, 0xffefb3, 0.78); + trail.lineBetween(startX, startY - 5, targetX, targetY - 5); + if (this.mapMask) { + trail.setMask(this.mapMask); + } + + const sparks = [0.22, 0.48, 0.74].map((ratio, index) => { + const spark = this.add.star( + Phaser.Math.Linear(startX, targetX, ratio), + Phaser.Math.Linear(startY, targetY, ratio) - 5, + 4, + 3, + 8 + index, + index === 1 ? 0xfff1bd : 0xd8b15f, + 0.78 + ); + spark.setDepth(7.2); + if (this.mapMask) { + spark.setMask(this.mapMask); + } + this.tweens.add({ targets: spark, angle: 140 + index * 55, scale: 0.3, duration, ease: 'Sine.easeOut' }); + return spark; + }); + const objects: Phaser.GameObjects.GameObject[] = [trail, ...sparks]; + this.tweens.add({ + targets: objects, + alpha: 0, + delay: Math.round(duration * 0.28), + duration: Math.max(150, Math.round(duration * 0.72)), + ease: 'Sine.easeOut', + onComplete: () => objects.forEach((object) => object.destroy()) + }); + } + private movementTileDistanceFrom(fromX: number, fromY: number, x: number, y: number) { return Math.max(1, Math.abs(x - fromX) + Math.abs(y - fromY)); } @@ -16141,10 +16462,13 @@ export class BattleScene extends Phaser.Scene { return; } + const effectLine = [this.statusEffectMapText(result.statusApplied), this.combatSortieContributionText(result)] + .filter(Boolean) + .join(' · '); const lines = [ this.combatMapDamageTitle(result), `HP ${result.previousDefenderHp}→${result.defender.hp}`, - this.statusEffectMapText(result.statusApplied) + this.truncateUiText(effectLine, 28) ].filter(Boolean); this.showMapResultPopup( @@ -16169,11 +16493,15 @@ export class BattleScene extends Phaser.Scene { if (result.usable.effect === 'heal') { this.showMapResultPopup( result.target, - [`회복 +${result.healAmount}`, `HP ${result.previousTargetHp}→${result.target.hp}`], + [ + `회복 +${result.healAmount}`, + `HP ${result.previousTargetHp}→${result.target.hp}`, + result.sortieHealBonus > 0 ? `후원 편성 +${result.sortieHealBonus}` : '' + ], '#a8ffd0', '#071623', 18, - 28 + result.sortieHealBonus > 0 ? 36 : 28 ); return; } @@ -17876,6 +18204,7 @@ export class BattleScene extends Phaser.Scene { baseMove: unit.move, effectiveMove: this.movementAllowance(unit) })), + contributionTotals: this.firstPursuitContributionTotals(), contributionLine: this.firstPursuitContributionLine() ?? null }; } @@ -18031,7 +18360,15 @@ export class BattleScene extends Phaser.Scene { return; } - this.time.delayedCall(900, () => this.debugForceBattleOutcome(outcome)); + this.time.delayedCall(this.debugForcedBattleOutcomeDelay(), () => this.debugForceBattleOutcome(outcome)); + } + + private debugForcedBattleOutcomeDelay() { + if (typeof window === 'undefined') { + return 900; + } + const raw = Number(new URLSearchParams(window.location.search).get('debugForceBattleOutcomeDelay')); + return Number.isFinite(raw) ? Phaser.Math.Clamp(Math.round(raw), 900, 60000) : 900; } private debugForcedBattleOutcomeParam(): BattleOutcome | undefined { @@ -18092,6 +18429,7 @@ export class BattleScene extends Phaser.Scene { } this.input.keyboard?.on('keydown-F9', () => this.toggleDebugOverlay()); + this.input.keyboard?.on('keydown-F8', () => this.debugForceBattleOutcome('victory')); this.input.keyboard?.on('keydown-F10', () => { const state = this.getDebugState(); console.info('[Battle Debug]', state); @@ -18131,12 +18469,14 @@ export class BattleScene extends Phaser.Scene { ? `${this.pendingMove.unit.name}: (${this.pendingMove.fromX},${this.pendingMove.fromY}) -> (${this.pendingMove.toX},${this.pendingMove.toY})` : 'none'; const sortieProbe = this.debugFirstPursuitRoleMechanicsProbe(); + const sortieTotals = this.isFirstPursuitRoleEffectBattle() ? this.firstPursuitContributionTotals() : undefined; const sortieProbeLines = sortieProbe ? [ `role front in=${sortieProbe.front.incomingDamage}/${sortieProbe.front.incomingBaseline} counter=${sortieProbe.front.counterDamage}/${sortieProbe.front.counterBaseline}`, `role flank move=${sortieProbe.flank.effectiveMove}/${sortieProbe.flank.baseMove} attack=${sortieProbe.flank.firstTurnDamage}/${sortieProbe.flank.firstTurnBaseline}`, `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}` + `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}` ] : []; @@ -18149,7 +18489,7 @@ export class BattleScene extends Phaser.Scene { `acted=${Array.from(this.actedUnitIds).join(',') || 'none'}`, `pointerTile=${pointerTile ? `${pointerTile.x},${pointerTile.y}` : 'outside'}`, ...sortieProbeLines, - 'F9 toggle overlay / F10 log state' + 'F8 force victory / F9 overlay / F10 log state' ]); } } diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index c16440c..14f08ab 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -45,6 +45,10 @@ export type BattleSaveUnitStats = { defeats: number; actions: number; support: number; + sortieBonusDamage?: number; + sortiePreventedDamage?: number; + sortieBonusHealing?: number; + sortieExtendedBondTriggers?: number; }; export type BattleSaveState = { @@ -512,10 +516,18 @@ function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOpti isBattleUnitStatValue(stats.damageTaken) && isBattleUnitStatValue(stats.defeats) && isBattleUnitStatValue(stats.actions) && - isBattleUnitStatValue(stats.support) + isBattleUnitStatValue(stats.support) && + isOptionalBattleUnitStatValue(stats.sortieBonusDamage) && + isOptionalBattleUnitStatValue(stats.sortiePreventedDamage) && + isOptionalBattleUnitStatValue(stats.sortieBonusHealing) && + isOptionalBattleUnitStatValue(stats.sortieExtendedBondTriggers) ); } +function isOptionalBattleUnitStatValue(value: unknown) { + return value === undefined || isBattleUnitStatValue(value); +} + function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) { return ( value === undefined ||