282 lines
9.1 KiB
TypeScript
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,
|
|
};
|
|
}
|