257
src/hooks/useNpcInteractionFlow.ts
Normal file
257
src/hooks/useNpcInteractionFlow.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { 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;
|
||||
};
|
||||
|
||||
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 useNpcInteractionFlow(gameState: GameState) {
|
||||
const [presentationByNpcId, setPresentationByNpcId] = useState<Record<string, CompanionRecruitPresentation>>({});
|
||||
const [observeFacingByNpcId, setObserveFacingByNpcId] = useState<Record<string, 'left' | 'right'>>({});
|
||||
const previousCompanionIdsRef = useRef<string[]>([]);
|
||||
const timerIdsRef = useRef<number[]>([]);
|
||||
const observeTimerIdsRef = useRef<Record<string, number>>({});
|
||||
|
||||
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 companionRenderStates: CompanionRenderState[] = (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,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as CompanionRenderState[];
|
||||
|
||||
return {
|
||||
companionRenderStates,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user