import { useCallback, useEffect, useRef, useState } from 'react'; import { getCharacterAnimationDurationMs } from '../data/characterCombat'; import { getCharacterById } from '../data/characterPresets'; import { AnimationState, CompanionRenderState, GameState } from '../types'; type CompanionRecruitPresentation = { animationState: AnimationState; entryOffsetX: number; entryOffsetY: number; transitionMs: number; recruitToken: number; }; type CompanionPresentationMap = Record; type CompanionObserveFacingMap = Record; const RECRUIT_ENTRY_OFFSET_X = 148; const RECRUIT_ENTRY_OFFSET_Y = 10; const MIN_RECRUIT_PHASE_MS = 180; const OBSERVE_MIN_PAUSE_MS = 500; const OBSERVE_MAX_PAUSE_MS = 2000; function randomFacing() { return Math.random() > 0.5 ? 'left' as const : 'right' as const; } function randomObservePause() { return Math.round(OBSERVE_MIN_PAUSE_MS + Math.random() * (OBSERVE_MAX_PAUSE_MS - OBSERVE_MIN_PAUSE_MS)); } export function buildCompanionRenderStatesForGameState(params: { gameState: GameState; presentationByNpcId?: CompanionPresentationMap; observeFacingByNpcId?: CompanionObserveFacingMap; }) { const { gameState, presentationByNpcId = {}, observeFacingByNpcId = {}, } = params; return (gameState.companions ?? []) .map((companion, index) => { const character = getCharacterById(companion.characterId); if (!character) return null; const presentation = presentationByNpcId[companion.npcId]; return { npcId: companion.npcId, character, hp: companion.hp, maxHp: companion.maxHp, mana: companion.mana, maxMana: companion.maxMana, skillCooldowns: companion.skillCooldowns, animationState: presentation?.animationState ?? ( companion.hp <= 0 ? companion.animationState ?? AnimationState.DIE : gameState.scrollWorld ? AnimationState.RUN : gameState.inBattle ? companion.animationState ?? AnimationState.IDLE : gameState.animationState ), actionMode: companion.actionMode ?? 'idle', slot: index % 2 === 0 ? 'upper' : 'lower', facing: observeFacingByNpcId[companion.npcId] ?? gameState.playerFacing, entryOffsetX: (presentation?.entryOffsetX ?? 0) + (companion.offsetX ?? 0), entryOffsetY: (presentation?.entryOffsetY ?? 0) + (companion.offsetY ?? 0), transitionMs: presentation?.transitionMs ?? companion.transitionMs ?? 0, recruitToken: presentation?.recruitToken, } satisfies CompanionRenderState; }) .filter(Boolean) as CompanionRenderState[]; } export function useNpcInteractionFlow(gameState: GameState) { const [presentationByNpcId, setPresentationByNpcId] = useState>({}); const [observeFacingByNpcId, setObserveFacingByNpcId] = useState>({}); const previousCompanionIdsRef = useRef([]); const timerIdsRef = useRef([]); const observeTimerIdsRef = useRef>({}); useEffect(() => { return () => { timerIdsRef.current.forEach(timerId => window.clearTimeout(timerId)); timerIdsRef.current = []; (Object.values(observeTimerIdsRef.current) as number[]).forEach(timerId => window.clearTimeout(timerId)); observeTimerIdsRef.current = {}; }; }, []); useEffect(() => { const currentCompanions = gameState.companions ?? []; const previousIds = new Set(previousCompanionIdsRef.current); const currentIds = currentCompanions.map(companion => companion.npcId); const currentIdSet = new Set(currentIds); setPresentationByNpcId(current => { const next = { ...current }; Object.keys(next).forEach(npcId => { if (!currentIdSet.has(npcId)) { delete next[npcId]; } }); return next; }); currentCompanions.forEach(companion => { if (previousIds.has(companion.npcId)) return; const character = getCharacterById(companion.characterId); if (!character) return; const hasDash = Boolean(character.animationMap?.[AnimationState.DASH]); const hasAcquire = Boolean(character.animationMap?.[AnimationState.ACQUIRE]); const dashDuration = hasDash ? Math.max(MIN_RECRUIT_PHASE_MS, getCharacterAnimationDurationMs(character, AnimationState.DASH)) : 0; const acquireDuration = hasAcquire ? Math.max(MIN_RECRUIT_PHASE_MS, getCharacterAnimationDurationMs(character, AnimationState.ACQUIRE)) : 0; const recruitToken = Date.now() + Math.random(); if (hasDash) { setPresentationByNpcId(current => ({ ...current, [companion.npcId]: { animationState: AnimationState.DASH, entryOffsetX: RECRUIT_ENTRY_OFFSET_X, entryOffsetY: RECRUIT_ENTRY_OFFSET_Y, transitionMs: 0, recruitToken, }, })); const launchTimer = window.setTimeout(() => { setPresentationByNpcId(current => { const existing = current[companion.npcId]; if (!existing) return current; return { ...current, [companion.npcId]: { ...existing, entryOffsetX: 0, entryOffsetY: 0, transitionMs: dashDuration, }, }; }); }, 16); timerIdsRef.current.push(launchTimer); const afterDashTimer = window.setTimeout(() => { if (!hasAcquire) { setPresentationByNpcId(current => { const next = { ...current }; delete next[companion.npcId]; return next; }); return; } setPresentationByNpcId(current => { const existing = current[companion.npcId]; if (!existing) return current; return { ...current, [companion.npcId]: { ...existing, animationState: AnimationState.ACQUIRE, transitionMs: 0, }, }; }); }, dashDuration + 24); timerIdsRef.current.push(afterDashTimer); if (hasAcquire) { const clearTimer = window.setTimeout(() => { setPresentationByNpcId(current => { const next = { ...current }; delete next[companion.npcId]; return next; }); }, dashDuration + acquireDuration + 56); timerIdsRef.current.push(clearTimer); } return; } if (hasAcquire) { setPresentationByNpcId(current => ({ ...current, [companion.npcId]: { animationState: AnimationState.ACQUIRE, entryOffsetX: 0, entryOffsetY: 0, transitionMs: 0, recruitToken, }, })); const clearTimer = window.setTimeout(() => { setPresentationByNpcId(current => { const next = { ...current }; delete next[companion.npcId]; return next; }); }, acquireDuration + 40); timerIdsRef.current.push(clearTimer); } }); previousCompanionIdsRef.current = currentIds; }, [gameState.companions]); useEffect(() => { const currentCompanions = gameState.companions ?? []; const currentIds = new Set(currentCompanions.map(companion => companion.npcId)); const isObserveActive = gameState.ambientIdleMode === 'observe_signs'; const clearObserveTimer = (npcId: string) => { const timerId = observeTimerIdsRef.current[npcId]; if (timerId) { window.clearTimeout(timerId); delete observeTimerIdsRef.current[npcId]; } }; Object.keys(observeTimerIdsRef.current).forEach(npcId => { if (!isObserveActive || !currentIds.has(npcId)) { clearObserveTimer(npcId); } }); if (!isObserveActive) { setObserveFacingByNpcId({}); return; } const scheduleNextObserveTurn = (npcId: string) => { clearObserveTimer(npcId); observeTimerIdsRef.current[npcId] = window.setTimeout(() => { setObserveFacingByNpcId(current => ({ ...current, [npcId]: randomFacing(), })); scheduleNextObserveTurn(npcId); }, randomObservePause()); }; currentCompanions.forEach(companion => { if (observeTimerIdsRef.current[companion.npcId]) return; setObserveFacingByNpcId(current => ({ ...current, [companion.npcId]: randomFacing(), })); scheduleNextObserveTurn(companion.npcId); }); }, [gameState.ambientIdleMode, gameState.companions]); const buildCompanionRenderStates = useCallback((state: GameState) => buildCompanionRenderStatesForGameState({ gameState: state, presentationByNpcId, observeFacingByNpcId, }), [observeFacingByNpcId, presentationByNpcId]); const companionRenderStates = buildCompanionRenderStates(gameState); return { companionRenderStates, buildCompanionRenderStates, }; }