diff --git a/public/sounds/dialogue/narrateur_coupureélec.mp3 b/public/sounds/dialogue/narrateur_coupure_elec.mp3 similarity index 100% rename from public/sounds/dialogue/narrateur_coupureélec.mp3 rename to public/sounds/dialogue/narrateur_coupure_elec.mp3 diff --git a/public/sounds/dialogue/narrateur_courantréparé.mp3 b/public/sounds/dialogue/narrateur_courant_repare.mp3 similarity index 100% rename from public/sounds/dialogue/narrateur_courantréparé.mp3 rename to public/sounds/dialogue/narrateur_courant_repare.mp3 diff --git a/public/sounds/dialogue/narrateur_poteauéleccassé.mp3 b/public/sounds/dialogue/narrateur_poteau_elec_casse.mp3 similarity index 100% rename from public/sounds/dialogue/narrateur_poteauéleccassé.mp3 rename to public/sounds/dialogue/narrateur_poteau_elec_casse.mp3 diff --git a/src/components/gameplay/pylon/PylonDownedPylon.tsx b/src/components/gameplay/pylon/PylonDownedPylon.tsx new file mode 100644 index 0000000..9cafb24 --- /dev/null +++ b/src/components/gameplay/pylon/PylonDownedPylon.tsx @@ -0,0 +1,127 @@ +import { useRef, useState } from "react"; +import { useFrame } from "@react-three/fiber"; +import { useGLTF } from "@react-three/drei"; +import * as THREE from "three"; +import { InteractableObject } from "@/components/three/interaction/InteractableObject"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { playDialogueById } from "@/utils/dialogues/playDialogue"; +import { + PYLON_DOWNED_ROTATION, + PYLON_NARRATIVE_INTERACT_RADIUS, + PYLON_NARRATIVE_DIALOGUES, + PYLON_STRAIGHTEN_ANIMATION_DURATION_MS, + PYLON_UPRIGHT_ROTATION, + PYLON_WORLD_POSITION, +} from "@/data/gameplay/pylonConfig"; + +const PYLON_MODEL_PATH = "/models/pylone/model.gltf"; + +export function PylonDownedPylon(): React.JSX.Element | null { + const mainState = useGameStore((state) => state.mainState); + const step = useGameStore((state) => state.pylon.currentStep); + const setMissionStep = useGameStore((state) => state.setMissionStep); + const setCanMove = useGameStore((state) => state.setCanMove); + const [isStraightening, setIsStraightening] = useState(false); + const groupRef = useRef(null); + const straightenStartRef = useRef(null); + + const { scene } = useGLTF(PYLON_MODEL_PATH); + + useFrame(() => { + const group = groupRef.current; + if (!group) return; + + if (!isStraightening || straightenStartRef.current === null) { + const targetRotation = + step === "narrator-outro" + ? PYLON_UPRIGHT_ROTATION + : PYLON_DOWNED_ROTATION; + group.rotation.set(...targetRotation); + return; + } + + const elapsed = performance.now() - straightenStartRef.current; + const t = Math.min(elapsed / PYLON_STRAIGHTEN_ANIMATION_DURATION_MS, 1); + const eased = 1 - Math.pow(1 - t, 3); + const startEuler = new THREE.Euler(...PYLON_DOWNED_ROTATION); + + group.rotation.set( + THREE.MathUtils.lerp(startEuler.x, 0, eased), + startEuler.y, + THREE.MathUtils.lerp(startEuler.z, 0, eased), + ); + }); + + if (mainState !== "pylon") return null; + + if ( + step === "approaching" || + step === "waiting" || + step === "inspected" || + step === "fragmented" || + step === "scanning" || + step === "repairing" || + step === "reassembling" || + step === "done" + ) { + return null; + } + + const isPylonInteractive = step === "arrived" || step === "npc-return"; + + const beginStraighten = (): void => { + setIsStraightening(true); + straightenStartRef.current = performance.now(); + setCanMove(false); + if (groupRef.current) { + groupRef.current.rotation.set(...PYLON_DOWNED_ROTATION); + } + window.setTimeout(() => { + setIsStraightening(false); + setCanMove(true); + setMissionStep("pylon", "waiting"); + }, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS); + }; + + return ( + + + {isPylonInteractive ? ( + { + if (step === "arrived") { + void (async () => { + const manifest = await loadDialogueManifest(); + if (!manifest) return; + await playDialogueById( + manifest, + PYLON_NARRATIVE_DIALOGUES.brokenPylon, + ); + })(); + } else if (step === "npc-return" && !isStraightening) { + beginStraighten(); + } + }} + > + + + + + + ) : null} + + ); +} + +useGLTF.preload(PYLON_MODEL_PATH); diff --git a/src/components/gameplay/pylon/PylonFarmerNPC.tsx b/src/components/gameplay/pylon/PylonFarmerNPC.tsx new file mode 100644 index 0000000..9cf3ea9 --- /dev/null +++ b/src/components/gameplay/pylon/PylonFarmerNPC.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { useFrame } from "@react-three/fiber"; +import { InteractableObject } from "@/components/three/interaction/InteractableObject"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { playDialogueById } from "@/utils/dialogues/playDialogue"; +import { + PYLON_FARMER_NPC_AFTER_POSITION, + PYLON_FARMER_NPC_POSITION, + PYLON_NARRATIVE_DIALOGUES, + PYLON_NARRATIVE_INTERACT_RADIUS, +} from "@/data/gameplay/pylonConfig"; + +export function PylonFarmerNPC(): React.JSX.Element | null { + const mainState = useGameStore((state) => state.mainState); + const step = useGameStore((state) => state.pylon.currentStep); + const setMissionStep = useGameStore((state) => state.setMissionStep); + const setCanMove = useGameStore((state) => state.setCanMove); + const groupRef = useRef(null); + + useEffect(() => { + if (mainState !== "pylon" || step !== "arrived") return; + + if (!groupRef.current) return; + (groupRef.current.userData as Record).startTime = + undefined; + }, [mainState, step]); + + useFrame(() => { + const group = groupRef.current; + if (!group) return; + + if ( + step === "npc-return" || + step === "waiting" || + step === "narrator-outro" + ) { + const startTime = (group.userData as Record) + .startTime as number | undefined; + if (startTime === undefined) { + (group.userData as Record).startTime = + performance.now(); + group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION); + return; + } + group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION); + } else { + group.position.set(...PYLON_FARMER_NPC_POSITION); + } + }); + + if (mainState !== "pylon") return null; + if (step !== "arrived") return null; + + return ( + + + + + + + + + + { + setCanMove(false); + void (async () => { + const manifest = await loadDialogueManifest(); + if (!manifest) { + setCanMove(true); + setMissionStep("pylon", "npc-return"); + return; + } + const audio = await playDialogueById( + manifest, + PYLON_NARRATIVE_DIALOGUES.farmerHelp, + ); + if (!audio) { + setCanMove(true); + setMissionStep("pylon", "npc-return"); + return; + } + audio.addEventListener( + "ended", + () => { + setCanMove(true); + setMissionStep("pylon", "npc-return"); + }, + { once: true }, + ); + })(); + }} + > + + + + + + + ); +} diff --git a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx new file mode 100644 index 0000000..c7258d9 --- /dev/null +++ b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx @@ -0,0 +1,61 @@ +import { useGameStore } from "@/managers/stores/useGameStore"; +import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback"; +import { ZoneDetection } from "@/components/zone/ZoneDetection"; +import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon"; +import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC"; +import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro"; +import { PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones"; +import { PYLON_NARRATIVE_DIALOGUES } from "@/data/gameplay/pylonConfig"; + +export function PylonNarrativeFlow(): React.JSX.Element | null { + const mainState = useGameStore((state) => state.mainState); + const step = useGameStore((state) => state.pylon.currentStep); + const setMissionStep = useGameStore((state) => state.setMissionStep); + const completeMission = useGameStore((state) => state.completeMission); + + useDialoguePlayback({ + enabled: mainState === "pylon" && step === "approaching", + dialogueId: PYLON_NARRATIVE_DIALOGUES.electricOutage, + }); + + useDialoguePlayback({ + enabled: mainState === "pylon" && step === "arrived", + dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral, + }); + + useDialoguePlayback({ + enabled: mainState === "pylon" && step === "narrator-outro", + dialogueId: PYLON_NARRATIVE_DIALOGUES.powerRestored, + onComplete: () => completeMission("pylon"), + }); + + if (mainState !== "pylon") return null; + + if (step === "approaching") { + return ( + setMissionStep("pylon", "arrived")} + /> + ); + } + + if (step === "arrived") { + return ( + <> + + + + ); + } + + if (step === "npc-return") { + return ; + } + + if (step === "narrator-outro") { + return ; + } + + return null; +} diff --git a/src/components/gameplay/pylon/PylonNarratorOutro.tsx b/src/components/gameplay/pylon/PylonNarratorOutro.tsx new file mode 100644 index 0000000..aaaea02 --- /dev/null +++ b/src/components/gameplay/pylon/PylonNarratorOutro.tsx @@ -0,0 +1,11 @@ +import { useGameStore } from "@/managers/stores/useGameStore"; + +export function PylonNarratorOutro(): React.JSX.Element | null { + const mainState = useGameStore((state) => state.mainState); + const step = useGameStore((state) => state.pylon.currentStep); + + if (mainState !== "pylon") return null; + if (step !== "narrator-outro") return null; + + return null; +} diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx new file mode 100644 index 0000000..49581ee --- /dev/null +++ b/src/components/zone/ZoneDetection.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import * as THREE from "three"; +import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; +import type { ZoneConfig } from "@/types/gameplay/zone"; + +interface ZoneDetectionProps { + zone: ZoneConfig; + onEnter: () => void; + height?: number; +} + +const _cameraPos = new THREE.Vector3(); + +function ZoneDebugVisual({ + zone, + active, +}: { + zone: ZoneConfig; + active: boolean; +}): React.JSX.Element | null { + if (!isDebugEnabled()) return null; + return ( + + + + + + + + + + + ); +} + +export function ZoneDetection({ + zone, + onEnter, + height, +}: ZoneDetectionProps): React.JSX.Element { + const camera = useThree((state) => state.camera); + const hasTriggeredRef = useRef(false); + const onEnterRef = useRef(onEnter); + const [isActive, setIsActive] = useState(false); + + useEffect(() => { + onEnterRef.current = onEnter; + }, [onEnter]); + + useFrame(() => { + if (hasTriggeredRef.current) return; + + camera.getWorldPosition(_cameraPos); + const dx = _cameraPos.x - zone.position[0]; + const dz = _cameraPos.z - zone.position[2]; + const horizontalDist = Math.sqrt(dx * dx + dz * dz); + + if (horizontalDist > zone.radius) return; + + const zoneHeight = height ?? zone.height; + if (_cameraPos.y < zone.position[1] - zoneHeight / 2) return; + if (_cameraPos.y > zone.position[1] + zoneHeight / 2) return; + + hasTriggeredRef.current = true; + setIsActive(true); + onEnterRef.current(); + }); + + return ; +} diff --git a/src/data/gameplay/pylonConfig.ts b/src/data/gameplay/pylonConfig.ts new file mode 100644 index 0000000..10e7204 --- /dev/null +++ b/src/data/gameplay/pylonConfig.ts @@ -0,0 +1,31 @@ +import type { Vector3Tuple } from "@/types/three/three"; + +export const PYLON_WORLD_POSITION: Vector3Tuple = [43, 5, 45]; + +export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -1.4]; + +export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0]; + +export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [ + PYLON_WORLD_POSITION[0] - 6, + PYLON_WORLD_POSITION[1], + PYLON_WORLD_POSITION[2] + 4, +]; + +export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [ + PYLON_WORLD_POSITION[0] + 1, + PYLON_WORLD_POSITION[1], + PYLON_WORLD_POSITION[2] - 2, +]; + +export const PYLON_NARRATIVE_INTERACT_RADIUS = 3.5; + +export const PYLON_STRAIGHTEN_ANIMATION_DURATION_MS = 2200; + +export const PYLON_NARRATIVE_DIALOGUES = { + electricOutage: "narrateur_coupureelec", + searchCentral: "narrateur_fouillelecentre", + brokenPylon: "narrateur_poteaueleccasse", + farmerHelp: "fermier_coupdemain", + powerRestored: "narrateur_courantrepare", +} as const; diff --git a/src/data/gameplay/repairMissionState.ts b/src/data/gameplay/repairMissionState.ts index 29ec61f..fc13c60 100644 --- a/src/data/gameplay/repairMissionState.ts +++ b/src/data/gameplay/repairMissionState.ts @@ -10,6 +10,9 @@ const REPAIR_MISSION_ID_VALUES: ReadonlySet = new Set( export const MISSION_STEPS = [ "locked", + "approaching", + "arrived", + "npc-return", "waiting", "inspected", "fragmented", @@ -17,6 +20,7 @@ export const MISSION_STEPS = [ "repairing", "reassembling", "done", + "narrator-outro", ] as const satisfies readonly MissionStep[]; const MISSION_STEP_VALUES: ReadonlySet = new Set(MISSION_STEPS); @@ -28,9 +32,18 @@ export function isMissionStep(value: string): value is MissionStep { return MISSION_STEP_VALUES.has(value); } -export function getNextMissionStep(step: MissionStep): MissionStep { +export function getNextMissionStep( + step: MissionStep, + mission?: RepairMissionId, +): MissionStep { switch (step) { case "locked": + return mission === "pylon" ? "approaching" : "waiting"; + case "approaching": + return "arrived"; + case "arrived": + return "npc-return"; + case "npc-return": return "waiting"; case "waiting": return "inspected"; @@ -43,16 +56,29 @@ export function getNextMissionStep(step: MissionStep): MissionStep { case "repairing": return "reassembling"; case "reassembling": - case "done": return "done"; + case "done": + return mission === "pylon" ? "narrator-outro" : "done"; + case "narrator-outro": + return "narrator-outro"; } } -export function getPreviousMissionStep(step: MissionStep): MissionStep { +export function getPreviousMissionStep( + step: MissionStep, + mission?: RepairMissionId, +): MissionStep { switch (step) { case "locked": - case "waiting": return "locked"; + case "approaching": + return "locked"; + case "arrived": + return "approaching"; + case "npc-return": + return "arrived"; + case "waiting": + return mission === "pylon" ? "npc-return" : "locked"; case "inspected": return "waiting"; case "fragmented": @@ -65,5 +91,7 @@ export function getPreviousMissionStep(step: MissionStep): MissionStep { return "repairing"; case "done": return "reassembling"; + case "narrator-outro": + return "done"; } } diff --git a/src/data/gameplay/zones.ts b/src/data/gameplay/zones.ts new file mode 100644 index 0000000..ca59adb --- /dev/null +++ b/src/data/gameplay/zones.ts @@ -0,0 +1,26 @@ +import type { ZoneConfig } from "@/types/gameplay/zone"; +import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig"; + +export const PYLON_APPROACH_ZONE: ZoneConfig = { + id: "pylon-approach", + position: [ + PYLON_WORLD_POSITION[0] - 20, + PYLON_WORLD_POSITION[1], + PYLON_WORLD_POSITION[2] - 5, + ], + radius: 12, + height: 18, + oneShot: true, +}; + +export const PYLON_ARRIVED_ZONE: ZoneConfig = { + id: "pylon-arrived", + position: [ + PYLON_WORLD_POSITION[0] - 3, + PYLON_WORLD_POSITION[1], + PYLON_WORLD_POSITION[2] + 2, + ], + radius: 8, + height: 15, + oneShot: true, +}; diff --git a/src/hooks/debug/usePlayerPositionDebug.ts b/src/hooks/debug/usePlayerPositionDebug.ts new file mode 100644 index 0000000..084970c --- /dev/null +++ b/src/hooks/debug/usePlayerPositionDebug.ts @@ -0,0 +1,29 @@ +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import type GUI from "lil-gui"; +import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; + +export function usePlayerPositionDebug(): void { + const pos = useRef({ x: 0, y: 0, z: 0 }); + const controllers = useRef<{ updateDisplay: () => void }[]>([]); + + useDebugFolder("Game", (folder: GUI) => { + const sub = folder.addFolder("Player Position"); + sub.open(); + + controllers.current = [ + sub.add(pos.current, "x").name("X").decimals(2).disable(), + sub.add(pos.current, "y").name("Y").decimals(2).disable(), + sub.add(pos.current, "z").name("Z").decimals(2).disable(), + ]; + }); + + useFrame(() => { + const p = window.playerPos; + if (!p) return; + pos.current.x = p[0]; + pos.current.y = p[1]; + pos.current.z = p[2]; + for (const c of controllers.current) c.updateDisplay(); + }); +} diff --git a/src/hooks/gameplay/useDialoguePlayback.ts b/src/hooks/gameplay/useDialoguePlayback.ts new file mode 100644 index 0000000..b33aa9d --- /dev/null +++ b/src/hooks/gameplay/useDialoguePlayback.ts @@ -0,0 +1,53 @@ +import { useEffect } from "react"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { playDialogueById } from "@/utils/dialogues/playDialogue"; + +interface UseDialoguePlaybackOptions { + enabled: boolean; + dialogueId: string | null; + onComplete?: () => void; +} + +export function useDialoguePlayback({ + enabled, + dialogueId, + onComplete, +}: UseDialoguePlaybackOptions): void { + const setCanMove = useGameStore((state) => state.setCanMove); + + useEffect(() => { + if (!enabled || !dialogueId) return undefined; + + let isCancelled = false; + setCanMove(false); + + void (async () => { + const manifest = await loadDialogueManifest(); + if (isCancelled || !manifest) { + setCanMove(true); + return; + } + + const audio = await playDialogueById(manifest, dialogueId); + if (isCancelled || !audio) { + setCanMove(true); + return; + } + + audio.addEventListener( + "ended", + () => { + setCanMove(true); + onComplete?.(); + }, + { once: true }, + ); + })(); + + return () => { + isCancelled = true; + setCanMove(true); + }; + }, [enabled, dialogueId, onComplete, setCanMove]); +} diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 850107a..0310f5b 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -146,7 +146,7 @@ function completeEbikeState(state: GameState): GameStateUpdate { }, pylon: { ...state.pylon, - currentStep: "waiting", + currentStep: "approaching", }, }; } @@ -212,7 +212,7 @@ function advanceRepairMissionState( state: GameState, mission: RepairMissionId, ): GameStateUpdate { - const nextStep = getNextMissionStep(state[mission].currentStep); + const nextStep = getNextMissionStep(state[mission].currentStep, mission); if (nextStep === "done") { return completeMissionState(state, mission); } @@ -227,7 +227,7 @@ function rewindRepairMissionState( return setMissionStepState( state, mission, - getPreviousMissionStep(state[mission].currentStep), + getPreviousMissionStep(state[mission].currentStep, mission), ); } diff --git a/src/types/gameplay/repairMission.ts b/src/types/gameplay/repairMission.ts index 17163f5..b612037 100644 --- a/src/types/gameplay/repairMission.ts +++ b/src/types/gameplay/repairMission.ts @@ -54,10 +54,39 @@ export interface RepairMissionConfig { export type MissionStep = | "locked" + | "approaching" + | "arrived" + | "npc-return" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "reassembling" - | "done"; + | "done" + | "narrator-outro"; + +export const PYLON_NARRATIVE_STEPS = [ + "approaching", + "arrived", + "npc-return", + "narrator-outro", +] as const; + +export const REPAIR_GAME_STEPS = [ + "waiting", + "inspected", + "fragmented", + "scanning", + "repairing", + "reassembling", + "done", +] as const; + +export function isPylonNarrativeStep(step: MissionStep): boolean { + return (PYLON_NARRATIVE_STEPS as readonly MissionStep[]).includes(step); +} + +export function isRepairGameStep(step: MissionStep): boolean { + return (REPAIR_GAME_STEPS as readonly MissionStep[]).includes(step); +} diff --git a/src/types/gameplay/zone.ts b/src/types/gameplay/zone.ts new file mode 100644 index 0000000..42f0404 --- /dev/null +++ b/src/types/gameplay/zone.ts @@ -0,0 +1,9 @@ +import type { Vector3Tuple } from "@/types/three/three"; + +export interface ZoneConfig { + id: string; + position: Vector3Tuple; + radius: number; + height: number; + oneShot: boolean; +} diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx index 016a864..78162cc 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -1,6 +1,7 @@ import { Ebike } from "@/components/ebike/Ebike"; import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { RepairGame } from "@/components/three/gameplay/RepairGame"; +import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow"; import { REPAIR_MISSION_POSITION_ENTRIES, REPAIR_MISSION_TRIGGERS, @@ -11,6 +12,7 @@ import { } from "@/data/gameplay/gameStageAnchors"; import { useGameStore } from "@/managers/stores/useGameStore"; import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore"; +import { isPylonNarrativeStep } from "@/types/gameplay/repairMission"; import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission"; import type { Vector3Tuple } from "@/types/three/three"; import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition"; @@ -77,15 +79,21 @@ function RepairMissionTrigger({ export function GameStageContent(): React.JSX.Element { const mainState = useGameStore((state) => state.mainState); + const pylonStep = useGameStore((state) => state.pylon.currentStep); const anchors = useRepairMissionAnchorStore((state) => state.anchors); + const pylonInNarrative = + mainState === "pylon" && isPylonNarrativeStep(pylonStep); + return ( <> {mainState === "intro" ? : null} + {mainState === "pylon" ? : null} {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => { const position = getRepairMissionPosition(mission, anchors); if (!position) return null; + if (mission === "pylon" && pylonInNarrative) return null; return ( ); diff --git a/src/world/World.tsx b/src/world/World.tsx index f8eadb5..8a3b526 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -8,6 +8,7 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug"; import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug"; import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug"; +import { usePlayerPositionDebug } from "@/hooks/debug/usePlayerPositionDebug"; import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading"; @@ -35,6 +36,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { useEnvironmentDebug(); useMapPerformanceDebug(); useCharacterDebug(); + usePlayerPositionDebug(); const cameraMode = useCameraMode(); const sceneMode = useSceneMode();