fix: sequence battle feedback and autosaves

This commit is contained in:
2026-07-29 03:49:21 +09:00
parent d4bd489b3d
commit 26ab244ad2
6 changed files with 2002 additions and 48 deletions

View File

@@ -9,6 +9,7 @@ import { isVisualMotionReduced } from '../settings/visualMotion';
import { battleMapAssets } from '../data/battleMapAssets';
import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
import {
battleScenarios,
defaultBattleScenario,
getBattleScenario,
type BattleCampaignRewardDefinition,
@@ -233,6 +234,7 @@ import {
type BattleSaveUnitStats
} from '../state/battleSaveState';
import {
campaignBattleResumeStorageKeys,
clearCampaignBattleSavesForSlot,
clearBattleSaveStorageForBattle,
readBattleSaveStorageCandidate
@@ -257,6 +259,7 @@ const battleFhdUiScale = 1.5;
const battleEnvironmentTintDepth = 3.04;
const battleEnvironmentHazeDepth = 3.12;
const battleEnvironmentParticleDepth = 3.3;
const battleFeedbackSettlePaddingMs = 24;
const firstBattleTutorialId: CampaignTutorialId = 'first-battle-basic-controls';
type FirstBattlePreparationState = {
@@ -1532,6 +1535,15 @@ type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt' | 'celebrate';
const levelUpCelebrationStepOffsets = [0, -7, -2, -9, 0, -6, -3, -8];
type BattlePhase = 'deployment' | 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved';
type BattleAutosaveReason =
| 'battle-start'
| 'deployment-confirmed'
| 'ally-action'
| 'ally-turn-start'
| 'pagehide'
| 'hidden'
| 'debug';
type BattleAutosaveResult = 'saved' | 'skipped' | 'failed';
type ScenarioCombatAssetStatus = 'idle' | 'loading' | 'ready' | 'degraded';
type BattleCommand = 'attack' | UsableCommand | 'wait';
type DamageCommand = Exclude<BattleCommand, 'wait'>;
@@ -2370,6 +2382,10 @@ type IntentCounterplayEvent = {
type IntentCounterplayActionOutcome = {
defeatedEnemyIds?: string[];
};
type IntentCounterplaySettlement = {
message: string;
popupDuration: number;
};
type TacticalInitiativeRole = BattleSaveTacticalCommandRole;
type TacticalCommandSource = NonNullable<BattleSaveTacticalCommandState['source']>;
@@ -3997,6 +4013,7 @@ export class BattleScene extends Phaser.Scene {
private battleEventQueue: BattleSavePendingEvent[] = [];
private activeBattleEvent?: BattleSavePendingEvent;
private battleEventDismissTween?: Phaser.Tweens.Tween;
private battleEventCompletionWaiters: Array<() => void> = [];
private battleEventPresentationCount = 0;
private deferBattleEventPresentation = false;
private tacticalEventReactionHistory: TacticalEventReactionSnapshot[] = [];
@@ -4016,6 +4033,7 @@ export class BattleScene extends Phaser.Scene {
private turnPromptRiskRows: TurnEndRiskRowView[] = [];
private mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
private mapResultPopupPeakCount = 0;
private battleFeedbackReadableUntil = 0;
private mapResultPopupLast?: {
unitId: string;
lines: string[];
@@ -4164,6 +4182,22 @@ export class BattleScene extends Phaser.Scene {
private launchCamaraderieMemoryBondId?: string;
private launchCamaraderieMemoryBonus = 0;
private storyHandoff?: CampaignStoryHandoffReference;
private battleAutosaveWriteCount = 0;
private battleAutosaveSkipCount = 0;
private battleAutosaveFailureCount = 0;
private battleAutosaveLastResult?: BattleAutosaveResult;
private battleAutosaveLastReason?: BattleAutosaveReason;
private battleAutosaveLastSlot?: number;
private battleAutosaveLastSavedAt?: string;
private battleAutosaveReady = false;
private readonly handleBattlePageHide = () => {
this.persistBattleAutosave('pagehide');
};
private readonly handleBattleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
this.persistBattleAutosave('hidden');
}
};
constructor() {
super('BattleScene');
@@ -4237,6 +4271,8 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptRiskSummaryText = undefined;
this.turnPromptRiskFootnoteText = undefined;
this.turnPromptRiskRows = [];
this.battleEventCompletionWaiters = [];
this.battleFeedbackReadableUntil = 0;
this.turnText = undefined;
this.battleTitleText = undefined;
this.objectiveTrackerText = undefined;
@@ -4354,6 +4390,14 @@ export class BattleScene extends Phaser.Scene {
this.resetSortieCooperationStats();
this.launchCamaraderieMemoryBondId = undefined;
this.launchCamaraderieMemoryBonus = 0;
this.battleAutosaveWriteCount = 0;
this.battleAutosaveSkipCount = 0;
this.battleAutosaveFailureCount = 0;
this.battleAutosaveLastResult = undefined;
this.battleAutosaveLastReason = undefined;
this.battleAutosaveLastSlot = undefined;
this.battleAutosaveLastSavedAt = undefined;
this.battleAutosaveReady = false;
}
create() {
@@ -4384,6 +4428,8 @@ export class BattleScene extends Phaser.Scene {
this.mapResultPopupObjects.forEach((object) => object.active && object.destroy());
this.mapResultPopupObjects = [];
this.hideBattleSoundCaption();
this.battleAutosaveReady = false;
this.uninstallBattleAutosaveHandlers();
});
this.resultFormationReviewVisible = false;
this.resultFormationReviewFeedback = '';
@@ -4478,6 +4524,7 @@ export class BattleScene extends Phaser.Scene {
});
this.input.mouse?.disableContextMenu();
this.installBattleInputHandlers();
this.installBattleAutosaveHandlers();
this.installDebugHotkeys();
this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0);
@@ -4634,6 +4681,27 @@ export class BattleScene extends Phaser.Scene {
this.uninstallDebugHotkeys();
}
private installBattleAutosaveHandlers() {
this.uninstallBattleAutosaveHandlers();
window.addEventListener('pagehide', this.handleBattlePageHide);
document.addEventListener('visibilitychange', this.handleBattleVisibilityChange);
this.events.once(
Phaser.Scenes.Events.SHUTDOWN,
this.uninstallBattleAutosaveHandlers,
this
);
}
private uninstallBattleAutosaveHandlers() {
this.events.off(
Phaser.Scenes.Events.SHUTDOWN,
this.uninstallBattleAutosaveHandlers,
this
);
window.removeEventListener('pagehide', this.handleBattlePageHide);
document.removeEventListener('visibilitychange', this.handleBattleVisibilityChange);
}
private battleInputEventHandled(
channel: string,
event: KeyboardEvent,
@@ -4975,6 +5043,8 @@ export class BattleScene extends Phaser.Scene {
this.time.delayedCall(180, () => {
this.showOpeningBattleEvent();
this.scheduleFirstBattleTutorial();
this.battleAutosaveReady = true;
this.persistBattleAutosave('battle-start');
});
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
this.refreshEnemyIntentForecast();
@@ -11114,6 +11184,8 @@ export class BattleScene extends Phaser.Scene {
this.refreshEnemyIntentForecast();
this.showOpeningBattleEvent();
this.scheduleFirstBattleTutorial();
this.battleAutosaveReady = true;
this.persistBattleAutosave('deployment-confirmed');
}
private deploymentSignature() {
@@ -11949,8 +12021,18 @@ export class BattleScene extends Phaser.Scene {
.filter(Boolean)
.join('/');
soundDirector.playGrowthTick();
this.showMapResultPopup(actor, [`의도 파훼 +${events.length}`, detail], '#ffdf7b', '#2b1606', 18, 36);
return `의도 파훼 +${events.length} · ${actor.name} · ${compactDetail}`;
const popupDuration = this.showMapResultPopup(
actor,
[`의도 파훼 +${events.length}`, detail],
'#ffdf7b',
'#2b1606',
18,
36
);
return {
message: `의도 파훼 +${events.length} · ${actor.name} · ${compactDetail}`,
popupDuration
} satisfies IntentCounterplaySettlement;
}
private recordIntentGuardCounterplay(
@@ -13066,7 +13148,7 @@ export class BattleScene extends Phaser.Scene {
return;
}
this.finishUnitAction(unit, this.commandResultMessage(unit, command));
void this.finishUnitAction(unit, this.commandResultMessage(unit, command));
}
private actionTargetingLabel(action: DamageCommand, usable?: BattleUsable) {
@@ -14263,10 +14345,8 @@ export class BattleScene extends Phaser.Scene {
await this.delay(180);
await this.presentCombatResult(result.counter);
}
await this.showCombatExchangeMapResults(result);
// Keep the result anchored on its unit long enough to read before auto-focusing the next ally.
await this.delay(620);
this.finishUnitAction(attacker, this.formatCombatResult(result), {
await this.showCombatExchangeMapResults(result, true);
await this.finishUnitAction(attacker, this.formatCombatResult(result), {
defeatedEnemyIds: result.defeated ? [result.defender.id] : []
});
}
@@ -14292,10 +14372,9 @@ export class BattleScene extends Phaser.Scene {
const result = this.resolveSupportAction(user, target, usable);
this.clearMarkers();
await this.presentSupportResult(result);
this.showSupportMapResult(result);
// Keep the support result visible before the camera moves to the next actionable ally.
await this.delay(620);
this.finishUnitAction(user, this.formatSupportResult(result));
const popupDuration = this.showSupportMapResult(result);
await this.waitSceneDuration(popupDuration);
await this.finishUnitAction(user, this.formatSupportResult(result));
}
private renderAttackPreview(preview: CombatPreview, locked = false) {
@@ -14359,11 +14438,19 @@ export class BattleScene extends Phaser.Scene {
battleScenario.id,
this.prologueVolunteerReassured
);
this.triggerBattleEvent(firstBattleVolunteerPromiseEventKey, '첫 교전', [
const presentationWasDeferred =
this.deferBattleEventPresentation;
this.deferBattleEventPresentation = true;
try {
this.triggerBattleEvent(firstBattleVolunteerPromiseEventKey, '첫 교전', [
`${attacker.name} · ${target.name} 공격을 시작합니다.`,
'공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.',
...(volunteerPromiseLine ? [volunteerPromiseLine] : [])
], { playCue: false });
], { playCue: false });
} finally {
this.deferBattleEventPresentation =
presentationWasDeferred;
}
}
private battleBuffShortText(buff: BattleBuffState) {
@@ -15336,11 +15423,12 @@ export class BattleScene extends Phaser.Scene {
return x + width + 6;
}
private finishUnitAction(unit: UnitData, message: string, outcome: IntentCounterplayActionOutcome = {}) {
const counterplayMessage = this.settleEnemyIntentCounterplay(unit, this.pendingMove, outcome);
private async finishUnitAction(unit: UnitData, message: string, outcome: IntentCounterplayActionOutcome = {}) {
const counterplaySettlement = this.settleEnemyIntentCounterplay(unit, this.pendingMove, outcome);
const counterplayMessage = counterplaySettlement?.message;
const resolvedMessage = counterplayMessage ? `${counterplayMessage}\n${message}` : message;
this.actedUnitIds.add(unit.id);
this.phase = 'idle';
this.phase = 'animating';
this.selectedUnit = undefined;
this.pendingMove = undefined;
this.targetingAction = undefined;
@@ -15352,14 +15440,27 @@ export class BattleScene extends Phaser.Scene {
this.renderTacticalInitiativeChip();
soundDirector.playSelect();
this.pushBattleLog(resolvedMessage);
this.checkBattleEvents();
this.collectBattleEventsWithoutPresentation();
this.updateObjectiveTracker();
if (this.resolveBattleOutcomeIfNeeded(counterplayMessage ? 820 : 0)) {
await this.waitForBattleFeedbackReadability();
this.showNextBattleEvent();
await this.waitForBattleEventPresentation();
if (!this.scene.isActive() || this.battleOutcome) {
return;
}
this.phase = 'idle';
if (this.resolveBattleOutcomeIfNeeded()) {
return;
}
await this.waitForBattleEventPresentation();
if (!this.scene.isActive() || this.battleOutcome) {
return;
}
this.refreshEnemyIntentForecast();
this.persistBattleAutosave('ally-action');
const remaining = this.remainingAllyCount();
const turnHint =
@@ -19906,6 +20007,8 @@ export class BattleScene extends Phaser.Scene {
this.phase === 'idle' &&
this.activeFaction === 'ally' &&
this.turnNumber === 1 &&
!this.activeBattleEvent &&
this.battleEventQueue.length === 0 &&
this.battleEventObjects.length === 0 &&
this.mapMenuObjects.length === 0 &&
this.saveSlotPanelObjects.length === 0 &&
@@ -21555,6 +21658,7 @@ export class BattleScene extends Phaser.Scene {
}
this.triggeredBattleEvents.add(groupedEvent.key);
});
this.resolveBattleEventCompletionWaiters();
}
private clearBattleEvents() {
@@ -21565,16 +21669,37 @@ export class BattleScene extends Phaser.Scene {
this.activeBattleEvent = undefined;
this.battleEventQueue = [];
this.deferBattleEventPresentation = false;
this.resolveBattleEventCompletionWaiters();
}
private waitForBattleEventPresentation() {
if (!this.activeBattleEvent && this.battleEventObjects.length === 0) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.battleEventCompletionWaiters.push(resolve);
});
}
private resolveBattleEventCompletionWaiters() {
if (this.activeBattleEvent || this.battleEventObjects.length > 0) {
return;
}
this.battleEventCompletionWaiters.splice(0).forEach((resolve) => resolve());
}
private checkBattleEvents() {
this.collectBattleEventsWithoutPresentation();
this.showNextBattleEvent();
}
private collectBattleEventsWithoutPresentation() {
this.deferBattleEventPresentation = true;
try {
this.collectBattleEvents();
} finally {
this.deferBattleEventPresentation = false;
}
this.showNextBattleEvent();
}
private collectBattleEvents() {
@@ -22966,6 +23091,111 @@ export class BattleScene extends Phaser.Scene {
return eventIds;
}
private isSafeBattleAutosaveCheckpoint() {
return (
this.scene.isActive() &&
this.battleAutosaveReady &&
this.activeFaction === 'ally' &&
this.phase === 'idle' &&
!this.battleOutcome &&
!this.resultNavigationPending &&
!this.selectedUnit &&
!this.pendingMove &&
!this.targetingAction &&
!this.selectedUsable
);
}
private persistBattleAutosave(reason: BattleAutosaveReason = 'debug') {
this.battleAutosaveLastReason = reason;
if (!this.isSafeBattleAutosaveCheckpoint()) {
this.battleAutosaveSkipCount += 1;
this.battleAutosaveLastResult = 'skipped';
return false;
}
let previousStorageValues: Array<{
key: string;
value: string | null;
}> = [];
let storageMutationStarted = false;
try {
const campaign = getCampaignState();
const normalizedSlot = normalizeBattleSaveSlot(
campaign.activeSaveSlot,
campaignSaveSlotCount
);
const state = this.createBattleSaveState();
const serializedState = JSON.stringify(state);
const slotKey =
this.battleSaveStorageKeyForSlot(normalizedSlot);
const storageKeys = [
...new Set(
Object.keys(battleScenarios).flatMap((battleId) =>
campaignBattleResumeStorageKeys(
battleId,
normalizedSlot,
campaignSaveSlotCount
)
)
)
];
previousStorageValues = storageKeys.map((key) => ({
key,
value: window.localStorage.getItem(key)
}));
storageMutationStarted = true;
clearCampaignBattleSavesForSlot(normalizedSlot);
window.localStorage.setItem(
slotKey,
serializedState
);
if (normalizedSlot === 1) {
window.localStorage.setItem(
battleSaveStorageKey,
serializedState
);
window.localStorage.removeItem(
legacyBattleSaveStorageKey
);
}
saveCampaignState(campaign, normalizedSlot);
this.battleAutosaveWriteCount += 1;
this.battleAutosaveLastResult = 'saved';
this.battleAutosaveLastSlot = normalizedSlot;
this.battleAutosaveLastSavedAt = state.savedAt;
return true;
} catch {
if (storageMutationStarted) {
this.restoreBattleAutosaveStorage(
previousStorageValues
);
}
this.battleAutosaveFailureCount += 1;
this.battleAutosaveLastResult = 'failed';
return false;
}
}
private restoreBattleAutosaveStorage(
values: ReadonlyArray<{
key: string;
value: string | null;
}>
) {
values.forEach(({ key, value }) => {
try {
if (value === null) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, value);
}
} catch {
// Best effort: write-denied storage normally also preserves the old value.
}
});
}
private saveBattleState(slot = 1) {
try {
const state = this.createBattleSaveState();
@@ -22991,6 +23221,7 @@ export class BattleScene extends Phaser.Scene {
}
try {
this.battleAutosaveReady = false;
const campaign = loadCampaignState(slot);
this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(campaign.selectedSortieUnitIds);
this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(campaign.sortieFormationAssignments);
@@ -23011,7 +23242,9 @@ export class BattleScene extends Phaser.Scene {
);
this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation);
this.applyBattleSaveState(state);
this.battleAutosaveReady = true;
this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
this.scheduleFirstBattleTutorial();
if (this.activeFaction === 'enemy' && !this.battleOutcome) {
this.time.delayedCall(250, () => {
if (this.activeFaction === 'enemy' && !this.battleOutcome) {
@@ -27440,17 +27673,67 @@ export class BattleScene extends Phaser.Scene {
}
private delay(ms: number) {
return new Promise<void>((resolve) => {
this.time.delayedCall(this.scaledBattleDuration(ms), () => resolve());
});
return this.waitSceneDuration(
this.scaledBattleDuration(ms)
);
}
private waitSceneDuration(ms: number) {
return new Promise<void>((resolve) => {
this.time.delayedCall(ms, () => resolve());
if (!this.scene.isActive()) {
resolve();
return;
}
let settled = false;
const complete = () => {
if (settled) {
return;
}
settled = true;
this.events.off(
Phaser.Scenes.Events.SHUTDOWN,
complete
);
resolve();
};
this.events.once(
Phaser.Scenes.Events.SHUTDOWN,
complete
);
this.time.delayedCall(ms, complete);
});
}
private extendBattleFeedbackReadableWindow(duration: number) {
if (duration <= 0) {
return;
}
this.battleFeedbackReadableUntil = Math.max(
this.battleFeedbackReadableUntil,
this.time.now + duration
);
}
private async waitForBattleFeedbackReadability() {
while (this.scene.isActive()) {
const remaining = this.battleFeedbackReadableUntil - this.time.now;
const activeFeedbackObjects =
this.mapResultPopupObjects.some(
(object) => object.active
) ||
Boolean(
this.battleSoundCaption?.active &&
this.battleSoundCaption.visible
);
if (remaining <= 0 && !activeFeedbackObjects) {
return;
}
await this.waitSceneDuration(
remaining > 0 ? remaining : 1
);
}
}
private moveUnitViewAsync(
unit: UnitData,
x: number,
@@ -27588,6 +27871,7 @@ export class BattleScene extends Phaser.Scene {
.join('\n');
this.renderRosterPanel('ally', turnMessage);
this.refreshEnemyIntentForecast();
this.persistBattleAutosave('ally-turn-start');
const firstUnit = this.firstActionableAlly();
if (firstUnit) {
@@ -29506,7 +29790,7 @@ export class BattleScene extends Phaser.Scene {
private showSupportMapResult(result: SupportResult) {
if (result.usable.effect === 'heal') {
this.showMapResultPopup(
return this.showMapResultPopup(
result.target,
[
`회복 +${result.healAmount}`,
@@ -29524,10 +29808,9 @@ export class BattleScene extends Phaser.Scene {
? 42
: 28
);
return;
}
this.showMapResultPopup(
return this.showMapResultPopup(
result.target,
[
`강화 ${result.buff?.turns ?? result.usable.duration ?? 1}`,
@@ -29597,6 +29880,9 @@ export class BattleScene extends Phaser.Scene {
const duration = this.scaledBattleDuration(680, 260);
const delay = this.scaledBattleDuration(80, 30);
this.extendBattleFeedbackReadableWindow(
duration + delay + battleFeedbackSettlePaddingMs
);
const bounds = popup.getBounds();
this.mapResultPopupLast = {
unitId: unit.id,
@@ -29762,6 +30048,9 @@ export class BattleScene extends Phaser.Scene {
}
if (!this.isTileVisible(anchor.x, anchor.y)) {
this.showBattleSoundCaption('objective', lines.filter(Boolean).join(' · '), [anchor]);
this.extendBattleFeedbackReadableWindow(
2020 + battleFeedbackSettlePaddingMs
);
return;
}
@@ -29785,6 +30074,15 @@ export class BattleScene extends Phaser.Scene {
if (this.mapMask) {
popup.setMask(this.mapMask);
}
this.mapResultPopupObjects =
this.mapResultPopupObjects.filter(
(object) => object.active
);
this.mapResultPopupObjects.push(popup);
this.mapResultPopupPeakCount = Math.max(
this.mapResultPopupPeakCount,
this.mapResultPopupObjects.length
);
this.tweens.add({
targets: popup,
y: popup.y - 26,
@@ -29792,8 +30090,20 @@ export class BattleScene extends Phaser.Scene {
duration: 880,
delay: 80,
ease: 'Sine.easeOut',
onComplete: () => popup.destroy()
onComplete: () => {
this.mapResultPopupObjects =
this.mapResultPopupObjects.filter(
(object) =>
object !== popup && object.active
);
if (popup.active) {
popup.destroy();
}
}
});
this.extendBattleFeedbackReadableWindow(
960 + battleFeedbackSettlePaddingMs
);
}
private objectiveFeedbackAnchor(objective: BattleObjectiveDefinition, state: BattleObjectiveState) {
@@ -34230,6 +34540,17 @@ export class BattleScene extends Phaser.Scene {
turnNumber: this.turnNumber,
activeFaction: this.activeFaction,
phase: this.phase,
autosave: {
ready: this.battleAutosaveReady,
safeCheckpoint: this.isSafeBattleAutosaveCheckpoint(),
writeCount: this.battleAutosaveWriteCount,
skipCount: this.battleAutosaveSkipCount,
failureCount: this.battleAutosaveFailureCount,
lastResult: this.battleAutosaveLastResult ?? null,
lastReason: this.battleAutosaveLastReason ?? null,
lastSlot: this.battleAutosaveLastSlot ?? null,
lastSavedAt: this.battleAutosaveLastSavedAt ?? null
},
deploymentKeyboard: this.deploymentKeyboardDebugState(),
combatAssets: {
status: this.scenarioCombatAssetStatus,
@@ -34668,6 +34989,7 @@ export class BattleScene extends Phaser.Scene {
return false;
}
this.triggerFirstEngagementEvent(attacker, target);
this.showNextBattleEvent();
return this.isBattleEventKnown(firstBattleVolunteerPromiseEventKey);
}