feat: add sortie contribution feedback

This commit is contained in:
2026-07-10 06:36:10 +09:00
parent f13f24b84a
commit dfc27bf053
3 changed files with 419 additions and 52 deletions

View File

@@ -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<typeof entry> => Boolean(entry));
.filter((entry): entry is NonNullable<typeof entry> => 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<string, UnitBattleStats>) {
private deserializeBattleStats(savedStats?: Record<string, BattleSaveUnitStats>) {
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'
]);
}
}

View File

@@ -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 ||