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(null); const [stageOuterWidth, setStageOuterWidth] = useState(0); const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false); const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0); const previousSceneTitleRef = useRef(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 (
setBackgroundLoadFailed(true)} />
); }