This commit is contained in:
315
src/hooks/idleAdventureFlow.ts
Normal file
315
src/hooks/idleAdventureFlow.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
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<SetStateAction<GameState>>,
|
||||
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<SetStateAction<GameState>>;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user