223 lines
9.3 KiB
TypeScript
223 lines
9.3 KiB
TypeScript
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
|
|
|
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
|
|
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
|
|
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
|
|
import {AnimationState, WorldType} from '../../types';
|
|
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
|
|
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
|
|
import {GameCanvasOverlayLayer} from './GameCanvasOverlayLayer';
|
|
import {GameCanvasSceneLayer} from './GameCanvasSceneLayer';
|
|
import {
|
|
type GameCanvasProps,
|
|
getCharacterBottomOffsetPx,
|
|
getMonsterWorldLeft,
|
|
getPlayerWorldLeft,
|
|
HOSTILE_NPC_SCENE_INSET_PX,
|
|
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
|
SCENE_TRANSITION_SPEED_PX_PER_S,
|
|
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
|
|
} from './GameCanvasShared';
|
|
|
|
export function GameCanvasRuntime({
|
|
scrollWorld,
|
|
animationState,
|
|
playerCharacter,
|
|
encounter,
|
|
currentScenePreset,
|
|
worldType,
|
|
customWorldProfile = null,
|
|
storyEngineMemory = null,
|
|
sceneHostileNpcs,
|
|
playerX,
|
|
playerOffsetY,
|
|
playerFacing,
|
|
playerActionMode = 'idle',
|
|
inBattle,
|
|
playerHp,
|
|
playerMaxHp,
|
|
activeCombatEffects = [],
|
|
companions = [],
|
|
dialogueIndicator = null,
|
|
npcAffinityEffect = null,
|
|
onEntitySelect = null,
|
|
onSceneNameClick = null,
|
|
sceneTransitionPhase = 'idle',
|
|
sceneTransitionToken = 0,
|
|
onSceneTransitionDurationsChange = null,
|
|
}: GameCanvasProps) {
|
|
const stageRef = useRef<HTMLDivElement>(null);
|
|
const [stageOuterWidth, setStageOuterWidth] = useState(0);
|
|
const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false);
|
|
const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0);
|
|
const previousSceneTitleRef = useRef<string | null>(currentScenePreset?.name ?? null);
|
|
const resolvedWorldType = worldType
|
|
? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA
|
|
: null;
|
|
const activeSceneActBackground =
|
|
currentScenePreset?.id
|
|
? resolveActiveSceneActBackgroundImage({
|
|
profile: customWorldProfile,
|
|
sceneId: currentScenePreset.id,
|
|
storyEngineMemory,
|
|
})
|
|
: null;
|
|
const backgroundSrc = activeSceneActBackground
|
|
|| currentScenePreset?.imageSrc
|
|
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
|
|
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
|
|
const groundBottom = '18%';
|
|
const stageLiftPx = 68;
|
|
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
|
|
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
|
|
const closestHostileNpcDistance = sceneHostileNpcs.length > 0
|
|
? Math.min(...sceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
|
|
: Infinity;
|
|
const escapeLead = scrollWorld ? Math.max(0, Math.min(1, (closestHostileNpcDistance - 1.2) / 3.4)) : 0;
|
|
const sideAnchor = '15%';
|
|
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
|
|
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
|
|
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
|
|
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
|
|
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
|
|
const companionAnchorBottom = `calc(${groundBottom} + ${stageLiftPx}px - ${playerGroundOffset}px + ${playerOffsetY}px)`;
|
|
const playerBottomOffsetPx = getCharacterBottomOffsetPx(stageLiftPx, playerCharacter, playerOffsetY);
|
|
const playerLeft = playerActionMode === 'melee' && !scrollWorld
|
|
? playerMeleeLeft
|
|
: playerWorldLeft;
|
|
const monsterAnchorMeters = 3.2;
|
|
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
|
|
const baseLeft =
|
|
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
|
|
? monsterMeleeLeft
|
|
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
|
|
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
|
|
};
|
|
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
|
|
const base = playerActionMode === 'melee' && !scrollWorld
|
|
? playerMeleeLeft
|
|
: getPlayerWorldLeft(sideAnchor, effectX, cameraAnchorX);
|
|
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
|
|
};
|
|
const getHostileNpcEffectLeft = (effectX: number, hostileNpcId?: string, offsetPx = 0) => {
|
|
const effectHostileNpc = hostileNpcId ? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
|
|
const base = effectHostileNpc
|
|
? getHostileNpcOuterLeft(effectHostileNpc)
|
|
: getMonsterWorldLeft(sideAnchor, effectX, cameraAnchorX, monsterAnchorMeters);
|
|
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
|
|
};
|
|
const isSceneTransitionExiting = sceneTransitionPhase === 'exiting';
|
|
const isSceneTransitionEntering = sceneTransitionPhase === 'entering';
|
|
const effectivePlayerAnimationState = sceneTransitionPhase === 'idle' ? animationState : AnimationState.RUN;
|
|
const effectivePlayerFacing = sceneTransitionPhase === 'idle' ? playerFacing : 'right';
|
|
const shouldShowPlayerDialogueIcon =
|
|
Boolean(dialogueIndicator?.showPlayer)
|
|
&& sceneTransitionPhase === 'idle'
|
|
&& effectivePlayerAnimationState !== AnimationState.RUN;
|
|
const transitionSweepPx = Math.max(stageOuterWidth + SCENE_TRANSITION_SPRITE_CLEARANCE_PX, 320);
|
|
const sceneTransitionTravelDurationS = transitionSweepPx / SCENE_TRANSITION_SPEED_PX_PER_S;
|
|
const sceneTransitionExitDurationS = sceneTransitionTravelDurationS;
|
|
const sceneTransitionEntryDurationS = sceneTransitionTravelDurationS;
|
|
const sceneTransitionEntryTotalDurationS =
|
|
sceneTransitionEntryDurationS + SCENE_TRANSITION_LOWER_COMPANION_DELAY_S;
|
|
|
|
useLayoutEffect(() => {
|
|
const stage = stageRef.current;
|
|
if (!stage) return;
|
|
|
|
const measure = () => setStageOuterWidth(stage.clientWidth);
|
|
|
|
measure();
|
|
const observer = new ResizeObserver(() => measure());
|
|
observer.observe(stage);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setBackgroundLoadFailed(false);
|
|
}, [backgroundSrc]);
|
|
|
|
useEffect(() => {
|
|
onSceneTransitionDurationsChange?.({
|
|
exitMs: Math.round(sceneTransitionExitDurationS * 1000),
|
|
entryMs: Math.round(sceneTransitionEntryTotalDurationS * 1000),
|
|
});
|
|
}, [
|
|
onSceneTransitionDurationsChange,
|
|
sceneTransitionEntryTotalDurationS,
|
|
sceneTransitionExitDurationS,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const nextSceneTitle = currentScenePreset?.name ?? null;
|
|
const previousSceneTitle = previousSceneTitleRef.current;
|
|
|
|
if (nextSceneTitle && previousSceneTitle && previousSceneTitle !== nextSceneTitle) {
|
|
setSceneTitleSpinToken(current => current + 1);
|
|
}
|
|
|
|
previousSceneTitleRef.current = nextSceneTitle;
|
|
}, [currentScenePreset?.name]);
|
|
|
|
return (
|
|
<div ref={stageRef} className="relative h-full w-full overflow-hidden bg-black">
|
|
<GameCanvasSceneLayer
|
|
backgroundLoadFailed={backgroundLoadFailed}
|
|
backgroundSrc={backgroundSrc}
|
|
currentScenePreset={currentScenePreset}
|
|
resolvedWorldType={resolvedWorldType}
|
|
sceneTitleSpinToken={sceneTitleSpinToken}
|
|
onSceneNameClick={onSceneNameClick}
|
|
onBackgroundLoadError={() => setBackgroundLoadFailed(true)}
|
|
/>
|
|
<GameCanvasEntityLayer
|
|
companions={companions}
|
|
currentScenePreset={currentScenePreset}
|
|
sceneTransitionToken={sceneTransitionToken}
|
|
isSceneTransitionEntering={isSceneTransitionEntering}
|
|
isSceneTransitionExiting={isSceneTransitionExiting}
|
|
transitionSweepPx={transitionSweepPx}
|
|
sceneTransitionExitDurationS={sceneTransitionExitDurationS}
|
|
sceneTransitionEntryDurationS={sceneTransitionEntryDurationS}
|
|
companionAnchorLeft={companionAnchorLeft}
|
|
companionAnchorBottom={companionAnchorBottom}
|
|
playerBottomOffsetPx={playerBottomOffsetPx}
|
|
sceneTransitionPhase={sceneTransitionPhase}
|
|
inBattle={inBattle}
|
|
onEntitySelect={onEntitySelect}
|
|
playerLeft={playerLeft}
|
|
playerCharacter={playerCharacter}
|
|
playerHp={playerHp}
|
|
playerMaxHp={playerMaxHp}
|
|
effectivePlayerFacing={effectivePlayerFacing}
|
|
effectivePlayerAnimationState={effectivePlayerAnimationState}
|
|
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
|
|
dialogueIndicator={dialogueIndicator}
|
|
npcAffinityEffect={npcAffinityEffect}
|
|
sceneCombatants={sceneHostileNpcs}
|
|
monsters={monsters}
|
|
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
|
|
groundBottom={groundBottom}
|
|
stageLiftPx={stageLiftPx}
|
|
encounter={encounter}
|
|
sideAnchor={sideAnchor}
|
|
cameraAnchorX={cameraAnchorX}
|
|
monsterAnchorMeters={monsterAnchorMeters}
|
|
playerX={playerX}
|
|
/>
|
|
<GameCanvasEffectLayer
|
|
activeCombatEffects={activeCombatEffects}
|
|
getPlayerEffectLeft={getPlayerEffectLeft}
|
|
getHostileNpcEffectLeft={getHostileNpcEffectLeft}
|
|
sceneCombatants={sceneHostileNpcs}
|
|
playerCharacter={playerCharacter}
|
|
groundBottom={groundBottom}
|
|
stageLiftPx={stageLiftPx}
|
|
playerOffsetY={playerOffsetY}
|
|
stageRef={stageRef}
|
|
/>
|
|
<GameCanvasOverlayLayer escapeLead={escapeLead} />
|
|
</div>
|
|
);
|
|
}
|