Files
Genarrative/src/hooks/useNpcInteractionFlow.ts
高物 c49c64896a
Some checks failed
CI / verify (push) Has been cancelled
初始仓库迁移
2026-04-04 23:57:06 +08:00

258 lines
8.3 KiB
TypeScript

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