208
src/components/game-canvas/GameCanvasRuntime.tsx
Normal file
208
src/components/game-canvas/GameCanvasRuntime.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
import {resolveRuleWorldType} from '../../data/customWorldRuntime';
|
||||
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
|
||||
import {getWorldCampScenePreset} from '../../data/scenePresets';
|
||||
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,
|
||||
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,
|
||||
sceneHostileNpcs,
|
||||
sceneMonsters,
|
||||
playerX,
|
||||
playerOffsetY,
|
||||
playerFacing,
|
||||
playerActionMode = 'idle',
|
||||
inBattle,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
activeCombatEffects = [],
|
||||
companions = [],
|
||||
dialogueIndicator = 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 ? resolveRuleWorldType(worldType) ?? WorldType.WUXIA : null;
|
||||
const backgroundSrc = currentScenePreset?.imageSrc
|
||||
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
|
||||
const campSceneId = worldType ? getWorldCampScenePreset(worldType)?.id ?? null : null;
|
||||
const showOpeningCampOverlay = Boolean(!inBattle && currentScenePreset?.id && currentScenePreset.id === campSceneId);
|
||||
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 resolvedSceneHostileNpcs = sceneHostileNpcs ?? sceneMonsters ?? [];
|
||||
const closestHostileNpcDistance = resolvedSceneHostileNpcs.length > 0
|
||||
? Math.min(...resolvedSceneHostileNpcs.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 resolvedSceneHostileNpcs)[number]) =>
|
||||
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
|
||||
? monsterMeleeLeft
|
||||
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
|
||||
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 ? resolvedSceneHostileNpcs.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}
|
||||
showOpeningCampOverlay={showOpeningCampOverlay}
|
||||
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}
|
||||
sceneHostileNpcs={resolvedSceneHostileNpcs}
|
||||
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}
|
||||
sceneHostileNpcs={resolvedSceneHostileNpcs}
|
||||
playerCharacter={playerCharacter}
|
||||
groundBottom={groundBottom}
|
||||
stageLiftPx={stageLiftPx}
|
||||
playerOffsetY={playerOffsetY}
|
||||
stageRef={stageRef}
|
||||
/>
|
||||
<GameCanvasOverlayLayer escapeLead={escapeLead} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user