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
+
+
+
+ Snap all to terrain
+
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) => (
+
+
+
+ ))}
+
+ );
+}