import type { Dispatch, SetStateAction } from 'react'; import { getCharacterAnimationDurationMs } from '../data/characterCombat'; import { buildEncounterEntryState, hasEncounterEntity, interpolateEncounterTransitionState, } from '../data/encounterTransition'; import { CALL_OUT_ENTRY_X_METERS, createSceneCallOutEncounter, createSceneEncounterPreview, resolveSceneEncounterPreview, } from '../data/sceneEncounterPreviews'; import { AnimationState, Character, GameState, StoryOption } from '../types'; const TURN_VISUAL_MS = 820; const RESET_STAGE_MS = 260; const CALL_OUT_APPROACH_DURATION_MS = 1800; const CALL_OUT_APPROACH_TICK_MS = 180; const CALL_OUT_ALERT_PAUSE_MS = 260; const OBSERVE_SIGNS_DURATION_MS = 5000; const OBSERVE_SIGNS_MIN_PAUSE_MS = 500; const OBSERVE_SIGNS_MAX_PAUSE_MS = 2000; type RecoveryApplier = ( state: GameState, character: Character, functionId: string, ) => GameState; function sleep(ms: number) { return new Promise(resolve => window.setTimeout(resolve, ms)); } function randomBetween(min: number, max: number) { return Math.round(min + Math.random() * (max - min)); } async function playEncounterEntrySequence( setGameState: Dispatch>, startState: GameState, finalState: GameState, durationMs: number, tickMs: number, ) { if (!hasEncounterEntity(finalState)) { setGameState(finalState); return finalState; } const runTicks = Math.max(1, Math.ceil(durationMs / tickMs)); const tickDurationMs = Math.max(1, Math.round(durationMs / runTicks)); let currentState = startState; setGameState(currentState); for (let tick = 1; tick <= runTicks; tick += 1) { const progress = tick / runTicks; currentState = interpolateEncounterTransitionState(startState, finalState, progress); setGameState(currentState); await sleep(tickDurationMs); } setGameState(finalState); return finalState; } export function buildIdleAfterSequence(params: { state: GameState; option: StoryOption; character: Character; nextScenePreset: GameState['currentScenePreset']; applyRecoveryEffectToState: RecoveryApplier; }) { const { state, option, character, nextScenePreset, applyRecoveryEffectToState } = params; let afterSequence = applyRecoveryEffectToState(state, character, option.functionId); if (option.functionId === 'idle_call_out') { const baseState = { ...afterSequence, ambientIdleMode: undefined, currentScenePreset: nextScenePreset ?? afterSequence.currentScenePreset, currentEncounter: null, npcInteractionActive: false, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sceneHostileNpcs: [], playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: false, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, } as GameState; const callOutState = { ...baseState, ...createSceneCallOutEncounter(baseState), } as GameState; afterSequence = callOutState.sceneHostileNpcs.length > 0 || callOutState.currentEncounter ? resolveSceneEncounterPreview(callOutState) : baseState; } else if (option.functionId === 'idle_explore_forward') { afterSequence = resolveSceneEncounterPreview({ ...afterSequence, ambientIdleMode: undefined, currentScenePreset: nextScenePreset ?? afterSequence.currentScenePreset, playerActionMode: 'idle' as const, activeCombatEffects: [], } as GameState); } else if (option.functionId === 'idle_travel_next_scene') { const travelBaseState = { ...afterSequence, ambientIdleMode: undefined, currentScenePreset: nextScenePreset, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: false, } as GameState; const travelEntryState = { ...travelBaseState, ...createSceneEncounterPreview(travelBaseState), } as GameState; afterSequence = hasEncounterEntity(travelEntryState) ? resolveSceneEncounterPreview(travelEntryState) : travelBaseState; } else { afterSequence = { ...afterSequence, ambientIdleMode: undefined, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], currentScenePreset: nextScenePreset, playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: false, } as GameState; } return afterSequence; } export async function playIdleSequence(params: { setGameState: Dispatch>; state: GameState; option: StoryOption; character: Character; finalState: GameState; applyRecoveryEffectToState: RecoveryApplier; }) { const { setGameState, state, option, character, finalState, applyRecoveryEffectToState } = params; let currentState = applyRecoveryEffectToState(state, character, option.functionId); if (currentState !== state) { setGameState(currentState); await sleep(RESET_STAGE_MS); } if (option.functionId === 'idle_observe_signs') { let elapsedMs = 0; let nextFacing: 'left' | 'right' = currentState.playerFacing === 'left' ? 'right' : 'left'; currentState = { ...currentState, ambientIdleMode: 'observe_signs', animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, }; setGameState(currentState); while (elapsedMs < OBSERVE_SIGNS_DURATION_MS) { currentState = { ...currentState, ambientIdleMode: 'observe_signs', playerFacing: nextFacing, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, }; setGameState(currentState); const remainingMs = OBSERVE_SIGNS_DURATION_MS - elapsedMs; const pauseMs = Math.min( remainingMs, randomBetween(OBSERVE_SIGNS_MIN_PAUSE_MS, OBSERVE_SIGNS_MAX_PAUSE_MS), ); await sleep(pauseMs); elapsedMs += pauseMs; nextFacing = nextFacing === 'left' ? 'right' : 'left'; } setGameState(finalState); return finalState; } if (option.functionId === 'idle_explore_forward') { setGameState(finalState); return finalState; } if (option.functionId === 'idle_call_out') { const callOutAcquireDurationMs = Math.max( CALL_OUT_ALERT_PAUSE_MS, getCharacterAnimationDurationMs(character, AnimationState.ACQUIRE), ); currentState = { ...currentState, ambientIdleMode: undefined, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], playerFacing: 'right', animationState: AnimationState.ACQUIRE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: false, }; setGameState(currentState); await sleep(callOutAcquireDurationMs); const approachState = { ...finalState, playerFacing: 'right' as const, animationState: AnimationState.ACQUIRE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, }; const entryState = buildEncounterEntryState(approachState, CALL_OUT_ENTRY_X_METERS); const approachedState = await playEncounterEntrySequence( setGameState, entryState, approachState, CALL_OUT_APPROACH_DURATION_MS, CALL_OUT_APPROACH_TICK_MS, ); if ( approachedState.animationState !== finalState.animationState || approachedState.playerActionMode !== finalState.playerActionMode || approachedState.scrollWorld !== finalState.scrollWorld ) { setGameState(finalState); } return finalState; } if (option.functionId === 'idle_travel_next_scene') { currentState = { ...currentState, ambientIdleMode: undefined, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], playerFacing: 'right', animationState: AnimationState.RUN, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: true, }; setGameState(currentState); await sleep(TURN_VISUAL_MS); const entryState = buildEncounterEntryState(finalState, CALL_OUT_ENTRY_X_METERS); return playEncounterEntrySequence( setGameState, entryState, finalState, CALL_OUT_APPROACH_DURATION_MS, CALL_OUT_APPROACH_TICK_MS, ); } const nextPlayerX = Number((state.playerX + option.visuals.playerMoveMeters).toFixed(2)); const shouldAnimateMove = option.visuals.playerAnimation !== AnimationState.IDLE || Math.abs(option.visuals.playerMoveMeters) > 0; if (shouldAnimateMove) { currentState = { ...currentState, playerX: nextPlayerX, playerFacing: option.visuals.playerFacing, animationState: option.visuals.playerAnimation, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: option.visuals.scrollWorld, }; setGameState(currentState); await sleep(TURN_VISUAL_MS); } setGameState(finalState); return finalState; }