feat: improve combat feedback and progression

This commit is contained in:
2026-07-19 00:34:11 +09:00
parent f29956b90e
commit 87e3ddb047
16 changed files with 1284 additions and 80 deletions

View File

@@ -22,6 +22,12 @@ export type ItemDefinition = {
effects: string[];
};
export type EquipmentBonuses = {
attack: number;
defense: number;
strategy: number;
};
export const equipmentSlotLabels: Record<EquipmentSlot, string> = {
weapon: '무기',
armor: '방어구',
@@ -264,3 +270,18 @@ export function isEquipmentInventoryLabel(label: string) {
export function equipmentExpToNext(level: number) {
return 50 + Math.min(level, 9) * 20;
}
export function calculateEquipmentBonuses(equipment: EquipmentSet): EquipmentBonuses {
return equipmentSlots.reduce<EquipmentBonuses>(
(bonuses, slot) => {
const state = equipment[slot];
const item = getItem(state.itemId);
const levelBonus = Math.max(0, Math.floor(state.level) - 1);
bonuses.attack += (item.attackBonus ?? 0) + (slot === 'weapon' ? levelBonus : 0);
bonuses.defense += (item.defenseBonus ?? 0) + (slot === 'armor' ? levelBonus : 0);
bonuses.strategy += (item.strategyBonus ?? 0) + (slot === 'accessory' ? levelBonus : 0);
return bonuses;
},
{ attack: 0, defense: 0, strategy: 0 }
);
}

View File

@@ -0,0 +1,43 @@
export type EnemyIntentBattlePhase =
| 'deployment'
| 'idle'
| 'moving'
| 'command'
| 'targeting'
| 'animating'
| 'resolved';
type EnemyIntentRuntimeState = {
phase: EnemyIntentBattlePhase;
battleResolved: boolean;
};
export type EnemyIntentForecastState = EnemyIntentRuntimeState & {
activeFaction: 'ally' | 'enemy';
};
/**
* Enemy intent is a campaign-wide combat rule. Deployment and settled battles
* deliberately stay quiet, while every playable allied turn exposes the same
* forecast regardless of scenario id.
*/
export function isEnemyIntentForecastAvailable(state: EnemyIntentForecastState) {
return (
state.activeFaction === 'ally' &&
state.phase !== 'deployment' &&
state.phase !== 'resolved' &&
!state.battleResolved
);
}
/** Counterplay is settled after an allied action, including a final defeat. */
export function isEnemyIntentCounterplayAvailable(state: EnemyIntentRuntimeState) {
return state.phase !== 'deployment' && state.phase !== 'resolved' && !state.battleResolved;
}
export function enemyIntentOpeningGuide(legacyInitiativeThreshold?: number) {
const common = '적 의도 · 검=공격 · 발=추격 · 이동·선제·전열 방호로 파훼';
return legacyInitiativeThreshold
? `${common} · 파훼 ${legacyInitiativeThreshold}회 → 전술 명령`
: common;
}

View File

@@ -0,0 +1,55 @@
import type { UnitClassKey } from './battleRules';
import type { UnitData, UnitStats } from './scenario';
const levelUpStatKeysByClass: Record<UnitClassKey, Array<keyof UnitStats>> = {
lord: ['might', 'intelligence', 'leadership'],
infantry: ['might', 'leadership'],
cavalry: ['might', 'leadership', 'agility'],
spearman: ['might', 'leadership'],
archer: ['might', 'intelligence'],
strategist: ['intelligence', 'leadership'],
quartermaster: ['intelligence', 'leadership'],
bandit: ['might', 'leadership'],
yellowTurban: ['might', 'intelligence', 'leadership'],
rebelLeader: ['might', 'intelligence', 'leadership']
};
export function applyCampaignUnitProgress(scenarioUnit: UnitData, progress?: UnitData): UnitData {
const cloned = cloneUnitProgressData(scenarioUnit);
if (!progress || scenarioUnit.faction !== 'ally' || progress.faction !== 'ally' || progress.id !== scenarioUnit.id) {
return cloned;
}
const level = Math.max(scenarioUnit.level, progress.level);
const levelUps = level - scenarioUnit.level;
const progressedStatKeys = new Set(levelUpStatKeysByClass[scenarioUnit.classKey]);
const stats = (Object.keys(scenarioUnit.stats) as Array<keyof UnitStats>).reduce<UnitStats>((next, key) => {
const authoredGrowthFloor = scenarioUnit.stats[key] + (progressedStatKeys.has(key) ? levelUps : 0);
next[key] = Math.min(255, Math.max(progress.stats[key], authoredGrowthFloor));
return next;
}, {} as UnitStats);
const maxHp = Math.max(progress.maxHp, scenarioUnit.maxHp + levelUps * 2);
return {
...cloned,
level,
exp: progress.exp,
hp: Math.max(1, Math.min(progress.hp, maxHp)),
maxHp,
attack: Math.max(progress.attack, scenarioUnit.attack + levelUps),
stats,
equipment: cloneUnitProgressData(progress).equipment
};
}
function cloneUnitProgressData(unit: UnitData): UnitData {
return {
...unit,
stats: { ...unit.stats },
equipment: {
weapon: { ...unit.equipment.weapon },
armor: { ...unit.equipment.armor },
accessory: { ...unit.equipment.accessory }
}
};
}

View File

@@ -12,6 +12,11 @@ import {
type BattleTacticalGuideEventKey
} from '../data/battles';
import { getSortieFlow } from '../data/campaignFlow';
import {
enemyIntentOpeningGuide,
isEnemyIntentCounterplayAvailable,
isEnemyIntentForecastAvailable
} from '../data/enemyIntent';
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
import {
ensureUnitAnimations,
@@ -43,6 +48,7 @@ import {
type UsableCommand
} from '../data/battleUsables';
import {
calculateEquipmentBonuses,
equipmentExpToNext,
equipmentSlotLabels,
equipmentSlots,
@@ -51,6 +57,7 @@ import {
type EquipmentSlot,
type ItemDefinition
} from '../data/battleItems';
import { applyCampaignUnitProgress as mergeCampaignUnitProgress } from '../data/unitProgress';
import {
applySortieDeploymentPlan,
createSortieDeploymentPlan,
@@ -126,6 +133,16 @@ import {
type BattleSaveTacticalCommandState,
type BattleSaveUnitStats
} from '../state/battleSaveState';
import {
combatPresentationImportanceReason,
combatPresentationModeLabel,
loadCombatPresentationMode,
nextCombatPresentationMode,
saveCombatPresentationMode,
shouldUseFullCombatCutIn,
type CombatPresentationImportance,
type CombatPresentationMode
} from '../settings/combatPresentation';
import { palette } from '../ui/palette';
import { startLazyScene } from './lazyScenes';
@@ -1363,7 +1380,7 @@ type RosterTab = 'ally' | 'enemy';
type ActiveFaction = 'ally' | 'enemy';
const battleSpeedOptions = ['normal', 'fast', 'very-fast'] as const;
type BattleSpeed = (typeof battleSpeedOptions)[number];
type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'bgm' | 'close';
type MapMenuAction = 'endTurn' | 'threat' | 'save' | 'load' | 'roster' | 'bond' | 'situation' | 'speed' | 'presentation' | 'bgm' | 'close';
type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold';
type BattleOutcome = 'victory' | 'defeat';
type SaveSlotMode = 'save' | 'load';
@@ -1990,6 +2007,15 @@ type CombatCutInStageSnapshot = {
mapBacked: boolean;
};
type CombatPresentationSnapshot = {
mode: CombatPresentationMode;
tier: 'full' | 'map';
reason: string;
action: CombatCutInStageMode;
actorId: string;
targetId: string;
};
type TacticalInitiativeCombatEffect = {
role?: TacticalInitiativeRole;
damageBonus: number;
@@ -3376,6 +3402,7 @@ const mapMenuLabels: Partial<Record<MapMenuAction, string>> = {
bond: '공명',
situation: '전황',
speed: '속도',
presentation: '전투 연출',
bgm: 'BGM',
close: '닫기'
};
@@ -3515,6 +3542,9 @@ export class BattleScene extends Phaser.Scene {
private mapMenuObjects: Array<Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text> = [];
private mapMenuLayout?: MapMenuLayout;
private battleSpeed: BattleSpeed = 'normal';
private combatPresentationMode: CombatPresentationMode = 'important';
private combatPresentationCount = { full: 0, map: 0 };
private combatPresentationLast?: CombatPresentationSnapshot;
private fastForwardHeld = false;
private battleSpeedControlObjects: Phaser.GameObjects.GameObject[] = [];
private tacticalInitiativeChipObjects: Phaser.GameObjects.GameObject[] = [];
@@ -3794,6 +3824,9 @@ export class BattleScene extends Phaser.Scene {
this.battleOutcome = undefined;
this.phase = 'idle';
this.battleSpeed = this.loadBattleSpeed();
this.combatPresentationMode = loadCombatPresentationMode();
this.combatPresentationCount = { full: 0, map: 0 };
this.combatPresentationLast = undefined;
this.fastForwardHeld = false;
soundDirector.playMusic('battle-prep');
this.input.mouse?.disableContextMenu();
@@ -4450,7 +4483,27 @@ export class BattleScene extends Phaser.Scene {
}
private effectiveSortieUnitIds(campaign?: CampaignState) {
return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? [];
const configured = this.launchSortieUnitIds.length > 0
? this.launchSortieUnitIds
: campaign?.selectedSortieUnitIds ?? [];
const availableAllyIds = new Set(
initialBattleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)
);
const limit = battleScenario.sortie?.sortieLimit ?? availableAllyIds.size;
const normalized = [...new Set(configured)]
.filter((unitId) => availableAllyIds.has(unitId))
.slice(0, limit);
if (normalized.length > 0 || !battleScenario.sortie) {
return normalized;
}
return [...new Set([
...(battleScenario.sortie.requiredUnits ?? []),
...battleScenario.sortie.recommendedUnits.map((entry) => entry.unitId),
...availableAllyIds
])]
.filter((unitId) => availableAllyIds.has(unitId))
.slice(0, limit);
}
private scenarioRecommendedSortieFormationAssignments() {
@@ -4533,6 +4586,12 @@ export class BattleScene extends Phaser.Scene {
));
}
private intentCounterplayAssignments() {
return battleUnits
.filter((unit) => unit.faction === 'ally')
.map((unit) => ({ unit, role: this.sortieRoleForUnit(unit) ?? 'reserve' as SortieFormationRole }));
}
private sortieRoleGroups() {
const assignments = this.sortieRoleAssignments();
return coreSortieSynergyRoles.map((role) => ({
@@ -4837,7 +4896,7 @@ export class BattleScene extends Phaser.Scene {
}
const parts = this.sortieRoleAssignments().map(({ role, unit }) => {
const stats = this.statsFor(unit.id);
const counterplay = this.isFirstPursuitRoleEffectBattle() ? `${this.intentCounterplayContributionText(stats)}·` : '';
const counterplay = `${this.intentCounterplayContributionText(stats)}·`;
if (role === 'front') {
return `전열 ${unit.name}: ${counterplay}감소 ${stats.sortiePreventedDamage}·반격 추가 ${stats.sortieBonusDamage}`;
}
@@ -4846,6 +4905,10 @@ export class BattleScene extends Phaser.Scene {
}
return `후원 ${unit.name}: ${counterplay}추가 피해 ${stats.sortieBonusDamage}·회복 ${stats.sortieBonusHealing}`;
});
const assignedUnitIds = new Set(this.sortieRoleAssignments().map(({ unit }) => unit.id));
this.intentCounterplayAssignments()
.filter(({ unit }) => !assignedUnitIds.has(unit.id) && this.intentCounterplayTotal(this.statsFor(unit.id)) > 0)
.forEach(({ unit }) => parts.push(`${unit.name}: ${this.intentCounterplayContributionText(this.statsFor(unit.id))}`));
if (this.firstPursuitTrinityConfigured()) {
parts.push(this.firstPursuitTrinityActive() ? '삼재진: 공명 2칸 유지' : '삼재진: 퇴각으로 해제');
}
@@ -4853,18 +4916,22 @@ export class BattleScene extends Phaser.Scene {
}
private sortieContributionTotals() {
return this.sortieRoleAssignments().reduce(
const totals = this.sortieRoleAssignments().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;
totals.counterplays += this.intentCounterplayTotal(stats);
return totals;
},
{ bonusDamage: 0, preventedDamage: 0, bonusHealing: 0, extendedBondTriggers: 0, counterplays: 0 }
);
totals.counterplays = this.intentCounterplayAssignments().reduce(
(count, { unit }) => count + this.intentCounterplayTotal(this.statsFor(unit.id)),
0
);
return totals;
}
private tacticalInitiativeResultText() {
@@ -4889,10 +4956,11 @@ export class BattleScene extends Phaser.Scene {
private sortieUnitContributionLine(unit: UnitData) {
const role = this.sortieRoleForUnit(unit);
if (!role || !sortieRoleEffects[role]) {
return undefined;
const stats = this.statsFor(unit.id);
return this.intentCounterplayTotal(stats) > 0 ? this.intentCounterplayContributionText(stats) : undefined;
}
const stats = this.statsFor(unit.id);
const counterplay = this.isFirstPursuitRoleEffectBattle() ? ` · ${this.intentCounterplayContributionText(stats)}` : '';
const counterplay = ` · ${this.intentCounterplayContributionText(stats)}`;
const extendedBond = stats.sortieExtendedBondTriggers > 0 ? ` · 공명 ${stats.sortieExtendedBondTriggers}` : '';
if (role === 'front') {
return `전열${counterplay} · 피해 ${stats.sortiePreventedDamage} 감소 · 반격 +${stats.sortieBonusDamage}${extendedBond}`;
@@ -4904,20 +4972,8 @@ export class BattleScene extends Phaser.Scene {
}
private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) {
const cloned = cloneUnitData(unit);
const progress = campaign?.roster.find((candidate) => candidate.id === unit.id);
if (!progress || unit.faction !== 'ally') {
return cloned;
}
return {
...cloned,
level: progress.level,
exp: progress.exp,
hp: Math.max(1, Math.min(progress.hp, progress.maxHp)),
maxHp: progress.maxHp,
equipment: JSON.parse(JSON.stringify(progress.equipment)) as UnitData['equipment']
};
return mergeCampaignUnitProgress(unit, progress);
}
private applyCampaignBondProgress(bond: BattleBond, campaign?: CampaignState) {
@@ -6088,6 +6144,25 @@ export class BattleScene extends Phaser.Scene {
}
}
private cycleCombatPresentationMode() {
this.combatPresentationMode = nextCombatPresentationMode(this.combatPresentationMode);
saveCombatPresentationMode(this.combatPresentationMode);
this.renderSituationPanel(
`전투 연출: ${combatPresentationModeLabel(this.combatPresentationMode)}\n` +
(this.combatPresentationMode === 'always'
? '모든 공격과 책략을 전체 화면으로 보여줍니다.'
: this.combatPresentationMode === 'important'
? '고유 전법·치명타·격파·공명·레벨 상승만 전체 화면으로 보여줍니다.'
: '모든 행동을 빠른 지도 연출로 보여줍니다.')
);
}
private combatPresentationMenuLabel() {
return this.combatPresentationMode === 'important'
? '중요만'
: combatPresentationModeLabel(this.combatPresentationMode);
}
private battleSpeedFactor() {
const heldFastForward = this.activeFaction === 'enemy' && this.fastForwardHeld ? 1.75 : 1;
return battleSpeedFactors[this.battleSpeed] * heldFastForward;
@@ -8509,13 +8584,18 @@ export class BattleScene extends Phaser.Scene {
}
private enemyIntentForecastEnabled() {
return (
this.isFirstPursuitRoleEffectBattle() &&
this.activeFaction === 'ally' &&
this.phase !== 'deployment' &&
this.phase !== 'resolved' &&
!this.battleOutcome
);
return isEnemyIntentForecastAvailable({
activeFaction: this.activeFaction,
phase: this.phase,
battleResolved: Boolean(this.battleOutcome)
});
}
private enemyIntentCounterplayEnabled() {
return isEnemyIntentCounterplayAvailable({
phase: this.phase,
battleResolved: Boolean(this.battleOutcome)
});
}
private currentEnemyActionPlans() {
@@ -8683,7 +8763,10 @@ export class BattleScene extends Phaser.Scene {
return '아군 턴을 종료하시겠습니까?';
}
const groups = new Map<string, { target: UnitData; immediate: number; approach: number }>();
this.currentEnemyActionPlans().forEach((plan) => {
const plans = this.enemyIntentForecasts.size > 0
? Array.from(this.enemyIntentForecasts.values())
: this.currentEnemyActionPlans();
plans.forEach((plan) => {
if (!plan.target || plan.kind === 'wait') {
return;
}
@@ -8757,7 +8840,7 @@ export class BattleScene extends Phaser.Scene {
pendingMove: PendingMove | undefined,
outcome: IntentCounterplayActionOutcome = {}
) {
if (!this.isFirstPursuitRoleEffectBattle() || !pendingMove || pendingMove.unit.id !== actor.id) {
if (!this.enemyIntentCounterplayEnabled() || !pendingMove || pendingMove.unit.id !== actor.id) {
return undefined;
}
@@ -8853,7 +8936,7 @@ export class BattleScene extends Phaser.Scene {
preventedDamage: number
) {
if (
!this.isFirstPursuitRoleEffectBattle() ||
!this.enemyIntentCounterplayEnabled() ||
isCounter ||
preventedDamage <= 0 ||
attacker.faction !== 'enemy' ||
@@ -8880,7 +8963,7 @@ export class BattleScene extends Phaser.Scene {
private isIntentGuardCounterplayResult(result: CombatResult) {
return (
this.isFirstPursuitRoleEffectBattle() &&
this.enemyIntentCounterplayEnabled() &&
!result.isCounter &&
result.attacker.faction === 'enemy' &&
result.defender.faction === 'ally' &&
@@ -8903,7 +8986,7 @@ export class BattleScene extends Phaser.Scene {
}
private intentCounterplayDebugState() {
const byUnit = this.firstPursuitRoleAssignments().map(({ unit, role }) => {
const byUnit = this.intentCounterplayAssignments().map(({ unit, role }) => {
const stats = this.statsFor(unit.id);
return {
unitId: unit.id,
@@ -8924,7 +9007,9 @@ export class BattleScene extends Phaser.Scene {
{ intentDefeats: 0, intentLineBreaks: 0, intentGuardSuccesses: 0, total: 0 }
);
return {
active: this.isFirstPursuitRoleEffectBattle(),
active: this.enemyIntentCounterplayEnabled(),
forecastVisible: this.enemyIntentForecastEnabled(),
scope: 'campaign',
totals,
byUnit,
pending: this.pendingMove
@@ -11022,7 +11107,7 @@ export class BattleScene extends Phaser.Scene {
const result = this.resolveCombatAction(attacker, target, action, false, usable);
this.clearMarkers();
await this.showBondMapEffect(result);
await this.playCombatCutIn(result);
await this.presentCombatResult(result);
if (result.bondChain?.defeated) {
this.applyDefeatedStyle(result.defender);
this.updateMiniMap();
@@ -11030,7 +11115,7 @@ export class BattleScene extends Phaser.Scene {
result.counter = this.resolveCounterAttack(result);
if (result.counter) {
await this.delay(180);
await this.playCombatCutIn(result.counter);
await this.presentCombatResult(result.counter);
}
await this.showCombatExchangeMapResults(result);
await this.delay(result.counter ? 220 : 160);
@@ -11059,7 +11144,7 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'animating';
const result = this.resolveSupportAction(user, target, usable);
this.clearMarkers();
await this.playSupportCutIn(result);
await this.presentSupportResult(result);
this.showSupportMapResult(result);
await this.delay(160);
this.finishUnitAction(user, this.formatSupportResult(result));
@@ -12229,9 +12314,9 @@ export class BattleScene extends Phaser.Scene {
if (doctrineActive) {
const totals = this.sortieContributionTotals();
const specialTactics = this.isFirstPursuitRoleEffectBattle()
? `파훼 ${totals.counterplays} · ${this.tacticalInitiativeResultText()} · `
: '';
const specialTactics = `파훼 ${totals.counterplays} · ${
this.isFirstPursuitRoleEffectBattle() ? `${this.tacticalInitiativeResultText()} · ` : ''
}`;
const hiddenSummary = hiddenAllyCount > 0 ? ` · 외 ${hiddenAllyCount}명 정산` : '';
const contributionSummary = this.trackResultObject(this.add.text(
left + panelWidth - 44,
@@ -16381,9 +16466,7 @@ export class BattleScene extends Phaser.Scene {
...(this.launchSortieRecommendation
? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`]
: []),
...(this.isFirstPursuitRoleEffectBattle()
? [`적 의도 · 검=공격 · 발=추격 · 파훼 ${tacticalInitiativeThreshold}회 → 전술 명령`]
: []),
enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined),
...(battleScenario.id === 'first-battle-zhuo-commandery'
? ['첫 행동 · 아군 선택 → 파란 이동 칸 → 명령 공격 → 붉은 적 선택']
: []),
@@ -17031,7 +17114,7 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'idle';
this.refreshUnitLegibilityStyles();
const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'bgm', 'close'];
const actions: MapMenuAction[] = ['endTurn', 'threat', 'save', 'load', 'roster', 'bond', 'situation', 'speed', 'presentation', 'bgm', 'close'];
const menuWidth = 148;
const buttonHeight = 34;
const padding = 8;
@@ -17126,6 +17209,10 @@ export class BattleScene extends Phaser.Scene {
this.cycleBattleSpeed(false);
return;
}
if (action === 'presentation') {
this.cycleCombatPresentationMode();
return;
}
if (action === 'bgm') {
soundDirector.setMuted(!soundDirector.isMuted());
this.renderSituationPanel(`BGM을 ${soundDirector.isMuted() ? '껐습니다' : '켰습니다'}.`);
@@ -17172,6 +17259,9 @@ export class BattleScene extends Phaser.Scene {
if (action === 'speed') {
return `속도 ${battleSpeedLabels[this.battleSpeed]}`;
}
if (action === 'presentation') {
return `연출 ${this.combatPresentationMenuLabel()}`;
}
return mapMenuLabels[action] ?? action;
}
@@ -17507,7 +17597,8 @@ export class BattleScene extends Phaser.Scene {
this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation);
this.applyBattleSaveState(state);
this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
} catch {
} catch (error) {
console.error('[Battle Save] Failed to restore battle state.', error);
this.renderSituationPanel('저장 데이터를 불러오지 못했습니다.');
}
}
@@ -17956,11 +18047,11 @@ export class BattleScene extends Phaser.Scene {
this.renderSituationPanel(`적 행동: ${enemy.name}\n${plan.target.name} 공격`);
this.centerCameraOnTile(Math.floor((enemy.x + plan.target.x) / 2), Math.floor((enemy.y + plan.target.y) / 2));
const result = this.resolveAttack(enemy, plan.target);
await this.playCombatCutIn(result);
await this.presentCombatResult(result);
result.counter = this.resolveCounterAttack(result);
if (result.counter) {
await this.delay(180);
await this.playCombatCutIn(result.counter);
await this.presentCombatResult(result.counter);
}
await this.showCombatExchangeMapResults(result);
await this.delay(result.counter ? 220 : 160);
@@ -17981,7 +18072,7 @@ export class BattleScene extends Phaser.Scene {
this.renderSituationPanel(`적 행동: ${enemy.name}\n${target.name}에게 ${usable.name}`);
this.centerCameraOnTile(Math.floor((enemy.x + target.x) / 2), Math.floor((enemy.y + target.y) / 2));
const result = this.resolveCombatAction(enemy, target, usable.command, false, usable);
await this.playCombatCutIn(result);
await this.presentCombatResult(result);
this.showCombatMapResult(result);
await this.delay(160);
return this.formatEnemyCombatResult(result, behavior);
@@ -18646,6 +18737,268 @@ export class BattleScene extends Phaser.Scene {
return { snapshot, shadows };
}
private combatResultImportance(result: CombatResult): CombatPresentationImportance {
return {
signature: Boolean(result.usable?.signatureOwnerId && result.usable.signatureOwnerId === result.attacker.id),
critical: result.critical,
defeated: result.defeated || result.baseDefeated || Boolean(result.bondChain?.defeated),
resonance: Boolean(result.bondDamageBonus > 0 || result.bondChainAttempt || result.bondChain),
growth: Boolean(
result.characterGrowth.leveled ||
result.attackerGrowth.leveled ||
result.defenderGrowth.leveled ||
result.bondGrowth.some((growth) => growth.leveled)
)
};
}
private supportResultImportance(result: SupportResult): CombatPresentationImportance {
return {
signature: Boolean(result.usable.signatureOwnerId && result.usable.signatureOwnerId === result.user.id),
growth: result.characterGrowth.leveled || result.equipmentGrowth.leveled
};
}
private async presentCombatResult(result: CombatResult) {
const importance = this.combatResultImportance(result);
const fullCutIn = shouldUseFullCombatCutIn(this.combatPresentationMode, importance);
this.recordCombatPresentation(
fullCutIn ? 'full' : 'map',
combatPresentationImportanceReason(importance),
result.isCounter ? 'counter' : result.action,
result.attacker,
result.defender
);
if (fullCutIn) {
await this.playCombatCutIn(result);
return;
}
await this.playCombatMapPresentation(result);
}
private async presentSupportResult(result: SupportResult) {
const importance = this.supportResultImportance(result);
const fullCutIn = shouldUseFullCombatCutIn(this.combatPresentationMode, importance);
this.recordCombatPresentation(
fullCutIn ? 'full' : 'map',
combatPresentationImportanceReason(importance),
'support',
result.user,
result.target
);
if (fullCutIn) {
await this.playSupportCutIn(result);
return;
}
await this.playSupportMapPresentation(result);
}
private recordCombatPresentation(
tier: CombatPresentationSnapshot['tier'],
reason: string,
action: CombatCutInStageMode,
actor: UnitData,
target: UnitData
) {
this.combatPresentationCount[tier] += 1;
this.combatPresentationLast = {
mode: this.combatPresentationMode,
tier,
reason,
action,
actorId: actor.id,
targetId: target.id
};
}
private async playCombatMapPresentation(result: CombatResult) {
this.hideCombatCutIn();
const attackerView = this.unitViews.get(result.attacker.id);
const defenderView = this.unitViews.get(result.defender.id);
const targetX = defenderView?.sprite.x ?? this.tileCenterX(result.defender.x);
const targetY = defenderView?.sprite.y ?? this.tileCenterY(result.defender.y);
const accent = result.critical || result.defeated ? 0xff8f5f : result.action === 'strategy' ? 0x83d6ff : 0xffdf7b;
const duration = this.scaledBattleDuration(result.critical || result.defeated ? 300 : 220, 95);
const objects: Phaser.GameObjects.GameObject[] = [];
let actorFramePromise: Promise<void> = Promise.resolve();
if (attackerView?.sprite.active && attackerView.sprite.visible) {
const direction = Math.sign(targetX - attackerView.sprite.x) || (result.attacker.faction === 'ally' ? 1 : -1);
actorFramePromise = this.playUnitActionFrames(
attackerView.sprite,
result.attacker,
attackerView.direction,
this.actionPoseForCommand(result.action),
42,
1
);
this.tweens.add({
targets: attackerView.sprite,
x: attackerView.sprite.x + direction * Math.min(20, this.layout.tileSize * 0.28),
duration: Math.max(55, Math.round(duration * 0.42)),
yoyo: true,
ease: 'Sine.easeInOut'
});
}
if (result.action === 'strategy') {
soundDirector.playStrategyPulse();
} else if (result.action === 'item') {
soundDirector.playItemLaunch();
} else if (result.attacker.classKey === 'archer' || this.attackRange(result.attacker) > 1) {
soundDirector.playArrowShot();
soundDirector.playArrowImpact(result.hit, result.critical);
} else {
soundDirector.playMeleeSwing(result.hit, result.critical);
}
const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.2, this.layout.tileSize * 0.28);
ring.setStrokeStyle(result.critical || result.defeated ? 6 : 4, accent, 0.94);
ring.setDepth(12.8);
if (this.mapMask) {
ring.setMask(this.mapMask);
}
objects.push(ring);
const spark = this.add.star(
targetX,
targetY - this.layout.tileSize * 0.2,
result.critical || result.defeated ? 8 : 6,
this.layout.tileSize * 0.12,
this.layout.tileSize * (result.critical || result.defeated ? 0.48 : 0.36),
accent,
result.hit ? 0.9 : 0.38
);
spark.setDepth(12.7);
if (this.mapMask) {
spark.setMask(this.mapMask);
}
objects.push(spark);
const actionLabel = result.isCounter ? '반격' : result.usable?.name ?? commandLabels[result.action];
const badge = this.add.text(targetX, targetY - this.layout.tileSize * 0.9, actionLabel, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(result.critical || result.defeated ? 16 : 13),
color: result.hit ? '#fff2b8' : '#d9f1ff',
fontStyle: '700',
backgroundColor: 'rgba(7, 13, 19, 0.88)',
padding: { x: this.battleUiLength(8), y: this.battleUiLength(4) },
stroke: '#120d08',
strokeThickness: this.battleUiLength(2)
});
badge.setOrigin(0.5);
badge.setDepth(13);
if (this.mapMask) {
badge.setMask(this.mapMask);
}
objects.push(badge);
this.tweens.add({ targets: ring, scale: result.critical || result.defeated ? 2.2 : 1.7, alpha: 0, duration, ease: 'Sine.easeOut' });
this.tweens.add({ targets: spark, scale: 1.35, angle: 120, alpha: 0, duration, ease: 'Sine.easeOut' });
this.tweens.add({ targets: badge, y: badge.y - this.battleUiLength(10), alpha: 0, delay: Math.round(duration * 0.45), duration: Math.round(duration * 0.55) });
if (result.critical || result.defeated) {
this.cameras.main.shake(this.scaledBattleDuration(140, 80), result.defeated ? 0.008 : 0.005);
}
this.showCompactGrowthMapResult(result.attacker, result.characterGrowth, result.attackerGrowth);
await Promise.all([this.delay(duration + 20), actorFramePromise]);
if (attackerView) {
this.syncUnitMotion(result.attacker, attackerView);
}
objects.forEach((object) => object.active && object.destroy());
}
private async playSupportMapPresentation(result: SupportResult) {
this.hideCombatCutIn();
const userView = this.unitViews.get(result.user.id);
const targetView = this.unitViews.get(result.target.id);
const targetX = targetView?.sprite.x ?? this.tileCenterX(result.target.x);
const targetY = targetView?.sprite.y ?? this.tileCenterY(result.target.y);
const accent = result.usable.effect === 'heal' ? 0x59d18c : 0xd8b15f;
const duration = this.scaledBattleDuration(240, 100);
const objects: Phaser.GameObjects.GameObject[] = [];
let userFramePromise: Promise<void> = Promise.resolve();
if (userView?.sprite.active && userView.sprite.visible) {
userFramePromise = this.playUnitActionFrames(
userView.sprite,
result.user,
userView.direction,
result.usable.command === 'strategy' ? 'strategy' : 'item',
42,
1
);
this.tweens.add({
targets: userView.sprite,
scaleX: userView.baseScaleX * 1.08,
scaleY: userView.baseScaleY * 1.08,
duration: Math.max(55, Math.round(duration * 0.42)),
yoyo: true,
ease: 'Sine.easeOut'
});
}
soundDirector.playEffect(result.usable.command === 'strategy' ? 'strategy-cast' : 'exp-gain', {
volume: result.usable.command === 'strategy' ? 0.3 : 0.24,
stopAfterMs: 620
});
const ring = this.add.circle(targetX, targetY - this.layout.tileSize * 0.18, this.layout.tileSize * 0.3);
ring.setStrokeStyle(4, accent, 0.92);
ring.setDepth(12.8);
if (this.mapMask) {
ring.setMask(this.mapMask);
}
objects.push(ring);
const spark = this.add.star(targetX, targetY - this.layout.tileSize * 0.18, 6, 7, this.layout.tileSize * 0.35, accent, 0.86);
spark.setDepth(12.7);
if (this.mapMask) {
spark.setMask(this.mapMask);
}
objects.push(spark);
const badge = this.add.text(targetX, targetY - this.layout.tileSize * 0.9, result.usable.name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(13),
color: result.usable.effect === 'heal' ? '#a8ffd0' : '#fff2b8',
fontStyle: '700',
backgroundColor: 'rgba(7, 13, 19, 0.88)',
padding: { x: this.battleUiLength(8), y: this.battleUiLength(4) },
stroke: '#071018',
strokeThickness: this.battleUiLength(2)
});
badge.setOrigin(0.5);
badge.setDepth(13);
if (this.mapMask) {
badge.setMask(this.mapMask);
}
objects.push(badge);
this.tweens.add({ targets: ring, scale: 1.8, alpha: 0, duration, ease: 'Sine.easeOut' });
this.tweens.add({ targets: spark, scale: 1.25, angle: 180, alpha: 0, duration, ease: 'Sine.easeOut' });
this.tweens.add({ targets: badge, y: badge.y - this.battleUiLength(10), alpha: 0, delay: Math.round(duration * 0.45), duration: Math.round(duration * 0.55) });
this.showCompactGrowthMapResult(result.user, result.characterGrowth, result.equipmentGrowth);
await Promise.all([this.delay(duration + 20), userFramePromise]);
if (userView) {
this.syncUnitMotion(result.user, userView);
}
objects.forEach((object) => object.active && object.destroy());
}
private showCompactGrowthMapResult(
unit: UnitData,
characterGrowth: CharacterGrowthResult,
equipmentGrowth: EquipmentGrowthResult
) {
const lines = [
characterGrowth.leveled ? `LEVEL UP · Lv ${characterGrowth.level}` : '',
equipmentGrowth.leveled ? `${equipmentGrowth.itemName} · Lv ${equipmentGrowth.level}` : ''
].filter(Boolean);
if (lines.length === 0) {
return;
}
this.showMapResultPopup(unit, lines, '#a8ffd0', '#072416', 16, lines.length > 1 ? 34 : 26);
soundDirector.playGrowthTick();
}
private async playCombatCutIn(result: CombatResult) {
this.hideCombatCutIn();
@@ -25402,27 +25755,15 @@ export class BattleScene extends Phaser.Scene {
}
private equipmentAttackBonus(unit: UnitData) {
return equipmentSlots.reduce((total, slot) => {
const item = getItem(unit.equipment[slot].itemId);
const levelBonus = slot === 'weapon' ? unit.equipment[slot].level - 1 : 0;
return total + (item.attackBonus ?? 0) + levelBonus;
}, 0);
return calculateEquipmentBonuses(unit.equipment).attack;
}
private equipmentStrategyBonus(unit: UnitData) {
return equipmentSlots.reduce((total, slot) => {
const item = getItem(unit.equipment[slot].itemId);
const levelBonus = slot === 'accessory' ? unit.equipment[slot].level - 1 : 0;
return total + (item.strategyBonus ?? 0) + levelBonus;
}, 0);
return calculateEquipmentBonuses(unit.equipment).strategy;
}
private equipmentDefenseBonus(unit: UnitData) {
return equipmentSlots.reduce((total, slot) => {
const item = getItem(unit.equipment[slot].itemId);
const levelBonus = slot === 'armor' ? unit.equipment[slot].level - 1 : 0;
return total + (item.defenseBonus ?? 0) + levelBonus;
}, 0);
return calculateEquipmentBonuses(unit.equipment).defense;
}
private renderCompactValueBox(x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string) {
@@ -26297,6 +26638,12 @@ export class BattleScene extends Phaser.Scene {
firstBattleTutorial: this.firstBattleTutorialDebugState(),
combatCutIn: combatCutInDebug,
combatCutInStage: combatCutInDebug,
combatPresentation: {
mode: this.combatPresentationMode,
label: combatPresentationModeLabel(this.combatPresentationMode),
counts: { ...this.combatPresentationCount },
last: this.combatPresentationLast ? { ...this.combatPresentationLast } : null
},
sortieDoctrine: this.debugSortieDoctrine(),
firstSortieRoleEffects: this.debugFirstPursuitRoleEffects(),
firstSortieRoleMechanicsProbe: this.debugFirstPursuitRoleMechanicsProbe(),

View File

@@ -4,6 +4,7 @@ import { soundDirector } from '../audio/SoundDirector';
import { battleUiIconFrames, battleUiIconTextureForSize, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons';
import { selectCampSkin, type CampSkinSelection } from '../data/campSkins';
import {
calculateEquipmentBonuses,
equipmentExpToNext,
equipmentSlotLabels,
equipmentSlots,
@@ -23233,15 +23234,15 @@ export class CampScene extends Phaser.Scene {
}
private equipmentAttackBonus(unit: UnitData) {
return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).attackBonus ?? 0), 0);
return calculateEquipmentBonuses(unit.equipment).attack;
}
private equipmentStrategyBonus(unit: UnitData) {
return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).strategyBonus ?? 0), 0);
return calculateEquipmentBonuses(unit.equipment).strategy;
}
private equipmentDefenseBonus(unit: UnitData) {
return equipmentSlots.reduce((sum, slot) => sum + (getItem(unit.equipment[slot].itemId).defenseBonus ?? 0), 0);
return calculateEquipmentBonuses(unit.equipment).defense;
}
private wolongClueCount() {

View File

@@ -5,6 +5,7 @@ import {
battleUiIconFrames,
battleUiIconMicroTextureKey,
battleUiIconTextureKey,
loadBattleUiIcons,
type BattleUiIconKey
} from '../data/battleUiIcons';
import { portraitAssetEntriesForKey, portraitKeyForSpeaker, type PortraitAssetEntry } from '../data/portraitAssets';
@@ -26,7 +27,7 @@ import {
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
import { ensureLazyScene, startGameScene } from './lazyScenes';
type AlphaObject = Phaser.GameObjects.Container | Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Phaser.GameObjects.Text;
@@ -120,6 +121,11 @@ const firstBattleVictoryPageIds = new Set([
'first-victory-next'
]);
const firstBattleId = 'first-battle-zhuo-commandery';
const firstBattleWarmupUnitTextureKeys = [
'unit-rebel',
'unit-rebel-archer',
'unit-rebel-cavalry'
] as const;
export class StoryScene extends Phaser.Scene {
private pageIndex = 0;
@@ -250,6 +256,7 @@ export class StoryScene extends Phaser.Scene {
}
this.pageLoadingText = undefined;
});
this.warmFirstBattleSceneModule();
this.ensureStoryPageAssets(0, () => {
if (generation !== this.storyAssetLoadGeneration) {
@@ -348,20 +355,31 @@ export class StoryScene extends Phaser.Scene {
this.processStoryPageAssetQueue();
};
const finishUnitBases = () => {
if (generation !== this.storyAssetLoadGeneration) {
return;
}
if (this.isFirstBattleWarmupPage(request.pageIndex)) {
loadBattleUiIcons(this, finishRequest);
return;
}
finishRequest();
};
const loadUnitBases = () => {
if (generation !== this.storyAssetLoadGeneration || plan.unitTextureKeys.length === 0) {
finishRequest();
finishUnitBases();
return;
}
try {
loadUnitBaseSheets(this, plan.unitTextureKeys, finishRequest);
loadUnitBaseSheets(this, plan.unitTextureKeys, finishUnitBases);
} catch (error) {
console.warn(`Failed to prepare story unit bases for page ${request.pageIndex}`, error);
plan.unitTextureKeys
.filter((key) => !this.textures.exists(key))
.forEach((key) => this.storyAssetFailures.add(key));
finishRequest();
finishUnitBases();
}
};
@@ -416,13 +434,39 @@ export class StoryScene extends Phaser.Scene {
.filter((entry): entry is PortraitAssetEntry => entry !== undefined)
.filter((entry, index, entries) => entries.findIndex((candidate) => candidate.textureKey === entry.textureKey) === index);
const warmupUnitTextureKeys = this.isFirstBattleWarmupPage(pageIndex)
? firstBattleWarmupUnitTextureKeys
: [];
return {
backgrounds: backgroundKey && backgroundUrl ? [{ key: backgroundKey, url: backgroundUrl }] : [],
portraits,
unitTextureKeys: Array.from(new Set(storyCutsceneActorTextureKeys(page.cutscene)))
unitTextureKeys: Array.from(new Set([
...storyCutsceneActorTextureKeys(page.cutscene),
...warmupUnitTextureKeys
]))
};
}
private isFirstBattlePrelude() {
const requestedBattleId = typeof this.nextSceneData?.battleId === 'string'
? this.nextSceneData.battleId
: firstBattleId;
return this.nextScene === 'BattleScene' && requestedBattleId === firstBattleId;
}
private isFirstBattleWarmupPage(pageIndex: number) {
return this.isFirstBattlePrelude() && pageIndex === Math.min(1, this.pages.length - 1);
}
private warmFirstBattleSceneModule() {
if (!this.isFirstBattlePrelude()) {
return;
}
void ensureLazyScene(this.game, 'BattleScene').catch((error) => {
console.warn('Failed to warm the first battle scene module.', error);
});
}
private prefetchNextStoryPage(pageIndex: number) {
const nextPageIndex = pageIndex + 1;
if (nextPageIndex < this.pages.length) {

View File

@@ -1,5 +1,11 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import {
combatPresentationModeLabel,
loadCombatPresentationMode,
nextCombatPresentationMode,
saveCombatPresentationMode
} from '../settings/combatPresentation';
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
import {
hasCampaignSave,
@@ -371,10 +377,10 @@ export class TitleScene extends Phaser.Scene {
this.closeSaveSlotPanel();
const panel = this.add.container(x, y + ui(238));
const backdrop = this.add.rectangle(0, 0, ui(286), ui(204), 0x0e151d, 0.9);
const backdrop = this.add.rectangle(0, 0, ui(286), ui(250), 0x0e151d, 0.9);
backdrop.setStrokeStyle(ui(1), palette.gold, 0.52);
const title = this.add.text(0, -ui(74), '설정', {
const title = this.add.text(0, -ui(96), '설정', {
fontSize: uiPx(24),
color: '#f1e3c2',
fontStyle: '700',
@@ -383,20 +389,28 @@ export class TitleScene extends Phaser.Scene {
});
title.setOrigin(0.5);
const soundText = this.createSettingsButton(0, -ui(16), this.soundLabel());
const soundText = this.createSettingsButton(0, -ui(38), this.soundLabel());
soundText.on('pointerdown', () => {
soundDirector.setMuted(!soundDirector.isMuted());
soundText.setText(this.soundLabel());
soundDirector.playSelect();
});
const close = this.createSettingsButton(0, ui(42), '닫기');
const presentationText = this.createSettingsButton(0, ui(16), this.combatPresentationLabel());
presentationText.on('pointerdown', () => {
const nextMode = nextCombatPresentationMode(loadCombatPresentationMode());
saveCombatPresentationMode(nextMode);
presentationText.setText(this.combatPresentationLabel());
soundDirector.playSelect();
});
const close = this.createSettingsButton(0, ui(68), '닫기');
close.on('pointerdown', () => {
soundDirector.playSelect();
this.closeSettingsPanel();
});
const hint = this.add.text(0, ui(78), 'Esc 닫기', {
const hint = this.add.text(0, ui(104), '연출 항목을 눌러 단계 변경 · Esc 닫기', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: uiPx(11),
color: '#9fb0bf',
@@ -406,7 +420,7 @@ export class TitleScene extends Phaser.Scene {
});
hint.setOrigin(0.5);
panel.add([backdrop, title, soundText, close, hint]);
panel.add([backdrop, title, soundText, presentationText, close, hint]);
panel.setAlpha(0);
this.settingsPanel = panel;
this.tweens.add({ targets: panel, alpha: 1, y: y + ui(228), duration: 160, ease: 'Sine.easeOut' });
@@ -432,6 +446,10 @@ export class TitleScene extends Phaser.Scene {
return `음향: ${soundDirector.isMuted() ? '꺼짐' : '켜짐'}`;
}
private combatPresentationLabel() {
return `전투 연출: ${combatPresentationModeLabel(loadCombatPresentationMode())}`;
}
private closeSettingsPanel() {
this.settingsPanel?.destroy();
this.settingsPanel = undefined;

View File

@@ -34,7 +34,13 @@ export async function ensureLazyScene(game: Phaser.Game, key: LazySceneKey) {
}
});
pendingSceneLoads.set(key, load);
await load;
try {
await load;
} finally {
if (pendingSceneLoads.get(key) === load) {
pendingSceneLoads.delete(key);
}
}
}
export async function startLazyScene(scene: Phaser.Scene, key: LazySceneKey, data?: Record<string, unknown>) {

View File

@@ -0,0 +1,109 @@
export const combatPresentationStorageKey = 'heros-web:combat-presentation-mode';
export type CombatPresentationMode = 'always' | 'important' | 'map';
export type CombatPresentationImportance = {
signature?: boolean;
critical?: boolean;
defeated?: boolean;
resonance?: boolean;
growth?: boolean;
};
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
export const combatPresentationModes: readonly CombatPresentationMode[] = ['always', 'important', 'map'];
export const combatPresentationModeLabels: Record<CombatPresentationMode, string> = {
always: '항상',
important: '중요 장면만',
map: '지도 연출'
};
export function isCombatPresentationMode(value: unknown): value is CombatPresentationMode {
return typeof value === 'string' && combatPresentationModes.includes(value as CombatPresentationMode);
}
export function nextCombatPresentationMode(mode: CombatPresentationMode): CombatPresentationMode {
const index = combatPresentationModes.indexOf(mode);
return combatPresentationModes[(index + 1) % combatPresentationModes.length];
}
export function combatPresentationModeLabel(mode: CombatPresentationMode) {
return combatPresentationModeLabels[mode];
}
export function shouldUseFullCombatCutIn(
mode: CombatPresentationMode,
importance: CombatPresentationImportance
) {
if (mode === 'always') {
return true;
}
if (mode === 'map') {
return false;
}
return Boolean(
importance.signature ||
importance.critical ||
importance.defeated ||
importance.resonance ||
importance.growth
);
}
export function combatPresentationImportanceReason(importance: CombatPresentationImportance) {
if (importance.signature) {
return '고유 전법';
}
if (importance.defeated) {
return '격파';
}
if (importance.critical) {
return '치명타';
}
if (importance.resonance) {
return '공명';
}
if (importance.growth) {
return '레벨 상승';
}
return '일반 행동';
}
export function loadCombatPresentationMode(storage = resolveStorage()): CombatPresentationMode {
if (!storage) {
return 'important';
}
try {
const value = storage.getItem(combatPresentationStorageKey);
return isCombatPresentationMode(value) ? value : 'important';
} catch {
return 'important';
}
}
export function saveCombatPresentationMode(
mode: CombatPresentationMode,
storage = resolveStorage()
) {
if (!storage) {
return;
}
try {
storage.setItem(combatPresentationStorageKey, mode);
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
}
function resolveStorage(): StorageLike | undefined {
if (typeof window === 'undefined') {
return undefined;
}
try {
return window.localStorage;
} catch {
return undefined;
}
}

View File

@@ -79,8 +79,12 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
game,
activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key),
audio: () => soundDirector.getDebugState(),
battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' },
camp: () => campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene is not active yet.' },
battle: () => game.scene.isActive('BattleScene')
? battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene debug state is unavailable.' }
: { active: false, reason: 'BattleScene is not active yet.' },
camp: () => game.scene.isActive('CampScene')
? campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene debug state is unavailable.' }
: { active: false, reason: 'CampScene is not active yet.' },
goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined),
goToCamp: () => startLazySceneFromGame(game, 'CampScene'),
forceBattleOutcome: (outcome) => {