Files
Genarrative/src/hooks/useNpcInteractionFlow.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

282 lines
9.1 KiB
TypeScript

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<string, CompanionRecruitPresentation>;
type CompanionObserveFacingMap = Record<string, 'left' | 'right'>;
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<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 buildCompanionRenderStates = useCallback((state: GameState) => buildCompanionRenderStatesForGameState({
gameState: state,
presentationByNpcId,
observeFacingByNpcId,
}), [observeFacingByNpcId, presentationByNpcId]);
const companionRenderStates = buildCompanionRenderStates(gameState);
return {
companionRenderStates,
buildCompanionRenderStates,
};
}