From 26ef353a7f69c7b36e9bc47b3fbe3f4bd0bb6c47 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 11:02:29 +0900 Subject: [PATCH] feat: reward enemy intent counterplay --- scripts/verify-battle-save-normalization.mjs | 10 +- src/game/scenes/BattleScene.ts | 387 +++++++++++++++++-- src/game/state/battleSaveState.ts | 8 +- 3 files changed, 364 insertions(+), 41 deletions(-) diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index ad84d4a..73df4be 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -43,11 +43,14 @@ try { sortieBonusDamage: 4, sortiePreventedDamage: 3, sortieBonusHealing: 2, - sortieExtendedBondTriggers: 1 + sortieExtendedBondTriggers: 1, + intentDefeats: 1, + intentLineBreaks: 2, + intentGuardSuccesses: 3 } } }; - assert(isValidBattleSaveState(validSortieStatsState, options), 'Expected sortie contribution stats to pass validation.'); + assert(isValidBattleSaveState(validSortieStatsState, options), 'Expected sortie and counterplay 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.'); @@ -137,6 +140,9 @@ try { ['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 } } }], + ['invalid counterplay stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], intentLineBreaks: -1 } } }], + ['invalid counterplay stats precision', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], intentGuardSuccesses: 1.5 } } }], + ['too high counterplay stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], intentDefeats: 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 1378947..877f406 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1215,6 +1215,8 @@ type PendingMove = { fromY: number; toX: number; toY: number; + intentBefore: EnemyIntentSnapshot[]; + intentAfterMove: EnemyIntentSnapshot[]; }; type AttackIntent = { @@ -1244,6 +1246,9 @@ type UnitBattleStats = { sortiePreventedDamage: number; sortieBonusHealing: number; sortieExtendedBondTriggers: number; + intentDefeats: number; + intentLineBreaks: number; + intentGuardSuccesses: number; }; type ThreatTile = { @@ -1508,6 +1513,33 @@ type EnemyIntentVisual = { offsetY: number; }; +type EnemyIntentSnapshot = { + enemyId: string; + kind: EnemyActionPlanKind; + targetId?: string; + usableId?: string; + damage: number; + hitRate: number; +}; + +type IntentCounterplayKind = 'defeat' | 'line-break' | 'guard'; + +type IntentCounterplayEvent = { + turn: number; + kind: IntentCounterplayKind; + actorId: string; + enemyId: string; + beforeKind?: EnemyActionPlanKind; + beforeTargetId?: string; + afterKind?: EnemyActionPlanKind; + afterTargetId?: string; + preventedDamage?: number; +}; + +type IntentCounterplayActionOutcome = { + defeatedEnemyIds?: string[]; +}; + type BattleStatusKind = 'burn' | 'confusion'; type BattleStatusState = { @@ -3139,6 +3171,7 @@ export class BattleScene extends Phaser.Scene { private attackIntents: AttackIntent[] = []; private enemyIntentForecasts = new Map(); private enemyIntentVisuals: EnemyIntentVisual[] = []; + private intentCounterplayEvents: IntentCounterplayEvent[] = []; private battleLog: string[] = []; private enemyHomeTiles = new Map(); private battleStats = new Map(); @@ -3208,6 +3241,7 @@ export class BattleScene extends Phaser.Scene { this.actedUnitIds.clear(); this.attackIntents = []; this.clearEnemyIntentForecast(); + this.intentCounterplayEvents = []; this.approachPathCostCache.clear(); this.selectedUnit = undefined; this.pendingMove = undefined; @@ -3453,7 +3487,14 @@ export class BattleScene extends Phaser.Scene { private applyDebugBattleSetup() { const setupKey = this.debugBattleSetupKey(); - if (setupKey !== 'attack-preview' && setupKey !== 'status-ui' && setupKey !== 'sortie-feedback') { + if ( + setupKey !== 'attack-preview' && + setupKey !== 'status-ui' && + setupKey !== 'sortie-feedback' && + setupKey !== 'counterplay-block' && + setupKey !== 'counterplay-break' && + setupKey !== 'counterplay-victory' + ) { return; } @@ -3462,11 +3503,34 @@ export class BattleScene extends Phaser.Scene { ? 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); + : setupKey === 'counterplay-victory' + ? battleUnits.find((unit) => unit.id === leaderUnitId && unit.hp > 0) + : battleUnits.find((unit) => unit.id === 'pursuit-raider-a' && unit.hp > 0) + ?? battleUnits.find((unit) => unit.id === 'rebel-a' && unit.hp > 0) + ?? battleUnits.find((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!attacker || !target) { return; } + if (setupKey === 'counterplay-block' && this.isFirstPursuitRoleEffectBattle()) { + const liuBei = battleUnits.find((unit) => unit.id === 'liu-bei' && unit.hp > 0); + const zhangFei = battleUnits.find((unit) => unit.id === 'zhang-fei' && unit.hp > 0); + attacker.x = 3; + attacker.y = 15; + attacker.move = Math.max(attacker.move, 4); + target.x = 4; + target.y = 15; + if (liuBei) { + liuBei.x = 0; + liuBei.y = 19; + } + if (zhangFei) { + zhangFei.x = 2; + zhangFei.y = 19; + } + return; + } + const targetTile = this.debugAdjacentOpenTile(attacker, target.id); if (!targetTile) { return; @@ -3477,6 +3541,8 @@ export class BattleScene extends Phaser.Scene { if (setupKey === 'sortie-feedback') { target.maxHp = Math.max(target.maxHp, 80); target.hp = target.maxHp; + } else if (setupKey === 'counterplay-break' || setupKey === 'counterplay-victory') { + target.hp = 1; } else { target.hp = Math.max(1, target.hp); } @@ -3697,13 +3763,14 @@ export class BattleScene extends Phaser.Scene { } const parts = this.firstPursuitRoleAssignments().map(({ role, unit }) => { const stats = this.statsFor(unit.id); + const counterplay = this.intentCounterplayContributionText(stats); if (role === 'front') { - return `전열 ${unit.name}: 감소 ${stats.sortiePreventedDamage}·반격 추가 ${stats.sortieBonusDamage}`; + return `전열 ${unit.name}: ${counterplay}·감소 ${stats.sortiePreventedDamage}·반격 추가 ${stats.sortieBonusDamage}`; } if (role === 'flank') { - return `돌파 ${unit.name}: 추가 피해 ${stats.sortieBonusDamage}`; + return `돌파 ${unit.name}: ${counterplay}·추가 피해 ${stats.sortieBonusDamage}`; } - return `후원 ${unit.name}: 추가 피해 ${stats.sortieBonusDamage}·회복 ${stats.sortieBonusHealing}`; + return `후원 ${unit.name}: ${counterplay}·추가 피해 ${stats.sortieBonusDamage}·회복 ${stats.sortieBonusHealing}`; }); if (this.firstPursuitTrinityConfigured()) { parts.push(this.firstPursuitTrinityActive() ? '삼재진: 공명 2칸 유지' : '삼재진: 퇴각으로 해제'); @@ -3719,9 +3786,10 @@ export class BattleScene extends Phaser.Scene { totals.preventedDamage += stats.sortiePreventedDamage; totals.bonusHealing += stats.sortieBonusHealing; totals.extendedBondTriggers += stats.sortieExtendedBondTriggers; + totals.counterplays += this.intentCounterplayTotal(stats); return totals; }, - { bonusDamage: 0, preventedDamage: 0, bonusHealing: 0, extendedBondTriggers: 0 } + { bonusDamage: 0, preventedDamage: 0, bonusHealing: 0, extendedBondTriggers: 0, counterplays: 0 } ); } @@ -3731,14 +3799,15 @@ export class BattleScene extends Phaser.Scene { return undefined; } const stats = this.statsFor(unit.id); + const counterplay = this.intentCounterplayContributionText(stats); const extendedBond = stats.sortieExtendedBondTriggers > 0 ? ` · 공명 ${stats.sortieExtendedBondTriggers}회` : ''; if (role === 'front') { - return `전열 · 피해 ${stats.sortiePreventedDamage} 감소 · 반격 +${stats.sortieBonusDamage}${extendedBond}`; + return `전열 · ${counterplay} · 피해 ${stats.sortiePreventedDamage} 감소 · 반격 +${stats.sortieBonusDamage}${extendedBond}`; } if (role === 'flank') { - return `돌파 · 추가 피해 +${stats.sortieBonusDamage}${extendedBond}`; + return `돌파 · ${counterplay} · 추가 피해 +${stats.sortieBonusDamage}${extendedBond}`; } - return `후원 · 피해 +${stats.sortieBonusDamage} · 회복 +${stats.sortieBonusHealing}${extendedBond}`; + return `후원 · ${counterplay} · 피해 +${stats.sortieBonusDamage} · 회복 +${stats.sortieBonusHealing}${extendedBond}`; } private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) { @@ -4887,14 +4956,16 @@ export class BattleScene extends Phaser.Scene { const fromX = unit.x; const fromY = unit.y; + const intentBefore = this.enemyIntentSnapshots(); const stayingInPlace = fromX === x && fromY === y; unit.x = x; unit.y = y; - this.pendingMove = { unit, fromX, fromY, toX: x, toY: y }; + this.pendingMove = { unit, fromX, fromY, toX: x, toY: y, intentBefore, intentAfterMove: [] }; this.phase = 'command'; this.clearMarkers(); this.updateMiniMap(); this.refreshEnemyIntentForecast(); + this.pendingMove.intentAfterMove = this.enemyIntentSnapshots(); soundDirector.playSelect(); const targetX = this.tileCenterX(x); @@ -6333,6 +6404,195 @@ export class BattleScene extends Phaser.Scene { }; } + private enemyIntentSnapshots() { + if (!this.enemyIntentForecastEnabled()) { + return []; + } + const plans = this.enemyIntentForecasts.size > 0 + ? Array.from(this.enemyIntentForecasts.values()) + : this.currentEnemyActionPlans(); + return plans.map((plan) => ({ + enemyId: plan.enemy.id, + kind: plan.kind, + targetId: plan.target?.id, + usableId: plan.usable?.id, + damage: plan.preview?.damage ?? 0, + hitRate: plan.preview?.hitRate ?? 0 + })); + } + + private settleEnemyIntentCounterplay( + actor: UnitData, + pendingMove: PendingMove | undefined, + outcome: IntentCounterplayActionOutcome = {} + ) { + if (!this.isFirstPursuitRoleEffectBattle() || !pendingMove || pendingMove.unit.id !== actor.id) { + return undefined; + } + + const beforeByEnemy = new Map(pendingMove.intentBefore.map((snapshot) => [snapshot.enemyId, snapshot] as const)); + const afterByEnemy = new Map(pendingMove.intentAfterMove.map((snapshot) => [snapshot.enemyId, snapshot] as const)); + const defeatedEnemyIds = new Set(outcome.defeatedEnemyIds ?? []); + const events: IntentCounterplayEvent[] = []; + + defeatedEnemyIds.forEach((enemyId) => { + const before = beforeByEnemy.get(enemyId); + if (!before || before.kind === 'wait') { + return; + } + events.push({ + turn: this.turnNumber, + kind: 'defeat', + actorId: actor.id, + enemyId, + beforeKind: before.kind, + beforeTargetId: before.targetId + }); + }); + + const moved = pendingMove.fromX !== pendingMove.toX || pendingMove.fromY !== pendingMove.toY; + if (moved) { + pendingMove.intentBefore.forEach((before) => { + if ( + defeatedEnemyIds.has(before.enemyId) || + (before.kind !== 'attack' && before.kind !== 'usable') || + before.targetId !== actor.id + ) { + return; + } + const enemy = battleUnits.find((unit) => unit.id === before.enemyId && unit.faction === 'enemy'); + const after = afterByEnemy.get(before.enemyId); + const lineBroken = enemy && enemy.hp > 0 && ( + after?.kind === 'wait' || + (after?.kind === 'approach' && after.targetId === before.targetId) + ); + if (!lineBroken) { + return; + } + events.push({ + turn: this.turnNumber, + kind: 'line-break', + actorId: actor.id, + enemyId: before.enemyId, + beforeKind: before.kind, + beforeTargetId: before.targetId, + afterKind: after?.kind, + afterTargetId: after?.targetId + }); + }); + } + + if (events.length === 0) { + return undefined; + } + + 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 detail = [defeats > 0 ? `선제 ${defeats}` : '', lineBreaks > 0 ? `차단 ${lineBreaks}` : ''] + .filter(Boolean) + .join(' · '); + const compactDetail = [defeats > 0 ? `선${defeats}` : '', lineBreaks > 0 ? `차${lineBreaks}` : ''] + .filter(Boolean) + .join('/'); + soundDirector.playGrowthTick(); + this.showMapResultPopup(actor, [`의도 파훼 +${events.length}`, detail], '#ffdf7b', '#2b1606', 18, 36); + return `의도 파훼 +${events.length} · ${actor.name} · ${compactDetail}`; + } + + private recordIntentGuardCounterplay( + attacker: UnitData, + defender: UnitData, + isCounter: boolean, + preventedDamage: number + ) { + if ( + !this.isFirstPursuitRoleEffectBattle() || + isCounter || + preventedDamage <= 0 || + attacker.faction !== 'enemy' || + defender.faction !== 'ally' + ) { + return; + } + this.statsFor(defender.id).intentGuardSuccesses += 1; + this.intentCounterplayEvents = [ + ...this.intentCounterplayEvents, + { + turn: this.turnNumber, + kind: 'guard' as const, + actorId: defender.id, + enemyId: attacker.id, + preventedDamage + } + ].slice(-16); + } + + private isIntentGuardCounterplayResult(result: CombatResult) { + return ( + this.isFirstPursuitRoleEffectBattle() && + !result.isCounter && + result.attacker.faction === 'enemy' && + result.defender.faction === 'ally' && + result.sortiePreventedDamageAmount > 0 + ); + } + + private intentCounterplayTotal(stats: UnitBattleStats) { + return stats.intentDefeats + stats.intentLineBreaks + stats.intentGuardSuccesses; + } + + private intentCounterplayContributionText(stats: UnitBattleStats) { + const total = this.intentCounterplayTotal(stats); + const parts = [ + stats.intentDefeats > 0 ? `선${stats.intentDefeats}` : '', + stats.intentLineBreaks > 0 ? `차${stats.intentLineBreaks}` : '', + stats.intentGuardSuccesses > 0 ? `방${stats.intentGuardSuccesses}` : '' + ].filter(Boolean); + return `파훼 ${total}${parts.length > 0 ? `(${parts.join('/')})` : ''}`; + } + + private intentCounterplayDebugState() { + const byUnit = this.firstPursuitRoleAssignments().map(({ unit, role }) => { + const stats = this.statsFor(unit.id); + return { + unitId: unit.id, + role, + intentDefeats: stats.intentDefeats, + intentLineBreaks: stats.intentLineBreaks, + intentGuardSuccesses: stats.intentGuardSuccesses, + total: this.intentCounterplayTotal(stats) + }; + }); + const totals = byUnit.reduce( + (sum, entry) => ({ + intentDefeats: sum.intentDefeats + entry.intentDefeats, + intentLineBreaks: sum.intentLineBreaks + entry.intentLineBreaks, + intentGuardSuccesses: sum.intentGuardSuccesses + entry.intentGuardSuccesses, + total: sum.total + entry.total + }), + { intentDefeats: 0, intentLineBreaks: 0, intentGuardSuccesses: 0, total: 0 } + ); + return { + active: this.isFirstPursuitRoleEffectBattle(), + totals, + byUnit, + pending: this.pendingMove + ? { + unitId: this.pendingMove.unit.id, + moved: this.pendingMove.fromX !== this.pendingMove.toX || this.pendingMove.fromY !== this.pendingMove.toY, + intentBefore: this.pendingMove.intentBefore.map((snapshot) => ({ ...snapshot })), + intentAfterMove: this.pendingMove.intentAfterMove.map((snapshot) => ({ ...snapshot })) + } + : null, + recentEvents: this.intentCounterplayEvents.map((event) => ({ ...event })) + }; + } + private commandAccentColor(command: BattleCommand) { if (command === 'attack') { return 0xd8b15f; @@ -8177,7 +8437,9 @@ export class BattleScene extends Phaser.Scene { this.showCombatMapResult(result.counter); } await this.delay(result.counter ? 220 : 160); - this.finishUnitAction(attacker, this.formatCombatResult(result)); + this.finishUnitAction(attacker, this.formatCombatResult(result), { + defeatedEnemyIds: result.defeated ? [result.defender.id] : [] + }); } private async tryResolveSupportTarget(user: UnitData, target: UnitData, usable: BattleUsable) { @@ -9029,7 +9291,9 @@ export class BattleScene extends Phaser.Scene { return x + width + 6; } - private finishUnitAction(unit: UnitData, message: string) { + private finishUnitAction(unit: UnitData, message: string, outcome: IntentCounterplayActionOutcome = {}) { + const counterplayMessage = this.settleEnemyIntentCounterplay(unit, this.pendingMove, outcome); + const resolvedMessage = counterplayMessage ? `${counterplayMessage}\n${message}` : message; this.actedUnitIds.add(unit.id); this.phase = 'idle'; this.selectedUnit = undefined; @@ -9041,11 +9305,11 @@ export class BattleScene extends Phaser.Scene { this.hideTurnEndPrompt(); this.applyActedStyle(unit); soundDirector.playSelect(); - this.pushBattleLog(message); + this.pushBattleLog(resolvedMessage); this.checkBattleEvents(); this.updateObjectiveTracker(); - if (this.resolveBattleOutcomeIfNeeded()) { + if (this.resolveBattleOutcomeIfNeeded(counterplayMessage ? 820 : 0)) { return; } @@ -9056,7 +9320,7 @@ export class BattleScene extends Phaser.Scene { remaining > 0 ? `${remaining}명의 아군이 아직 행동할 수 있습니다.` : '모든 아군이 행동했습니다. 턴 종료 여부를 선택하세요.'; - const completionMessage = `${message}\n${turnHint}`; + const completionMessage = `${resolvedMessage}\n${turnHint}`; if (remaining > 0 && this.focusNextActionableAlly(unit, completionMessage)) { return; @@ -9119,7 +9383,7 @@ export class BattleScene extends Phaser.Scene { return this.focusActionableAlly(nextUnit, `${summary}\n다음 행동: ${nextUnit.name}\n이동할 파란 칸을 선택하세요.`); } - private resolveBattleOutcomeIfNeeded() { + private resolveBattleOutcomeIfNeeded(resultDelayMs = 0) { if (this.battleOutcome) { return true; } @@ -9128,26 +9392,26 @@ export class BattleScene extends Phaser.Scene { return condition.kind === 'unit-defeated' && !this.isUnitAlive(condition.unitId); }); if (defeatTriggered) { - this.completeBattle('defeat'); + this.completeBattle('defeat', resultDelayMs); return true; } const leader = battleUnits.find((unit) => unit.id === leaderUnitId); if (leader && leader.hp <= 0) { - this.completeBattle('victory'); + this.completeBattle('victory', resultDelayMs); return true; } const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!enemiesAlive) { - this.completeBattle('victory'); + this.completeBattle('victory', resultDelayMs); return true; } return false; } - private completeBattle(outcome: BattleOutcome) { + private completeBattle(outcome: BattleOutcome, resultDelayMs = 0) { if (this.battleOutcome) { return; } @@ -9175,12 +9439,19 @@ export class BattleScene extends Phaser.Scene { : `패배... ${battleScenario.defeatConditionLabel} 조건이 발생했습니다.`; this.pushBattleLog(message); this.renderSituationPanel(message); - soundDirector.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', { - volume: outcome === 'victory' ? 0.34 : 0.26, - stopAfterMs: 1200 - }); this.publishFirstBattleReport(outcome); - this.showBattleResult(outcome); + const revealResult = () => { + soundDirector.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', { + volume: outcome === 'victory' ? 0.34 : 0.26, + stopAfterMs: 1200 + }); + this.showBattleResult(outcome); + }; + if (resultDelayMs > 0) { + this.time.delayedCall(resultDelayMs, revealResult); + } else { + revealResult(); + } } private showBattleResult(outcome: BattleOutcome) { @@ -9259,7 +9530,7 @@ export class BattleScene extends Phaser.Scene { const contributionSummary = this.trackResultObject(this.add.text( left + panelWidth - 44, top + 358, - `추가 피해 +${totals.bonusDamage} · 피해 감소 ${totals.preventedDamage} · 추가 회복 +${totals.bonusHealing} · 2칸 공명 ${totals.extendedBondTriggers}회`, + `전술 기여 · 파훼 ${totals.counterplays} · 추가 피해 +${totals.bonusDamage} · 피해 감소 ${totals.preventedDamage} · 회복 +${totals.bonusHealing} · 2칸 공명 ${totals.extendedBondTriggers}회`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', @@ -9917,11 +10188,12 @@ 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, 32), { + const sortieText = this.trackResultObject(this.add.text(x + 14, y + 31, this.truncateUiText(sortieContribution, 48), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#d8b15f', - fontStyle: '700' + fontStyle: '700', + fixedWidth: 360 })); sortieText.setDepth(depth + 1); } @@ -10641,7 +10913,7 @@ export class BattleScene extends Phaser.Scene { 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 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 baselineDamage = hit @@ -10675,6 +10947,7 @@ export class BattleScene extends Phaser.Scene { sortiePreventedDamageAmount, sortieExtendedBondTriggered ); + this.recordIntentGuardCounterplay(attacker, defender, isCounter, sortiePreventedDamageAmount); this.faceUnitToward(attacker, defender); if (hit) { this.flashDamage(defender, damage, critical, previousDefenderHp); @@ -10733,6 +11006,14 @@ export class BattleScene extends Phaser.Scene { return this.canAttack(defender, attacker); } + private debugForceHitEnabled() { + return ( + this.debugToolsEnabled() && + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('debugForceHit') === '1' + ); + } + private rollPercent(rate: number) { return Phaser.Math.Between(1, 100) <= Phaser.Math.Clamp(rate, 0, 100); } @@ -11217,7 +11498,10 @@ export class BattleScene extends Phaser.Scene { sortieBonusDamage: 0, sortiePreventedDamage: 0, sortieBonusHealing: 0, - sortieExtendedBondTriggers: 0 + sortieExtendedBondTriggers: 0, + intentDefeats: 0, + intentLineBreaks: 0, + intentGuardSuccesses: 0 }; } @@ -11229,7 +11513,7 @@ export class BattleScene extends Phaser.Scene { const title = this.firstPursuitTrinityConfigured() ? '도원 삼재진 발동' : '편성 전술 발동'; this.triggerBattleEvent('opening', title, [ ...roleLines, - '적 의도(예상) · 검=공격 · 발걸음=추격 · 공/추=표적 수', + '적 의도 · 검=공격 · 발=추격 · 공/추=표적 수 · 대응=파훼', `작전 목표 · ${objectiveLines[0]}` ]); return; @@ -11258,10 +11542,10 @@ export class BattleScene extends Phaser.Scene { this.hideBattleEventBanner(); const showExpandedSortieBanner = this.isFirstPursuitRoleEffectBattle() && (title.includes('편성') || title.includes('삼재진')); - const maxBodyLines = showExpandedSortieBanner ? 5 : 2; + const maxBodyLines = showExpandedSortieBanner ? 6 : 2; const bodyLines = lines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42)); const width = 540; - const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 192 : 126); + const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 214 : 126); const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2); const top = this.layout.gridY + 18; const depth = 45; @@ -12209,6 +12493,7 @@ export class BattleScene extends Phaser.Scene { this.battleBuffs = new Map((state.battleBuffs ?? []).map((buff) => [buff.unitId, { ...buff }])); this.battleStatuses = this.deserializeBattleStatuses(state.battleStatuses); this.battleStats = this.deserializeBattleStats(state.battleStats); + this.intentCounterplayEvents = []; this.enemyUsableUseKeys = new Set(state.enemyUsableUseKeys ?? []); this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []); @@ -12314,7 +12599,10 @@ export class BattleScene extends Phaser.Scene { sortieBonusDamage: value.sortieBonusDamage ?? 0, sortiePreventedDamage: value.sortiePreventedDamage ?? 0, sortieBonusHealing: value.sortieBonusHealing ?? 0, - sortieExtendedBondTriggers: value.sortieExtendedBondTriggers ?? 0 + sortieExtendedBondTriggers: value.sortieExtendedBondTriggers ?? 0, + intentDefeats: value.intentDefeats ?? 0, + intentLineBreaks: value.intentLineBreaks ?? 0, + intentGuardSuccesses: value.intentGuardSuccesses ?? 0 }); }); return stats; @@ -13015,7 +13303,10 @@ export class BattleScene extends Phaser.Scene { 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)}${sortie}${counter}`; + const counterplay = this.isIntentGuardCounterplayResult(result) + ? `방호 성공 · ${result.defender.name} · 파훼 +1\n` + : ''; + return `${counterplay}${this.combatResultSummaryLine(result)} · ${this.enemyBehaviorResultLabel(behavior)}${sortie}${counter}`; } private enemyBehaviorResultLabel(behavior: EnemyAiBehavior) { @@ -15114,6 +15405,10 @@ export class BattleScene extends Phaser.Scene { if (result.sortiePreventedDamageAmount <= 0) { return Promise.resolve(); } + if (this.isIntentGuardCounterplayResult(result)) { + soundDirector.playGrowthTick(); + return this.showSortieCombatCue('front', `방호 성공 -${result.sortiePreventedDamageAmount} · 파훼 +1`, x, y, depth); + } return this.showSortieCombatCue('front', `전열 방호 -${result.sortiePreventedDamageAmount}`, x, y, depth); } @@ -16879,10 +17174,19 @@ export class BattleScene extends Phaser.Scene { return; } - const popup = this.add.text( + const visibleLines = lines.filter(Boolean); + const widestLength = Math.max(...visibleLines.map((line) => line.length), 1); + const estimatedWidth = Math.min(this.layout.mapWidth - 20, widestLength * fontSize * 0.78 + 16); + const popupX = Phaser.Math.Clamp( view.sprite.x, + this.layout.mapX + estimatedWidth / 2 + 6, + this.layout.mapX + this.layout.mapWidth - estimatedWidth / 2 - 6 + ); + + const popup = this.add.text( + popupX, view.sprite.y - this.layout.tileSize * 0.64, - lines.filter(Boolean).join('\n'), + visibleLines.join('\n'), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: `${fontSize}px`, @@ -17144,6 +17448,9 @@ export class BattleScene extends Phaser.Scene { private battleLogVisual(entry: string): { icon: BattleUiIconKey; tone: number } { const { text } = this.battleLogDisplayParts(entry); + if (text.includes('파훼') || text.includes('방호 성공') || text.includes('공격선 차단')) { + return { icon: 'counter', tone: palette.gold }; + } if (text.includes('목표 미달') || text.includes('보상 미획득')) { return { icon: 'failure', tone: 0xb86b55 }; } @@ -18484,6 +18791,7 @@ export class BattleScene extends Phaser.Scene { enemyMoveMarkerCount: this.markers.filter((marker) => marker.getData('markerType') === 'enemy-move').length, enemyIntentPreviews: Array.from(this.enemyIntentForecasts.values()).map((plan) => this.enemyActionPlanDebugValue(plan)), enemyIntentVisualCount: this.enemyIntentVisuals.length, + counterplayFeedback: this.intentCounterplayDebugState(), camera: { x: this.cameraTileX, y: this.cameraTileY, @@ -18811,6 +19119,7 @@ export class BattleScene extends Phaser.Scene { probe: state.firstSortieRoleMechanicsProbe })); } + console.info('[Counterplay Debug]', JSON.stringify(state.counterplayFeedback)); this.updateDebugOverlay(); }); this.input.on('pointermove', () => this.updateDebugOverlay()); @@ -18842,6 +19151,7 @@ export class BattleScene extends Phaser.Scene { : 'none'; const sortieProbe = this.debugFirstPursuitRoleMechanicsProbe(); const sortieTotals = this.isFirstPursuitRoleEffectBattle() ? this.firstPursuitContributionTotals() : undefined; + const counterplay = this.intentCounterplayDebugState(); const intentCounts = Array.from(this.enemyIntentForecasts.values()).reduce( (counts, plan) => { counts[plan.kind] += 1; @@ -18855,7 +19165,8 @@ export class BattleScene extends Phaser.Scene { `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}`, - `contrib dmg+${sortieTotals?.bonusDamage ?? 0} guard=${sortieTotals?.preventedDamage ?? 0} heal+${sortieTotals?.bonusHealing ?? 0} d2=${sortieTotals?.extendedBondTriggers ?? 0}` + `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}` ] : []; diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 14f08ab..41d93d0 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -49,6 +49,9 @@ export type BattleSaveUnitStats = { sortiePreventedDamage?: number; sortieBonusHealing?: number; sortieExtendedBondTriggers?: number; + intentDefeats?: number; + intentLineBreaks?: number; + intentGuardSuccesses?: number; }; export type BattleSaveState = { @@ -520,7 +523,10 @@ function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOpti isOptionalBattleUnitStatValue(stats.sortieBonusDamage) && isOptionalBattleUnitStatValue(stats.sortiePreventedDamage) && isOptionalBattleUnitStatValue(stats.sortieBonusHealing) && - isOptionalBattleUnitStatValue(stats.sortieExtendedBondTriggers) + isOptionalBattleUnitStatValue(stats.sortieExtendedBondTriggers) && + isOptionalBattleUnitStatValue(stats.intentDefeats) && + isOptionalBattleUnitStatValue(stats.intentLineBreaks) && + isOptionalBattleUnitStatValue(stats.intentGuardSuccesses) ); }