Files
Genarrative/src/hooks/idleAdventureFlow.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

316 lines
9.5 KiB
TypeScript

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;
}