From 9025d74398a4dc23de08e8df95edab0e53085364 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 11 Jul 2026 12:05:02 +0900 Subject: [PATCH] feat: add resonance follow-up attacks --- scripts/verify-battle-save-normalization.mjs | 18 + scripts/verify-release-candidate.mjs | 74 +++ src/game/scenes/BattleScene.ts | 452 +++++++++++++++++-- src/game/state/battleSaveState.ts | 7 +- 4 files changed, 522 insertions(+), 29 deletions(-) diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 76e4ee3..8d49a13 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -35,6 +35,23 @@ 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 validCompletedAttackHistoryState = { + ...validState, + actedUnitIds: ['liu-bei', 'guan-yu'], + attackIntents: [ + { attackerId: 'liu-bei', targetId: 'rebel-1' }, + { attackerId: 'guan-yu', targetId: 'rebel-1' } + ] + }; + assert( + isValidBattleSaveState(validCompletedAttackHistoryState, options), + 'Expected unique completed allied attacks against a living enemy to pass validation.' + ); + assert( + JSON.stringify(parseBattleSaveState(JSON.stringify(validCompletedAttackHistoryState), options)?.attackIntents) === + JSON.stringify(validCompletedAttackHistoryState.attackIntents), + 'Expected completed allied attack history to round-trip through battle-save parsing.' + ); assert( ['elite', 'mobile', 'siege'].every((sortieOrderId) => isValidBattleSaveState({ ...validState, sortieOrderId }, options)), 'Expected every sortie order id to pass battle-save validation.' @@ -218,6 +235,7 @@ try { ['unknown attack intent unit id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'ghost-unit' }] }], ['self-targeting attack intent', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'liu-bei' }] }], ['duplicate attack intent attacker id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'rebel-1' }, { attackerId: 'liu-bei', targetId: 'rebel-1' }] }], + ['unacted attack intent attacker', { actedUnitIds: [] }], ['enemy attack intent attacker', { attackIntents: [{ attackerId: 'rebel-1', targetId: 'liu-bei' }] }], ['ally attack intent target', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'guan-yu' }] }], ['defeated attack intent attacker', { units: patchUnit(0, { hp: 0 }) }], diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 967b72d..1e1a990 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -85,6 +85,80 @@ try { !firstBattleProbe.sideTexts.some((text) => text.includes('...')), `Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` ); + await setDebugQueryParam(page, 'debugForceBondChain', '1'); + const firstBattleBondChain = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const state = window.__HEROS_DEBUG__?.battle(); + const mechanics = state?.combatMechanicsProbe; + const attacker = mechanics?.attackerId ? scene?.debugUnitById(mechanics.attackerId) : undefined; + const partner = mechanics?.partnerId ? scene?.debugUnitById(mechanics.partnerId) : undefined; + const enemy = mechanics?.enemyId ? scene?.debugUnitById(mechanics.enemyId) : undefined; + if (!scene || !attacker || !partner || !enemy) { + return { chain: mechanics?.chain ?? null, forcedRoll: null, preview: null }; + } + + const originalIntents = scene.attackIntents.map((intent) => ({ ...intent })); + const originalActedUnitIds = new Set(scene.actedUnitIds); + scene.attackIntents = [{ attackerId: partner.id, targetId: enemy.id }]; + scene.actedUnitIds = new Set([...originalActedUnitIds, partner.id]); + const preview = scene.combatPreview(attacker, enemy, 'attack'); + scene.attackIntents = originalIntents; + scene.actedUnitIds = originalActedUnitIds; + + return { + chain: mechanics.chain ?? null, + forcedRoll: scene.debugBondChainRollOverride(), + preview: { + rate: preview.bondChainRate, + damage: preview.bondChainDamage, + bondId: preview.bondChainBondId ?? null, + partnerId: preview.bondChainPartnerId ?? null, + partnerName: preview.bondChainPartnerName ?? null, + label: preview.bondChainLabel ?? null + } + }; + }); + await setDebugQueryParam(page, 'debugForceBondChain', '0'); + const firstBattleBondChainForcedFailure = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return scene?.debugBondChainRollOverride() ?? null; + }); + await setDebugQueryParam(page, 'debugForceBondChain', null); + assert( + firstBattleBondChain.preview?.rate === 18 && + firstBattleBondChain.preview.damage > 0 && + firstBattleBondChain.preview.bondId && + firstBattleBondChain.preview.partnerId === firstBattleBondChain.chain?.expectedSupporterId && + firstBattleBondChain.preview.partnerName && + firstBattleBondChain.preview.label, + `Expected a complete level-72 resonance-pursuit preview contract: ${JSON.stringify(firstBattleBondChain)}` + ); + assert( + firstBattleBondChain.forcedRoll === true && + firstBattleBondChainForcedFailure === false && + firstBattleBondChain.chain?.rate === 18 && + firstBattleBondChain.chain.supportDamage === firstBattleBondChain.preview.damage && + firstBattleBondChain.chain.ineligibleWithoutPriorIntent === true && + firstBattleBondChain.chain.eligibleWithMatchingPriorIntent === true && + firstBattleBondChain.chain.strategyIgnored === true && + firstBattleBondChain.chain.counterIgnored === true && + firstBattleBondChain.chain.missedBaseBlocked === true && + firstBattleBondChain.chain.lethalBaseBlocked === true && + firstBattleBondChain.chain.survivingBaseAllowed === true && + firstBattleBondChain.chain.forcedSuccessTriggers === true && + firstBattleBondChain.chain.forcedFailureBlocked === true, + `Expected pursuit eligibility, hit/survival gates, and deterministic roll overrides: ${JSON.stringify(firstBattleBondChain)}` + ); + assert( + firstBattleBondChain.chain?.statCredit?.partnerDamage === firstBattleBondChain.chain.supportDamage && + firstBattleBondChain.chain.statCredit.partnerDefeats === 1 && + firstBattleBondChain.chain.statCredit.partnerActions === 0 && + firstBattleBondChain.chain.statCredit.defenderDamageTaken === firstBattleBondChain.chain.supportDamage && + firstBattleBondChain.chain.followUpKillCancelsCounter === true && + firstBattleBondChain.chain.intentClearedAtTurnReset === true, + `Expected pursuit damage and defeat credit to belong to the supporter, cancel counters on defeat, and reset next turn: ${JSON.stringify(firstBattleBondChain.chain)}` + ); + const firstBattleOrderHud = await assertLiveSortieOrderHud(page, { battleId: 'first-battle-zhuo-commandery', orderId: 'elite', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 1b4d642..d6b3c0d 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1282,6 +1282,25 @@ type BondCombatBonus = { extendedRange?: boolean; }; +type BondChainCandidate = { + bondId: string; + label: string; + partner: UnitData; + rate: number; + damage: number; +}; + +type BondChainResult = { + bondId: string; + label: string; + partnerId: string; + partnerName: string; + rate: number; + damage: number; + previousDefenderHp: number; + defeated: boolean; +}; + type UnitBattleStats = { damageDealt: number; damageTaken: number; @@ -1473,6 +1492,12 @@ type CombatPreview = { bondLabel?: string; bondPartnerIds: string[]; bondExtendedRange: boolean; + bondChainRate: number; + bondChainDamage: number; + bondChainBondId?: string; + bondChainPartnerId?: string; + bondChainPartnerName?: string; + bondChainLabel?: string; equipmentDamageBonus: number; equipmentDamageReduction: number; equipmentHitBonus: number; @@ -1506,6 +1531,7 @@ type CombatResult = CombatPreview & { hit: boolean; critical: boolean; isCounter: boolean; + baseDefeated: boolean; defeated: boolean; characterGrowth: CharacterGrowthResult; attackerGrowth: EquipmentGrowthResult; @@ -1518,6 +1544,7 @@ type CombatResult = CombatPreview & { initiativeBonusDamageAmount: number; initiativePreventedDamageAmount: number; initiativeReadied: boolean; + bondChain?: BondChainResult; counter?: CombatResult; }; @@ -9996,6 +10023,15 @@ export class BattleScene extends Phaser.Scene { preview.terrainAffinity !== 100 ? `적성${preview.terrainAffinity}%` : '' ].filter(Boolean); + if (preview.bondChainPartnerName && preview.bondChainRate > 0 && preview.bondChainDamage > 0) { + summaries.push({ + icon: 'leadership', + label: `추격·${preview.bondChainPartnerName}`, + value: `${preview.bondChainRate}% · +${preview.bondChainDamage}`, + tone: palette.gold + }); + } + if (preview.action === 'strategy') { summaries.push({ icon: 'intelligence', @@ -10276,6 +10312,15 @@ export class BattleScene extends Phaser.Scene { if (preview.bondDamageBonus > 0 && preview.bondLabel) { detailBadgeX = this.renderPreviewBadge(detailBadgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight); } + if (preview.bondChainPartnerName && preview.bondChainRate > 0) { + detailBadgeX = this.renderPreviewBadge( + detailBadgeX, + badgeY, + 'leadership', + `추격 ${preview.bondChainPartnerName} ${preview.bondChainRate}% +${preview.bondChainDamage}`, + maxBadgeRight + ); + } if (preview.equipmentEffectLabels.length > 0) { this.renderPreviewBadge(detailBadgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight); } @@ -13114,6 +13159,7 @@ export class BattleScene extends Phaser.Scene { const attackPower = this.actionPower(attacker, action, usable); const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus); const bondBonus = this.activeBondCombatBonus(attacker, defender, action, isCounter); + const bondChainCandidate = this.activeBondChainCandidate(attacker, defender, action, isCounter); const equipmentEffect = this.combatEquipmentEffect(attacker, defender, action, usable, bondBonus); const sortieEffect = this.sortieRoleCombatEffect(attacker, defender, action, isCounter, ignoreSortieEffects); const initiativeEffect = this.tacticalInitiativeCombatEffect( @@ -13219,6 +13265,12 @@ export class BattleScene extends Phaser.Scene { bondLabel: bondBonus.label, bondPartnerIds: bondBonus.partnerIds, bondExtendedRange: Boolean(bondBonus.extendedRange), + bondChainRate: bondChainCandidate?.rate ?? 0, + bondChainDamage: bondChainCandidate?.damage ?? 0, + bondChainBondId: bondChainCandidate?.bondId, + bondChainPartnerId: bondChainCandidate?.partner.id, + bondChainPartnerName: bondChainCandidate?.partner.name, + bondChainLabel: bondChainCandidate?.label, equipmentDamageBonus: equipmentEffect.damageBonus, equipmentDamageReduction: equipmentEffect.damageReduction, equipmentHitBonus: equipmentEffect.hitBonus, @@ -13294,6 +13346,79 @@ export class BattleScene extends Phaser.Scene { stats.sortieBonusHealing += sortieBonusHealing; } + private recordBondChainStats(partner: UnitData, defender: UnitData, damage: number, defeated: boolean) { + const credit = this.bondChainStatCredit(damage, defeated); + const partnerStats = this.statsFor(partner.id); + partnerStats.damageDealt += credit.partnerDamage; + partnerStats.defeats += credit.partnerDefeats; + + this.statsFor(defender.id).damageTaken += credit.defenderDamageTaken; + } + + private bondChainStatCredit(damage: number, defeated: boolean) { + return { + partnerDamage: Math.max(0, damage), + partnerDefeats: defeated ? 1 : 0, + partnerActions: 0, + defenderDamageTaken: Math.max(0, damage) + }; + } + + private canResolveBondChain(preview: CombatPreview, hit: boolean, defenderHp: number) { + return Boolean( + hit && + defenderHp > 0 && + preview.action === 'attack' && + preview.bondChainRate > 0 && + preview.bondChainDamage > 0 && + preview.bondChainPartnerId + ); + } + + private debugBondChainRollOverride() { + if (!this.debugToolsEnabled() || typeof window === 'undefined') { + return undefined; + } + const value = new URLSearchParams(window.location.search).get('debugForceBondChain'); + return value === '1' ? true : value === '0' ? false : undefined; + } + + private bondChainRollSucceeded(rate: number, forcedRoll = this.debugBondChainRollOverride()) { + return forcedRoll ?? this.rollPercent(rate); + } + + private resolveBondChainFollowUp(preview: CombatPreview, hit: boolean): BondChainResult | undefined { + if (!this.canResolveBondChain(preview, hit, preview.defender.hp)) { + return undefined; + } + + const partner = battleUnits.find((unit) => unit.id === preview.bondChainPartnerId && unit.hp > 0); + if (!partner) { + return undefined; + } + if (!this.bondChainRollSucceeded(preview.bondChainRate)) { + return undefined; + } + + const previousDefenderHp = preview.defender.hp; + const damage = Phaser.Math.Clamp(preview.bondChainDamage, 1, previousDefenderHp); + preview.defender.hp = Math.max(0, preview.defender.hp - damage); + const defeated = preview.defender.hp <= 0; + this.recordBondChainStats(partner, preview.defender, damage, defeated); + this.faceUnitToward(partner, preview.defender); + + return { + bondId: preview.bondChainBondId ?? '', + label: preview.bondChainLabel ?? '공명 연계', + partnerId: partner.id, + partnerName: partner.name, + rate: preview.bondChainRate, + damage, + previousDefenderHp, + defeated + }; + } + private resolveCombatAction(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false, usable?: BattleUsable): CombatResult { const preview = this.combatPreview(attacker, defender, action, usable, isCounter); const sortieOnlyPreview = this.combatPreview(attacker, defender, action, usable, isCounter, false, true); @@ -13316,10 +13441,11 @@ export class BattleScene extends Phaser.Scene { 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)); - const bondGrowth = attacker.faction === 'ally' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : []; + const bondGrowth = attacker.faction === 'ally' && action === 'attack' && !isCounter ? this.recordBondAttackIntent(attacker, defender) : []; const bondExp = bondGrowth.reduce((total, growth) => total + growth.amount, 0); defender.hp = Math.max(0, defender.hp - damage); + const baseDefeated = defender.hp <= 0; const statusApplied = hit && defender.hp > 0 && usable?.statusEffect ? this.applyBattleStatus( @@ -13345,14 +13471,18 @@ export class BattleScene extends Phaser.Scene { initiativeBonusDamageAmount, initiativePreventedDamageAmount ); + const bondChain = !isCounter ? this.resolveBondChainFollowUp(preview, hit) : undefined; this.faceUnitToward(attacker, defender); if (hit) { - this.flashDamage(defender, damage, critical, previousDefenderHp); + this.flashDamage(defender, damage, critical, previousDefenderHp, bondChain?.previousDefenderHp); } else { this.flashMiss(defender, this.missFeedbackLabel(action, usable)); } if (defender.hp <= 0) { + this.attackIntents = this.attackIntents.filter( + (intent) => intent.targetId !== defender.id && intent.attackerId !== defender.id + ); this.applyDefeatedStyle(defender); } else { this.syncUnitMotion(defender); @@ -13367,6 +13497,7 @@ export class BattleScene extends Phaser.Scene { hit, critical, isCounter, + baseDefeated, defeated: defender.hp <= 0, characterGrowth, attackerGrowth, @@ -13378,7 +13509,8 @@ export class BattleScene extends Phaser.Scene { sortiePreventedDamageAmount, initiativeBonusDamageAmount, initiativePreventedDamageAmount, - initiativeReadied + initiativeReadied, + bondChain }; } @@ -13660,8 +13792,11 @@ export class BattleScene extends Phaser.Scene { const summaryInitiative = initiativeContribution ? `\n전술 명령: ${initiativeContribution}` : ''; const summaryEquipment = result.equipmentEffectLabels.length > 0 ? `\n장비 효과: ${result.equipmentEffectLabels.join(', ')}` : ''; const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; + const summaryBondChain = result.bondChain + ? `\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 퇴각' : ''}` + : ''; const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; - 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}`; + return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`; } @@ -13701,7 +13836,7 @@ export class BattleScene extends Phaser.Scene { } const critical = result.critical ? '치명 ' : ''; - const defeated = result.defeated ? ' · 퇴각' : ''; + const defeated = result.baseDefeated ? ' · 퇴각' : ''; const status = result.statusApplied ? ` · ${this.statusEffectMapText(result.statusApplied)}` : ''; return `${critical}피해 ${result.damage}${defeated}${status}`; } @@ -13716,7 +13851,7 @@ export class BattleScene extends Phaser.Scene { ? `치명 ${damage} 피해` : `${damage} 피해`; const hpLine = result.hit ? ` · HP ${result.previousDefenderHp}→${result.defender.hp}` : ''; - const defeated = result.defeated ? ' · 퇴각' : ''; + const defeated = result.baseDefeated ? ' · 퇴각' : ''; const status = result.statusApplied ? ` · ${this.statusEffectMapText(result.statusApplied)}` : ''; return `${result.attacker.name} ${this.combatActionName(result)} → ${result.defender.name}: ${outcome}${hpLine}${defeated}${status}`; } @@ -16038,8 +16173,33 @@ export class BattleScene extends Phaser.Scene { } const outcomeCard = this.renderCombatOutcomeCard(result, left + panelWidth / 2 - 226, top + 184, 452, depth + 16); - await this.animateCombatGauge(defenderStatus.hpFill, result.previousDefenderHp / result.defender.maxHp, result.defender.hp / result.defender.maxHp, 420); - defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`); + const hpAfterBaseAttack = result.bondChain?.previousDefenderHp ?? result.defender.hp; + await this.animateCombatGauge( + defenderStatus.hpFill, + result.previousDefenderHp / result.defender.maxHp, + hpAfterBaseAttack / result.defender.maxHp, + 420 + ); + defenderStatus.hpText.setText(`${hpAfterBaseAttack} / ${result.defender.maxHp}`); + + if (result.bondChain) { + await this.showBondChainCombatEffect( + result, + defenderSprite, + defenderX, + groundY, + left + panelWidth / 2, + top + 142, + depth + 19 + ); + await this.animateCombatGauge( + defenderStatus.hpFill, + result.bondChain.previousDefenderHp / result.defender.maxHp, + result.defender.hp / result.defender.maxHp, + 300 + ); + defenderStatus.hpText.setText(`${result.defender.hp} / ${result.defender.maxHp}`); + } await this.animateGrowthGauge( attackerStatus.expFill, @@ -16073,7 +16233,7 @@ export class BattleScene extends Phaser.Scene { private renderCombatOutcomeSummaryCard(result: CombatResult, x: number, y: number, width: number, depth: number) { const height = 96; - const icon: BattleUiIconKey = result.defeated + const icon: BattleUiIconKey = result.baseDefeated ? 'critical' : !result.hit ? 'move' @@ -16105,15 +16265,15 @@ export class BattleScene extends Phaser.Scene { fixedWidth: 150 }); - const detail = result.defeated + const detail = result.baseDefeated ? `${result.defender.name} 퇴각` : result.hit - ? `${result.defender.name} HP ${result.previousDefenderHp} → ${result.defender.hp}` + ? `${result.defender.name} HP ${result.previousDefenderHp} → ${result.bondChain?.previousDefenderHp ?? result.defender.hp}` : `${result.defender.name} HP 유지 ${result.defender.hp}/${result.defender.maxHp}`; const detailText = this.add.text(86, 39, detail, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', - color: result.defeated ? '#ffb6a6' : '#c8d2dd', + color: result.baseDefeated ? '#ffb6a6' : '#c8d2dd', fontStyle: '700', fixedWidth: 190 }); @@ -16160,7 +16320,7 @@ export class BattleScene extends Phaser.Scene { } private combatOutcomeTitle(result: CombatResult) { - if (result.defeated) { + if (result.baseDefeated) { return '퇴각'; } if (!result.hit) { @@ -16173,7 +16333,7 @@ export class BattleScene extends Phaser.Scene { } private combatOutcomeTone(result: CombatResult) { - return result.defeated || result.critical ? 0xd8732c : result.hit ? this.previewAccentColor(result) : 0x83d6ff; + return result.baseDefeated || result.critical ? 0xd8732c : result.hit ? this.previewAccentColor(result) : 0x83d6ff; } private combatJudgementChipLabel(result: CombatResult) { @@ -16188,9 +16348,12 @@ export class BattleScene extends Phaser.Scene { } private combatOutcomeExtraChip(result: CombatResult): { icon: BattleUiIconKey; label: string; tone: number } | undefined { - if (result.defeated) { + if (result.baseDefeated) { return { icon: 'counter', label: '퇴각', tone: 0xd8732c }; } + if (result.bondChain) { + return { icon: 'leadership', label: `추격 +${result.bondChain.damage}`, tone: palette.gold }; + } if (this.combatCounterWillTrigger(result)) { return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 }; } @@ -16377,7 +16540,7 @@ export class BattleScene extends Phaser.Scene { this.renderCombatRibbonBadge(x + 46, y + 38, this.actionIcon(result), this.previewActionTypeLabel(result), depth + 1, 78); this.renderCombatRibbonBadge(x + 130, y + 38, outcomeIcon, outcomeLabel, depth + 1, 62); this.renderCombatRibbonBadge(x + 198, y + 38, 'advantage', result.matchupLabel, depth + 1, 76); - if (result.defeated) { + if (result.baseDefeated) { this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '퇴각', depth + 1, 58); } else if (this.combatCounterWillTrigger(result) || result.counter) { this.renderCombatRibbonBadge(x + 280, y + 38, 'counter', '반격', depth + 1, 58); @@ -17900,7 +18063,8 @@ export class BattleScene extends Phaser.Scene { return Promise.resolve(); } - const partnerUnits = result.bondPartnerIds + const effectPartnerIds = result.bondChain ? [result.bondChain.partnerId] : result.bondPartnerIds; + const partnerUnits = effectPartnerIds .map((partnerId) => battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0)) .filter((unit): unit is UnitData => Boolean(unit)); const visiblePartners = partnerUnits.filter((unit) => this.isTileVisible(unit.x, unit.y) && this.unitViews.has(unit.id)); @@ -17941,7 +18105,11 @@ export class BattleScene extends Phaser.Scene { objects.push(this.createBondBadge( attackerView.sprite.x, attackerView.sprite.y - this.layout.tileSize * 0.96, - result.bondExtendedRange ? '삼재진 · 2칸 공명' : '연계 발동', + result.bondChain + ? `공명 추격 · ${result.bondChain.partnerName}` + : result.bondExtendedRange + ? '삼재진 · 2칸 공명' + : '공명 강화', 36 )); @@ -18004,12 +18172,90 @@ export class BattleScene extends Phaser.Scene { this.tweens.add({ targets: aura, scale: 1.7, alpha: 0, duration: 560, ease: 'Sine.easeOut' }); this.tweens.add({ targets: inner, scale: 1.35, alpha: 0.04, duration: 560, ease: 'Sine.easeOut' }); - this.tweens.add({ targets: spark, angle: 180, scale: 1.22, duration: 560, ease: 'Sine.easeInOut' }); + this.tweens.add({ targets: spark, angle: 180, scale: 1.22, alpha: 0, duration: 560, ease: 'Sine.easeInOut' }); this.tweens.add({ targets: text, y: textY - 8, scale: 1.05, duration: 180, yoyo: true, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: text, alpha: 0, duration: 180, delay: 360, ease: 'Sine.easeIn' }); return this.delay(580); } + private async showBondChainCombatEffect( + result: CombatResult, + defenderSprite: Phaser.GameObjects.Sprite, + defenderX: number, + groundY: number, + textX: number, + textY: number, + depth: number + ) { + const chain = result.bondChain; + const partner = chain ? battleUnits.find((unit) => unit.id === chain.partnerId) : undefined; + if (!chain || !partner) { + return; + } + + soundDirector.playEffect('bond-resonance', { volume: 0.3, rate: 1.08, stopAfterMs: 780 }); + + const entryX = defenderX - 230; + const strikeX = defenderX - 82; + const supporterSprite = this.trackCombatObject( + this.add.sprite( + entryX, + groundY, + this.unitActionTexture(partner), + this.unitActionFrameIndex('east', this.actionPoseForCommand('attack')) + ) + ); + supporterSprite.setDepth(depth); + supporterSprite.setAlpha(0); + this.applyCombatSpriteDisplaySize(supporterSprite, partner); + this.applyActionSpriteBlend(supporterSprite); + + const banner = this.trackCombatObject(this.add.text( + textX, + textY, + `공명 추격 ${chain.partnerName}\n추가 피해 +${chain.damage}`, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '20px', + color: '#fff2b8', + fontStyle: '700', + align: 'center', + stroke: '#2b1606', + strokeThickness: 5, + lineSpacing: 2 + } + )); + banner.setOrigin(0.5); + banner.setDepth(depth + 3); + banner.setScale(0.88); + banner.setAlpha(0); + + const trail = this.trackCombatObject(this.add.graphics()); + trail.setDepth(depth - 1); + trail.lineStyle(7, 0xffdf7b, 0.7); + trail.beginPath(); + trail.moveTo(entryX, groundY - 40); + trail.lineTo(defenderX - 36, groundY - 40); + trail.strokePath(); + trail.setAlpha(0); + + this.tweens.add({ targets: [supporterSprite, banner, trail], alpha: 1, duration: 120, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: banner, scale: 1, y: textY - 5, duration: 180, ease: 'Back.easeOut' }); + this.tweens.add({ targets: supporterSprite, x: strikeX, duration: 230, ease: 'Cubic.easeIn' }); + await this.delay(240); + + const impact = this.trackCombatObject(this.add.star(defenderX - 24, groundY - 46, 8, 18, 52, 0xfff0b8, 0.94)); + impact.setDepth(depth + 2); + defenderSprite.setTintFill(0xffe0a3); + this.tweens.add({ targets: impact, scale: 1.55, angle: 90, alpha: 0, duration: 300, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: defenderSprite, x: defenderX + 14, duration: 90, yoyo: true, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: supporterSprite, x: entryX + 30, alpha: 0, duration: 260, delay: 80, ease: 'Sine.easeIn' }); + await this.delay(220); + defenderSprite.clearTint(); + await this.delay(180); + } + private showSortieAttackCombatEffect(result: CombatResult, x: number, y: number, depth: number) { if (result.initiativeBonusDamageAmount > 0) { return this.showSortieCombatCue('flank', `맹진 호령 +${result.initiativeBonusDamageAmount}`, x, y, depth); @@ -18368,7 +18614,7 @@ export class BattleScene extends Phaser.Scene { this.activeFaction = 'ally'; this.fastForwardHeld = false; this.actedUnitIds.clear(); - this.attackIntents = []; + this.attackIntents = this.emptyAllyTurnAttackIntents(); const recoveryMessage = this.applyTerrainRecovery('ally'); this.resetActedStyles(); this.updateTurnText(); @@ -18398,6 +18644,10 @@ export class BattleScene extends Phaser.Scene { } } + private emptyAllyTurnAttackIntents(): AttackIntent[] { + return []; + } + private applyTerrainRecovery(faction: ActiveFaction) { const messages: string[] = []; @@ -18818,6 +19068,75 @@ export class BattleScene extends Phaser.Scene { }); } + private bondChainPartnerEligible( + attacker: UnitData, + partner: UnitData, + defender: UnitData, + bond: BondState, + action: DamageCommand, + isCounter: boolean, + intents: AttackIntent[] = this.attackIntents, + actedUnitIds: ReadonlySet = this.actedUnitIds + ) { + if ( + isCounter || + action !== 'attack' || + attacker.faction !== 'ally' || + partner.faction !== attacker.faction || + defender.faction === attacker.faction || + attacker.hp <= 0 || + partner.hp <= 0 || + defender.hp <= 0 || + sortieBondBonusForLevel(bond.level).chainRate <= 0 || + !actedUnitIds.has(partner.id) || + !intents.some((intent) => intent.attackerId === partner.id && intent.targetId === defender.id) + ) { + return false; + } + + return this.bondPartnerJoinState(attacker, partner, defender).canJoin; + } + + private bondChainSupportDamage(partner: UnitData, defender: UnitData) { + const supportPreview = this.combatPreview(partner, defender, 'attack', undefined, true, false, true); + return Phaser.Math.Clamp(supportPreview.damage, 1, defender.maxHp); + } + + private activeBondChainCandidate(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false) { + if (isCounter || action !== 'attack' || attacker.faction !== 'ally' || defender.faction === attacker.faction) { + return undefined; + } + + const candidates = Array.from(this.bondStates.values()) + .map((bond) => { + if (!bond.unitIds.includes(attacker.id)) { + return undefined; + } + const partnerId = bond.unitIds.find((unitId) => unitId !== attacker.id); + const partner = partnerId ? battleUnits.find((unit) => unit.id === partnerId && unit.hp > 0) : undefined; + if (!partner || !this.bondChainPartnerEligible(attacker, partner, defender, bond, action, isCounter)) { + return undefined; + } + return { bond, partner }; + }) + .filter((candidate): candidate is { bond: BondState; partner: UnitData } => Boolean(candidate)) + .sort((left, right) => right.bond.level - left.bond.level || left.bond.id.localeCompare(right.bond.id)); + + const strongest = candidates[0]; + if (!strongest) { + return undefined; + } + + const [first, second] = strongest.bond.unitIds.map((id) => this.unitName(id)); + return { + bondId: strongest.bond.id, + label: `${strongest.bond.title}: ${first}·${second}`, + partner: strongest.partner, + rate: sortieBondBonusForLevel(strongest.bond.level).chainRate, + damage: this.bondChainSupportDamage(strongest.partner, defender) + } satisfies BondChainCandidate; + } + private activeBondCombatBonus(attacker: UnitData, defender: UnitData, action: DamageCommand, isCounter = false): BondCombatBonus { if (isCounter || action !== 'attack' || attacker.faction !== 'ally') { return { damageBonus: 0, chainRate: 0, partnerIds: [] }; @@ -19717,14 +20036,14 @@ export class BattleScene extends Phaser.Scene { this.syncUnitMotion(unit, view, direction); } - private flashDamage(unit: UnitData, damage: number, critical = false, previousHp?: number) { + private flashDamage(unit: UnitData, damage: number, critical = false, previousHp?: number, displayedHp = unit.hp) { const view = this.unitViews.get(unit.id); if (!view) { return; } - const title = unit.hp <= 0 ? `${critical ? '치명 ' : ''}퇴각 -${damage}` : `${critical ? '치명 ' : '피해 '}-${damage}`; - const detail = previousHp === undefined ? '' : `HP ${previousHp}→${unit.hp}`; + const title = displayedHp <= 0 ? `${critical ? '치명 ' : ''}퇴각 -${damage}` : `${critical ? '치명 ' : '피해 '}-${damage}`; + const detail = previousHp === undefined ? '' : `HP ${previousHp}→${displayedHp}`; this.showMapResultPopup(unit, [title, detail], critical ? '#ff8f5f' : '#ffdf7b', '#220909', critical ? 20 : 18, critical ? 34 : 30); view.sprite.setTintFill(0xffe0a3); @@ -19787,6 +20106,7 @@ export class BattleScene extends Phaser.Scene { } const effectLine = [ + result.bondChain ? `공명 추격 ${result.bondChain.partnerName} +${result.bondChain.damage}` : '', this.statusEffectMapText(result.statusApplied), this.combatSortieContributionText(result), this.combatInitiativeContributionText(result) @@ -19810,8 +20130,12 @@ export class BattleScene extends Phaser.Scene { } private combatMapDamageTitle(result: CombatResult) { + if (result.bondChain) { + const totalDamage = result.damage + result.bondChain.damage; + return result.bondChain.defeated ? `공명 퇴각 -${totalDamage}` : `합동 피해 -${totalDamage}`; + } const damage = `-${result.damage}`; - if (result.defeated) { + if (result.baseDefeated) { return `${result.critical ? '치명 ' : ''}퇴각 ${damage}`; } return `${result.critical ? '치명 ' : '피해 '}${damage}`; @@ -21825,7 +22149,7 @@ export class BattleScene extends Phaser.Scene { private debugCombatMechanicsProbe() { const bond = Array.from(this.bondStates.values()).find((candidate) => { const units = candidate.unitIds.map((unitId) => battleUnits.find((unit) => unit.id === unitId && unit.hp > 0)); - return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && sortieBondBonusForLevel(candidate.level).damageBonus > 0; + return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && sortieBondBonusForLevel(candidate.level).chainRate > 0; }); const attacker = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[0] && unit.hp > 0) : undefined; const partner = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[1] && unit.hp > 0) : undefined; @@ -21847,11 +22171,28 @@ export class BattleScene extends Phaser.Scene { return null; } - const adjacentPreview = this.combatPreview({ ...attacker, x: nearX, y: partner.y }, { ...enemy, x: edgeX, y: partner.y }, 'attack'); + const syntheticAttacker = { ...attacker, x: nearX, y: partner.y }; + const syntheticPartner = { ...partner }; + const syntheticEnemy = { ...enemy, x: edgeX, y: partner.y }; + const adjacentPreview = this.combatPreview(syntheticAttacker, syntheticEnemy, 'attack'); const sameTargetPreview = this.combatPreview({ ...attacker, x: edgeX, y: partner.y }, { ...enemy, x: nearX, y: partner.y }, 'attack'); const farPreview = this.combatPreview({ ...attacker, x: farAttackerX, y: partner.y }, { ...enemy, x: farDefenderX, y: partner.y }, 'attack'); const normalIncomingPreview = this.combatPreview(enemy, attacker, 'attack'); const counterIncomingPreview = this.combatPreview(enemy, attacker, 'attack', undefined, true); + const matchingIntent = [{ attackerId: syntheticPartner.id, targetId: syntheticEnemy.id }]; + const syntheticActedUnitIds = new Set([syntheticPartner.id]); + const chainBonus = sortieBondBonusForLevel(bond.level); + const supportDamage = this.bondChainSupportDamage(partner, enemy); + const chainGatePreview: CombatPreview = { + ...adjacentPreview, + bondChainRate: chainBonus.chainRate, + bondChainDamage: supportDamage, + bondChainBondId: bond.id, + bondChainPartnerId: syntheticPartner.id, + bondChainPartnerName: syntheticPartner.name, + bondChainLabel: bond.title + }; + const statCredit = this.bondChainStatCredit(supportDamage, true); return { bondId: bond.id, @@ -21865,7 +22206,64 @@ export class BattleScene extends Phaser.Scene { farBondDamageBonus: farPreview.bondDamageBonus, farPartnerIds: farPreview.bondPartnerIds, normalDamage: normalIncomingPreview.damage, - counterDamage: counterIncomingPreview.damage + counterDamage: counterIncomingPreview.damage, + chain: { + rate: chainBonus.chainRate, + expectedSupporterId: syntheticPartner.id, + supportDamage, + ineligibleWithoutPriorIntent: !this.bondChainPartnerEligible( + syntheticAttacker, + syntheticPartner, + syntheticEnemy, + bond, + 'attack', + false, + [], + syntheticActedUnitIds + ), + eligibleWithMatchingPriorIntent: this.bondChainPartnerEligible( + syntheticAttacker, + syntheticPartner, + syntheticEnemy, + bond, + 'attack', + false, + matchingIntent, + syntheticActedUnitIds + ), + strategyIgnored: !this.bondChainPartnerEligible( + syntheticAttacker, + syntheticPartner, + syntheticEnemy, + bond, + 'strategy', + false, + matchingIntent, + syntheticActedUnitIds + ), + counterIgnored: !this.bondChainPartnerEligible( + syntheticAttacker, + syntheticPartner, + syntheticEnemy, + bond, + 'attack', + true, + matchingIntent, + syntheticActedUnitIds + ), + missedBaseBlocked: !this.canResolveBondChain(chainGatePreview, false, syntheticEnemy.hp), + lethalBaseBlocked: !this.canResolveBondChain(chainGatePreview, true, 0), + survivingBaseAllowed: this.canResolveBondChain(chainGatePreview, true, syntheticEnemy.hp), + forcedSuccessTriggers: this.bondChainRollSucceeded(chainBonus.chainRate, true), + forcedFailureBlocked: !this.bondChainRollSucceeded(chainBonus.chainRate, false), + statCredit, + followUpKillCancelsCounter: !this.canCounterAttack( + { ...syntheticEnemy, hp: 0 }, + syntheticAttacker, + 'attack' + ), + intentClearedAtTurnReset: this.emptyAllyTurnAttackIntents().length === 0 + } }; } diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index b875fea..f2c987e 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -196,7 +196,7 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } - if (!areAttackIntentsTargetingLiveUnits(attackIntents, units, options)) { + if (!areAttackIntentsTargetingLiveUnits(attackIntents, state.actedUnitIds, units, options)) { return false; } @@ -282,7 +282,7 @@ function isSortieOrderId(value: unknown): value is SortieOrderId { return value === 'elite' || value === 'mobile' || value === 'siege'; } -function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { +function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions): value is string[] { return ( Array.isArray(value) && value.length <= unitReferenceLimit(options) && @@ -348,14 +348,17 @@ function areSavedBattleUnitsValid(units: unknown[], options: BattleSaveValidatio function areAttackIntentsTargetingLiveUnits( attackIntents: BattleSaveAttackIntent[], + actedUnitIds: string[], units: SavedBattleUnitState[], options: BattleSaveValidationOptions ) { const liveUnitIds = new Set(units.filter((unit) => unit.hp > 0).map((unit) => unit.id)); + const actedUnitIdSet = new Set(actedUnitIds); return attackIntents.every( (intent) => liveUnitIds.has(intent.attackerId) && liveUnitIds.has(intent.targetId) && + actedUnitIdSet.has(intent.attackerId) && (!options.validAllyUnitIds || options.validAllyUnitIds.has(intent.attackerId)) && (!options.validEnemyUnitIds || options.validEnemyUnitIds.has(intent.targetId)) );