316 lines
9.5 KiB
TypeScript
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;
|
|
}
|