From d5675fe82c1992074eb2d10bba4534c892e86d9d Mon Sep 17 00:00:00 2001 From: tom-boullay Date: Thu, 28 May 2026 15:49:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20restaure=20l'=C3=A9diteur=20map=20et=20?= =?UTF-8?q?ajoute=20les=20personnages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/technical/editor.md | 7 +- docs/user/editor.md | 5 +- .../Mat_baseColor.png | 0 .../Mat_normal.png | 0 .../Mat_occlusionRoughnessMetallic.png | 0 .../model.bin | 0 .../model.gltf | 0 src/components/editor/EditorControls.tsx | 13 ++- src/components/editor/scene/EditorMap.tsx | 78 ++++++++++++- src/components/editor/scene/EditorScene.tsx | 9 ++ src/components/three/models/AnimatedModel.tsx | 54 +++++---- .../world/personnages/personnageConfig.ts | 53 +++++++++ src/hooks/debug/usePersonnageDebug.ts | 108 ++++++++++++++++++ .../stores/usePersonnageDebugStore.ts | 89 +++++++++++++++ src/pages/docs/editor/page.tsx | 9 +- src/pages/docs/repair-game/page.tsx | 9 +- src/pages/editor/page.tsx | 33 ++++++ src/utils/debug/Debug.ts | 1 + src/world/World.tsx | 4 + src/world/debug/TestMap.tsx | 2 +- src/world/personnages/PersonnageSystem.tsx | 37 ++++++ 21 files changed, 454 insertions(+), 57 deletions(-) rename public/models/{elec => electricienne-animated}/Mat_baseColor.png (100%) rename public/models/{elec => electricienne-animated}/Mat_normal.png (100%) rename public/models/{elec => electricienne-animated}/Mat_occlusionRoughnessMetallic.png (100%) rename public/models/{elec => electricienne-animated}/model.bin (100%) rename public/models/{elec => electricienne-animated}/model.gltf (100%) create mode 100644 src/data/world/personnages/personnageConfig.ts create mode 100644 src/hooks/debug/usePersonnageDebug.ts create mode 100644 src/managers/stores/usePersonnageDebugStore.ts create mode 100644 src/world/personnages/PersonnageSystem.tsx diff --git a/docs/technical/editor.md b/docs/technical/editor.md index 08c1084..bcd9fc6 100644 --- a/docs/technical/editor.md +++ b/docs/technical/editor.md @@ -122,8 +122,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback - Click: select a node. - `Shift` + right click: add or remove a node from the multi-selection. - `Esc`: clear selection. -- Click empty space: clear selection. -- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection. +- Selection lock button: prevent object clicks and `Esc` from changing the current selection. - Selection clear button: intentionally clear the current selection even when the lock is active. - `T`: translate mode. - `R`: rotate mode. @@ -190,9 +189,9 @@ The state is passed to: - `EditorControls`, to render the lock/unlock button - `EditorScene`, to block `Esc` deselection when locked -- `EditorMap`, to block object selection and empty-space deselection when locked +- `EditorMap`, to block object selection when locked -The clear button calls `onClearSelection` directly from `EditorControls`. This is intentionally separate from scene click behavior so the user always has an explicit way to clear the selection. +The clear button calls `onClearSelection` directly from `EditorControls`. Clicking empty canvas space does not clear the current selection; use `Esc` or the explicit clear button instead. ## Dialogue SRT Editing diff --git a/docs/user/editor.md b/docs/user/editor.md index 67ee333..e0c0199 100644 --- a/docs/user/editor.md +++ b/docs/user/editor.md @@ -72,7 +72,7 @@ Use the trash button in `Selection` to delete the selected node from the map tre | -------------------- | -------------------------- | | Select object | Click object | | Toggle multi-select | `Shift` + right click | -| Deselect | `Esc` or click empty space | +| Deselect | `Esc` | | Lock selection | `Lock` button in Selection | | Clear selection | `X` button in Selection | | Translate mode | `T` | @@ -91,7 +91,7 @@ The `Selection` section shows the selected object name and its index in `public/ - Click an object to select it. - Use `Shift + right click` on objects to add or remove them from a multi-selection. - When several objects are selected, the gizmo appears on the selection group and applies translate, rotate, or scale to each selected node. -- Click empty space or press `Esc` to clear the selection. +- Press `Esc` to clear the selection. - Use the `X` button to clear the selection explicitly. - Use the `Lock` button to protect the current selection while editing. - Use the scale fields to edit X/Y/Z scale precisely. @@ -108,7 +108,6 @@ This is intended for map objects that should sit on the ground. Disable it when When selection is locked: - clicking another object does not change the selection -- clicking empty space does not clear the selection - pressing `Esc` does not clear the selection - the `X` button still clears the selection intentionally diff --git a/public/models/elec/Mat_baseColor.png b/public/models/electricienne-animated/Mat_baseColor.png similarity index 100% rename from public/models/elec/Mat_baseColor.png rename to public/models/electricienne-animated/Mat_baseColor.png diff --git a/public/models/elec/Mat_normal.png b/public/models/electricienne-animated/Mat_normal.png similarity index 100% rename from public/models/elec/Mat_normal.png rename to public/models/electricienne-animated/Mat_normal.png diff --git a/public/models/elec/Mat_occlusionRoughnessMetallic.png b/public/models/electricienne-animated/Mat_occlusionRoughnessMetallic.png similarity index 100% rename from public/models/elec/Mat_occlusionRoughnessMetallic.png rename to public/models/electricienne-animated/Mat_occlusionRoughnessMetallic.png diff --git a/public/models/elec/model.bin b/public/models/electricienne-animated/model.bin similarity index 100% rename from public/models/elec/model.bin rename to public/models/electricienne-animated/model.bin diff --git a/public/models/elec/model.gltf b/public/models/electricienne-animated/model.gltf similarity index 100% rename from public/models/elec/model.gltf rename to public/models/electricienne-animated/model.gltf diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx index 7080eec..5bceda2 100644 --- a/src/components/editor/EditorControls.tsx +++ b/src/components/editor/EditorControls.tsx @@ -41,6 +41,7 @@ interface EditorControlsProps { onClearSelection: () => void; snapToTerrain: boolean; onSnapToTerrainToggle: () => void; + onSnapAllToTerrain: () => void; newNodeName: string; onNewNodeNameChange: (value: string) => void; onAddNode: () => void; @@ -70,7 +71,7 @@ const EDITOR_SHORTCUTS = [ ["Shift + Right click", "Toggle multi-selection"], ["T / R / S", "Transform mode"], ["Ctrl Z / Y", "Undo / redo"], - ["Esc", "Deselect"], + ["Esc / X button", "Clear selection"], ["WASD", "Move when locked"], ] as const; @@ -117,6 +118,7 @@ export function EditorControls({ onClearSelection, snapToTerrain, onSnapToTerrainToggle, + onSnapAllToTerrain, newNodeName, onNewNodeNameChange, onAddNode, @@ -228,6 +230,15 @@ export function EditorControls({ /> Snap terrain on move + +
void; onTransformEnd: () => void; onNodeTransform: (nodeIndex: number, transform: MapNode) => void; + snapAllToTerrainRequest: number; + onSnapAllToTerrain: (mapNodes: MapNode[]) => void; } type EditorNodeObjectRef = React.RefObject>; @@ -64,6 +68,32 @@ const TEMP_POSITION = new THREE.Vector3(); const TEMP_QUATERNION = new THREE.Quaternion(); const TEMP_SCALE = new THREE.Vector3(); +function isOriginPosition(position: MapNode["position"]): boolean { + return position.every((value) => Math.abs(value) < 0.0001); +} + +function isSnapAllCandidate(node: MapNode): boolean { + return ( + isEditorVisibleMapNode(node) && + node.name !== "terrain" && + !isOriginPosition(node.position) + ); +} + +function shouldRenderEditorNode( + node: MapNode, + selectedNodeName: string | null, +): boolean { + if (!isEditorVisibleMapNode(node)) return false; + return selectedNodeName === null || node.name === selectedNodeName; +} + +function getEditorModelVisualScaleMultiplier(name: string): number { + return ( + getMapModelScaleMultiplier(name) * getVegetationModelScaleMultiplier(name) + ); +} + function applyNodeTransform(object: THREE.Object3D, node: MapNode): void { object.position.set(...node.position); object.rotation.set(...node.rotation); @@ -177,14 +207,21 @@ export function EditorMap({ onTransformStart, onTransformEnd, onNodeTransform, + snapAllToTerrainRequest, + onSnapAllToTerrain, }: EditorMapProps): React.JSX.Element { const objectsMapRef = useRef>(new Map()); const transformGroupRef = useRef(null); const transformSnapshotRef = useRef(null); const terrainHeight = useTerrainHeightSampler(); + const lastSnapAllToTerrainRequestRef = useRef(0); const selectedIndexSet = new Set(selectedNodeIndexes); const isMultiSelection = selectedNodeIndexes.length > 1; + const selectedNodeName = + selectedNodeIndex !== null + ? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null) + : null; const getTransformObject = useCallback(() => { if (isMultiSelection) { @@ -333,6 +370,37 @@ export function EditorMap({ prepareTransformGroup(); }, [prepareTransformGroup]); + useEffect(() => { + if ( + snapAllToTerrainRequest === 0 || + snapAllToTerrainRequest === lastSnapAllToTerrainRequestRef.current + ) { + return; + } + + lastSnapAllToTerrainRequestRef.current = snapAllToTerrainRequest; + + const snappedNodes = sceneData.mapNodes.map((node) => { + if (!isSnapAllCandidate(node)) return node; + + const [x, y, z] = node.position; + const terrainY = terrainHeight.getHeight(x, z); + if (terrainY === null || Math.abs(terrainY - y) < 0.0001) return node; + + return { + ...node, + position: [x, terrainY, z] satisfies [number, number, number], + }; + }); + + onSnapAllToTerrain(snappedNodes); + }, [ + onSnapAllToTerrain, + sceneData.mapNodes, + snapAllToTerrainRequest, + terrainHeight, + ]); + // TransformControls needs the current Three object; editor refs are managed outside React rendering. // eslint-disable-next-line react-hooks/refs const selectedObject = getTransformObject(); @@ -370,7 +438,7 @@ export function EditorMap({ /> ) : null} {sceneData.mapNodes.map((node, index) => { - if (!isEditorVisibleMapNode(node)) { + if (!shouldRenderEditorNode(node, selectedNodeName)) { return null; } @@ -451,6 +519,7 @@ function EditorModelNode({ scale: node.scale, }); const sceneInstance = useClonedObject(scene); + const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name); const pointerHandlers = createEditorNodePointerHandlers( index, onSelectNode, @@ -512,14 +581,15 @@ function EditorModelNode({ }, []); return ( - + > + + ); } diff --git a/src/components/editor/scene/EditorScene.tsx b/src/components/editor/scene/EditorScene.tsx index 56b0381..c7eb12a 100644 --- a/src/components/editor/scene/EditorScene.tsx +++ b/src/components/editor/scene/EditorScene.tsx @@ -6,6 +6,7 @@ import * as THREE from "three"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import { EditorMap } from "@/components/editor/scene/EditorMap"; import { FlyController } from "@/controls/editor/FlyController"; +import { PersonnageSystem } from "@/world/personnages/PersonnageSystem"; import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor"; @@ -33,6 +34,8 @@ interface EditorSceneProps { onTransformStart: () => void; onTransformEnd: () => void; onNodeTransform: (nodeIndex: number, transform: MapNode) => void; + snapAllToTerrainRequest: number; + onSnapAllToTerrain: (mapNodes: MapNode[]) => void; onUndo: () => void; onRedo: () => void; resetCameraRequest: number; @@ -58,6 +61,8 @@ export function EditorScene({ onTransformStart, onTransformEnd, onNodeTransform, + snapAllToTerrainRequest, + onSnapAllToTerrain, onUndo, onRedo, resetCameraRequest, @@ -224,8 +229,12 @@ export function EditorScene({ onTransformStart={onTransformStart} onTransformEnd={onTransformEnd} onNodeTransform={onNodeTransform} + snapAllToTerrainRequest={snapAllToTerrainRequest} + onSnapAllToTerrain={onSnapAllToTerrain} /> + + diff --git a/src/components/three/models/AnimatedModel.tsx b/src/components/three/models/AnimatedModel.tsx index 0827273..02e149a 100644 --- a/src/components/three/models/AnimatedModel.tsx +++ b/src/components/three/models/AnimatedModel.tsx @@ -68,32 +68,6 @@ export function AnimatedModel({ } }, [mixer, onAnimationEnd]); - const play = useCallback( - (name: string, fade = fadeDuration) => { - const action = actions[name]; - if (action) { - Object.values(actions).forEach((a) => { - if (a && a !== action) a.fadeOut(fade); - }); - action.reset().fadeIn(fade).play(); - setCurrentAnim(name); - } - }, - [actions, fadeDuration], - ); - - const stop = useCallback( - (fade = fadeDuration) => { - Object.values(actions).forEach((a) => a?.fadeOut(fade)); - const defaultAction = actions[defaultAnimation]; - if (defaultAction) { - defaultAction.reset().fadeIn(fade).play(); - setCurrentAnim(defaultAnimation); - } - }, - [actions, defaultAnimation, fadeDuration], - ); - const fadeTo = useCallback( (name: string, fade = fadeDuration) => { const action = actions[name]; @@ -107,6 +81,19 @@ export function AnimatedModel({ }, [actions, fadeDuration], ); + const play = fadeTo; + + const stop = useCallback( + (fade = fadeDuration) => { + Object.values(actions).forEach((a) => a?.fadeOut(fade)); + const defaultAction = actions[defaultAnimation]; + if (defaultAction) { + defaultAction.reset().fadeIn(fade).play(); + setCurrentAnim(defaultAnimation); + } + }, + [actions, defaultAnimation, fadeDuration], + ); const setSpeed = useCallback( (newSpeed: number) => { @@ -140,10 +127,21 @@ export function AnimatedModel({ } if (defaultAction) { - defaultAction.play(); + Object.values(actions).forEach((action) => { + if (action && action !== defaultAction) action.fadeOut(fadeDuration); + }); + defaultAction.reset().fadeIn(fadeDuration).play(); onLoaded?.(); } - }, [actions, defaultAnimation, modelPath, names, autoPlay, onLoaded]); + }, [ + actions, + defaultAnimation, + fadeDuration, + modelPath, + names, + autoPlay, + onLoaded, + ]); const contextValue: AnimatedModelContextValue = { play, diff --git a/src/data/world/personnages/personnageConfig.ts b/src/data/world/personnages/personnageConfig.ts new file mode 100644 index 0000000..337e071 --- /dev/null +++ b/src/data/world/personnages/personnageConfig.ts @@ -0,0 +1,53 @@ +import type { Vector3Tuple } from "@/types/three/three"; + +export type PersonnageId = "electricienne" | "gerant" | "fermier"; + +export interface PersonnageConfig { + id: PersonnageId; + label: string; + modelPath: string; + position: Vector3Tuple; + rotation: Vector3Tuple; + scale: Vector3Tuple; + animations: readonly string[]; + defaultAnimation: string; +} + +export const PERSONNAGE_CONFIGS = { + electricienne: { + id: "electricienne", + label: "Electricienne", + modelPath: "/models/electricienne-animated/model.gltf", + position: [-40.5, 0, 45.5], + rotation: [0, -0.35, 0], + scale: [1, 1, 1], + animations: ["Dance"], + defaultAnimation: "Dance", + }, + gerant: { + id: "gerant", + label: "Gerant", + modelPath: "/models/gerant-animated/model.gltf", + position: [45.2, 0, 45.5], + rotation: [0, -1.55, 0], + scale: [1, 1, 1], + animations: ["idle", "walk"], + defaultAnimation: "idle", + }, + fermier: { + id: "fermier", + label: "Fermier", + modelPath: "/models/fermier-animated/model.gltf", + position: [-6.5, 0, -69.5], + rotation: [0, -1.18, 0], + scale: [1, 1, 1], + animations: ["idle", "walk"], + defaultAnimation: "idle", + }, +} satisfies Record; + +export const PERSONNAGE_IDS = [ + "electricienne", + "gerant", + "fermier", +] as const satisfies readonly PersonnageId[]; diff --git a/src/hooks/debug/usePersonnageDebug.ts b/src/hooks/debug/usePersonnageDebug.ts new file mode 100644 index 0000000..da71ef6 --- /dev/null +++ b/src/hooks/debug/usePersonnageDebug.ts @@ -0,0 +1,108 @@ +import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; +import { + PERSONNAGE_CONFIGS, + PERSONNAGE_IDS, +} from "@/data/world/personnages/personnageConfig"; +import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore"; + +function createAnimationOptions( + animations: readonly string[], +): Record { + if (animations.length === 0) { + return { None: "" }; + } + + return Object.fromEntries( + animations.map((animation) => [animation || "None", animation]), + ); +} + +export function usePersonnageDebug(): void { + useDebugFolder("Personnages", (folder) => { + const store = usePersonnageDebugStore.getState(); + + for (const id of PERSONNAGE_IDS) { + const config = PERSONNAGE_CONFIGS[id]; + const state = store.personnages[id]; + const characterFolder = folder.addFolder(config.label); + const controls = { + animation: state.animation, + positionX: state.position[0], + positionY: state.position[1], + positionZ: state.position[2], + rotationX: state.rotation[0], + rotationY: state.rotation[1], + rotationZ: state.rotation[2], + scaleX: state.scale[0], + scaleY: state.scale[1], + scaleZ: state.scale[2], + }; + + characterFolder + .add(controls, "animation", createAnimationOptions(config.animations)) + .name("Animation") + .onChange((animation: string) => { + usePersonnageDebugStore.getState().setAnimation(id, animation); + }); + + characterFolder + .add(controls, "positionX", -120, 120, 0.1) + .name("Position X") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setPosition(id, 0, value); + }); + characterFolder + .add(controls, "positionY", -20, 40, 0.1) + .name("Position Y") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setPosition(id, 1, value); + }); + characterFolder + .add(controls, "positionZ", -120, 120, 0.1) + .name("Position Z") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setPosition(id, 2, value); + }); + + characterFolder + .add(controls, "rotationX", -Math.PI, Math.PI, 0.01) + .name("Rotation X") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setRotation(id, 0, value); + }); + characterFolder + .add(controls, "rotationY", -Math.PI, Math.PI, 0.01) + .name("Rotation Y") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setRotation(id, 1, value); + }); + characterFolder + .add(controls, "rotationZ", -Math.PI, Math.PI, 0.01) + .name("Rotation Z") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setRotation(id, 2, value); + }); + + characterFolder + .add(controls, "scaleX", 0.1, 5, 0.05) + .name("Scale X") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setScale(id, 0, value); + }); + characterFolder + .add(controls, "scaleY", 0.1, 5, 0.05) + .name("Scale Y") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setScale(id, 1, value); + }); + characterFolder + .add(controls, "scaleZ", 0.1, 5, 0.05) + .name("Scale Z") + .onChange((value: number) => { + usePersonnageDebugStore.getState().setScale(id, 2, value); + }); + + characterFolder.close(); + } + }); +} diff --git a/src/managers/stores/usePersonnageDebugStore.ts b/src/managers/stores/usePersonnageDebugStore.ts new file mode 100644 index 0000000..3a79ae5 --- /dev/null +++ b/src/managers/stores/usePersonnageDebugStore.ts @@ -0,0 +1,89 @@ +import { create } from "zustand"; +import { + PERSONNAGE_CONFIGS, + PERSONNAGE_IDS, + type PersonnageId, +} from "@/data/world/personnages/personnageConfig"; +import type { Vector3Tuple } from "@/types/three/three"; + +interface PersonnageDebugState { + animation: string; + position: Vector3Tuple; + rotation: Vector3Tuple; + scale: Vector3Tuple; +} + +interface PersonnageDebugStore { + personnages: Record; + setAnimation: (id: PersonnageId, animation: string) => void; + setPosition: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void; + setRotation: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void; + setScale: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void; +} + +function updateVector( + vector: Vector3Tuple, + axis: 0 | 1 | 2, + value: number, +): Vector3Tuple { + const next: Vector3Tuple = [...vector]; + next[axis] = value; + return next; +} + +const initialPersonnages = Object.fromEntries( + PERSONNAGE_IDS.map((id) => { + const config = PERSONNAGE_CONFIGS[id]; + + return [ + id, + { + animation: config.defaultAnimation, + position: [...config.position], + rotation: [...config.rotation], + scale: [...config.scale], + }, + ]; + }), +) as Record; + +export const usePersonnageDebugStore = create((set) => ({ + personnages: initialPersonnages, + setAnimation: (id, animation) => + set((state) => ({ + personnages: { + ...state.personnages, + [id]: { ...state.personnages[id], animation }, + }, + })), + setPosition: (id, axis, value) => + set((state) => ({ + personnages: { + ...state.personnages, + [id]: { + ...state.personnages[id], + position: updateVector(state.personnages[id].position, axis, value), + }, + }, + })), + setRotation: (id, axis, value) => + set((state) => ({ + personnages: { + ...state.personnages, + [id]: { + ...state.personnages[id], + rotation: updateVector(state.personnages[id].rotation, axis, value), + }, + }, + })), + setScale: (id, axis, value) => + set((state) => ({ + personnages: { + ...state.personnages, + [id]: { + ...state.personnages[id], + scale: updateVector(state.personnages[id].scale, axis, value), + }, + }, + })), +})); diff --git a/src/pages/docs/editor/page.tsx b/src/pages/docs/editor/page.tsx index 57712b9..1befe52 100644 --- a/src/pages/docs/editor/page.tsx +++ b/src/pages/docs/editor/page.tsx @@ -2,12 +2,5 @@ import editor from "../../../../docs/user/editor.md?raw"; import { DocsDocument } from "@/components/docs/DocsDocument"; export function DocsEditorPage(): React.JSX.Element { - return ( - - ); + return ; } diff --git a/src/pages/docs/repair-game/page.tsx b/src/pages/docs/repair-game/page.tsx index 130e593..faf5028 100644 --- a/src/pages/docs/repair-game/page.tsx +++ b/src/pages/docs/repair-game/page.tsx @@ -2,12 +2,5 @@ import repairGame from "../../../../docs/technical/repair-game.md?raw"; import { DocsDocument } from "@/components/docs/DocsDocument"; export function DocsRepairGamePage(): React.JSX.Element { - return ( - - ); + return ; } diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx index 061f9ae..877a716 100644 --- a/src/pages/editor/page.tsx +++ b/src/pages/editor/page.tsx @@ -323,6 +323,7 @@ export function EditorPage(): React.JSX.Element { const [newNodeName, setNewNodeName] = useState(DEFAULT_NEW_NODE_NAME); const [lockTerrainSelection, setLockTerrainSelection] = useState(true); const [resetCameraRequest, setResetCameraRequest] = useState(0); + const [snapAllToTerrainRequest, setSnapAllToTerrainRequest] = useState(0); const [focusSelectedCameraRequest, setFocusSelectedCameraRequest] = useState(0); const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">( @@ -372,9 +373,14 @@ export function EditorPage(): React.JSX.Element { const handleSelectNode = useCallback((index: number | null) => { setSelectedNodeIndex(index); setSelectedNodeIndexes(index === null ? [] : [index]); + if (index !== null) { setCameraViewMode("object"); + return; } + + setCameraViewMode("home"); + setResetCameraRequest((request) => request + 1); }, []); const handleToggleNodeSelection = useCallback((index: number) => { @@ -387,6 +393,9 @@ export function EditorPage(): React.JSX.Element { setSelectedNodeIndex(nextIndexes.at(-1) ?? null); if (nextIndexes.length > 0) { setCameraViewMode("object"); + } else { + setCameraViewMode("home"); + setResetCameraRequest((request) => request + 1); } return nextIndexes; @@ -396,6 +405,8 @@ export function EditorPage(): React.JSX.Element { const handleClearSelection = useCallback(() => { setSelectedNodeIndex(null); setSelectedNodeIndexes([]); + setCameraViewMode("home"); + setResetCameraRequest((request) => request + 1); }, []); const handleSelectionLockToggle = useCallback(() => { @@ -406,6 +417,25 @@ export function EditorPage(): React.JSX.Element { setSnapToTerrain((enabled) => !enabled); }, []); + const handleSnapAllToTerrainRequest = useCallback(() => { + setSnapAllToTerrainRequest((request) => request + 1); + }, []); + + const handleSnapAllToTerrain = useCallback( + (mapNodes: MapNode[]) => { + setSceneData((prev) => { + if (!prev) return null; + + const nextSceneData = { ...prev, mapNodes }; + if (!prev.mapTree) return nextSceneData; + + const mapTree = mergeFlatNodeTransformsIntoTree(nextSceneData); + return updateSceneDataTree(nextSceneData, mapTree); + }); + }, + [setSceneData], + ); + const handleNewNodeNameChange = useCallback((value: string) => { setNewNodeName(value); }, []); @@ -710,6 +740,8 @@ export function EditorPage(): React.JSX.Element { onTransformStart={handleTransformStart} onTransformEnd={handleTransformEnd} onNodeTransform={handleNodeTransform} + snapAllToTerrainRequest={snapAllToTerrainRequest} + onSnapAllToTerrain={handleSnapAllToTerrain} onUndo={handleUndo} onRedo={handleRedo} resetCameraRequest={resetCameraRequest} @@ -748,6 +780,7 @@ export function EditorPage(): React.JSX.Element { onClearSelection={handleClearSelection} snapToTerrain={snapToTerrain} onSnapToTerrainToggle={handleSnapToTerrainToggle} + onSnapAllToTerrain={handleSnapAllToTerrainRequest} newNodeName={newNodeName} onNewNodeNameChange={handleNewNodeNameChange} onAddNode={handleAddNode} diff --git a/src/utils/debug/Debug.ts b/src/utils/debug/Debug.ts index bd2f9d1..fd38c4d 100644 --- a/src/utils/debug/Debug.ts +++ b/src/utils/debug/Debug.ts @@ -24,6 +24,7 @@ const DEBUG_FOLDER_ORDER = [ "Interaction", "Hand Tracking", "Map", + "Personnages", ] as const; function isRecord(value: unknown): value is Record { diff --git a/src/world/World.tsx b/src/world/World.tsx index 67a69e1..69f2803 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -7,6 +7,7 @@ import { import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug"; import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug"; +import { usePersonnageDebug } from "@/hooks/debug/usePersonnageDebug"; import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading"; @@ -28,6 +29,7 @@ import { GameMusic } from "@/world/GameMusic"; import { Lighting } from "@/world/Lighting"; import { GameMap } from "@/world/GameMap"; import { GameStageContent } from "@/world/GameStageContent"; +import { PersonnageSystem } from "@/world/personnages/PersonnageSystem"; import { Player } from "@/world/player/Player"; import { TestMap } from "@/world/debug/TestMap"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; @@ -39,6 +41,7 @@ interface WorldProps { export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { useEnvironmentDebug(); useMapPerformanceDebug(); + usePersonnageDebug(); const cameraMode = useCameraMode(); const sceneMode = useSceneMode(); @@ -87,6 +90,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { onLoadingStateChange={onLoadingStateChange} onOctreeReady={handleOctreeReady} /> + {showGameStage ? ( diff --git a/src/world/debug/TestMap.tsx b/src/world/debug/TestMap.tsx index 0dea786..e5408c6 100644 --- a/src/world/debug/TestMap.tsx +++ b/src/world/debug/TestMap.tsx @@ -31,7 +31,7 @@ import type { OctreeReadyHandler } from "@/types/three/three"; import { logModelLoadError } from "@/utils/three/modelLoadLogger"; const ELECTRICIENNE_ANIMATED_MODEL_PATH = - "/models/electricienne_animated/model.gltf"; + "/models/electricienne-animated/model.gltf"; interface TestMapProps { onOctreeReady: OctreeReadyHandler; diff --git a/src/world/personnages/PersonnageSystem.tsx b/src/world/personnages/PersonnageSystem.tsx new file mode 100644 index 0000000..df66ad5 --- /dev/null +++ b/src/world/personnages/PersonnageSystem.tsx @@ -0,0 +1,37 @@ +import { Suspense } from "react"; +import { AnimatedModel } from "@/components/three/models/AnimatedModel"; +import { + PERSONNAGE_CONFIGS, + PERSONNAGE_IDS, + type PersonnageId, +} from "@/data/world/personnages/personnageConfig"; +import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; +import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore"; + +function PersonnageModel({ id }: { id: PersonnageId }): React.JSX.Element { + const config = PERSONNAGE_CONFIGS[id]; + const state = usePersonnageDebugStore((store) => store.personnages[id]); + const position = useTerrainSnappedPosition(state.position); + + return ( + + ); +} + +export function PersonnageSystem(): React.JSX.Element { + return ( + + {PERSONNAGE_IDS.map((id) => ( + + + + ))} + + ); +}