diff --git a/src/components/three/ExplodableModel.tsx b/src/components/three/ExplodableModel.tsx
new file mode 100644
index 0000000..daa1d68
--- /dev/null
+++ b/src/components/three/ExplodableModel.tsx
@@ -0,0 +1,105 @@
+import type { ReactNode } from "react";
+import { Component, useEffect, useMemo } from "react";
+import { useFrame } from "@react-three/fiber";
+import { useGLTF } from "@react-three/drei";
+import { ExplodedModel } from "@/utils/ExplodedModel";
+import type { Vector3Tuple } from "@/types/three";
+
+interface ModelErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: ReactNode;
+}
+
+interface ModelErrorBoundaryState {
+ hasError: boolean;
+}
+
+class ModelErrorBoundary extends Component<
+ ModelErrorBoundaryProps,
+ ModelErrorBoundaryState
+> {
+ constructor(props: ModelErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(): ModelErrorBoundaryState {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error): void {
+ console.warn("Failed to load explodable model", error);
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) return this.props.fallback ?? null;
+ return this.props.children;
+ }
+}
+
+interface ExplodableModelInnerProps {
+ modelPath: string;
+ split: boolean;
+ position?: Vector3Tuple;
+ rotation?: Vector3Tuple;
+ scale?: number | Vector3Tuple;
+ splitDistance?: number;
+}
+
+export function ExplodableModel(
+ props: ExplodableModelInnerProps,
+): React.JSX.Element {
+ return (
+ }
+ >
+
+
+ );
+}
+
+function ExplodableModelInner({
+ modelPath,
+ split,
+ position = [0, 0, 0],
+ rotation = [0, 0, 0],
+ scale = 1,
+ splitDistance = 1.2,
+}: ExplodableModelInnerProps): React.JSX.Element {
+ const { scene } = useGLTF(modelPath);
+ const model = useMemo(() => scene.clone(true), [scene]);
+ const explodedModel = useMemo(
+ () => new ExplodedModel(model, { distance: splitDistance }),
+ [model, splitDistance],
+ );
+ const parsedScale =
+ typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
+
+ useEffect(() => {
+ explodedModel.setSplit(split);
+ }, [explodedModel, split]);
+
+ useFrame((_, delta) => {
+ explodedModel.update(delta);
+ });
+
+ return (
+
+
+
+ );
+}
+
+function MissingModelFallback({
+ position = [0, 0, 0],
+}: {
+ position?: Vector3Tuple;
+}): React.JSX.Element {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/three/MainFeatureZone.tsx b/src/components/three/MainFeatureZone.tsx
new file mode 100644
index 0000000..31fd500
--- /dev/null
+++ b/src/components/three/MainFeatureZone.tsx
@@ -0,0 +1,75 @@
+import { useState } from "react";
+import { Text } from "@react-three/drei";
+import { TriggerObject } from "@/components/three/TriggerObject";
+import { ModelSelectorPlaceholder } from "@/components/three/ModelSelectorPlaceholder";
+import { RepairCaseModel } from "@/components/three/RepairCaseModel";
+
+const ZONE_ORIGIN = [10, 0.4, -8] as const;
+const CASE_MODEL_PATH = "/models/packderelance/model.gltf";
+const ZONE_RADIUS = 4.2;
+
+export function MainFeatureZone(): React.JSX.Element {
+ const [caseOpen, setCaseOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Pack de Relance Feature
+
+
+ setCaseOpen((value) => !value)}
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/three/ModelSelectorPlaceholder.tsx b/src/components/three/ModelSelectorPlaceholder.tsx
new file mode 100644
index 0000000..e47847f
--- /dev/null
+++ b/src/components/three/ModelSelectorPlaceholder.tsx
@@ -0,0 +1,93 @@
+import { Html } from "@react-three/drei";
+import { useCallback, useState } from "react";
+import { TriggerObject } from "@/components/three/TriggerObject";
+import { ExplodableModel } from "@/components/three/ExplodableModel";
+import { MAIN_FEATURE_MODEL_CATALOG } from "@/data/mainFeature/modelCatalog";
+import type { ModelCatalogItem } from "@/data/mainFeature/modelCatalog";
+import { useModelSelection } from "@/hooks/useModelSelection";
+import type { Vector3Tuple } from "@/types/three";
+
+interface ModelSelectorPlaceholderProps {
+ position: Vector3Tuple;
+ label: string;
+}
+
+export function ModelSelectorPlaceholder({
+ position,
+ label,
+}: ModelSelectorPlaceholderProps): React.JSX.Element {
+ const [selectedModel, setSelectedModel] = useState(
+ null,
+ );
+ const [split, setSplit] = useState(false);
+ const handleSelect = useCallback((model: ModelCatalogItem) => {
+ setSelectedModel(model);
+ setSplit(false);
+ }, []);
+ const selection = useModelSelection(MAIN_FEATURE_MODEL_CATALOG, handleSelect);
+ const triggerLabel = selectedModel
+ ? split
+ ? `Réassembler ${label}`
+ : `Démonter ${label}`
+ : `Choisir ${label}`;
+
+ return (
+
+ {
+ if (selectedModel) {
+ setSplit((value) => !value);
+ return;
+ }
+
+ selection.open();
+ }}
+ >
+ {selectedModel ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ {selection.isOpen ? (
+
+
+
{label}
+
Fleches: choisir
+
E/Enter: valider
+
+ {MAIN_FEATURE_MODEL_CATALOG.map((model, index) => (
+ -
+ {model.name}
+
+ ))}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/three/SimpleModel.tsx b/src/components/three/SimpleModel.tsx
index cfa6e83..6c7b9c2 100644
--- a/src/components/three/SimpleModel.tsx
+++ b/src/components/three/SimpleModel.tsx
@@ -1,5 +1,5 @@
import { useGLTF } from "@react-three/drei";
-import type { Vector3Tuple } from "@/types/3d";
+import type { Vector3Tuple } from "@/types/three";
export interface SimpleModelConfig {
modelPath: string;
diff --git a/src/components/three/TriggerObject.tsx b/src/components/three/TriggerObject.tsx
index 4e11c96..bd6fb8c 100644
--- a/src/components/three/TriggerObject.tsx
+++ b/src/components/three/TriggerObject.tsx
@@ -25,6 +25,7 @@ interface TriggerObjectProps {
soundVolume?: number;
spawnModel?: string;
spawnOffset?: Vector3Tuple;
+ onTrigger?: () => void;
}
let _spawnCounter = 0;
@@ -49,6 +50,7 @@ export function TriggerObject({
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
spawnModel,
spawnOffset = TRIGGER_DEFAULT_SPAWN_OFFSET,
+ onTrigger,
}: TriggerObjectProps): React.JSX.Element {
const [spawned, setSpawned] = useState([]);
@@ -64,6 +66,8 @@ export function TriggerObject({
AudioManager.getInstance().playSound(soundPath, soundVolume);
}
+ onTrigger?.();
+
if (spawnModel) {
const spawnPos: Vector3Tuple = [
position[0] + spawnOffset[0],
diff --git a/src/components/three/index.ts b/src/components/three/index.ts
index d8516b8..31fe607 100644
--- a/src/components/three/index.ts
+++ b/src/components/three/index.ts
@@ -4,5 +4,10 @@ export type { AnimatedModelConfig } from "./AnimatedModel";
export { SimpleModel } from "./SimpleModel";
export type { SimpleModelConfig } from "./SimpleModel";
+export { ExplodableModel } from "./ExplodableModel";
+export { MainFeatureZone } from "./MainFeatureZone";
+export { ModelSelectorPlaceholder } from "./ModelSelectorPlaceholder";
+export { RepairCaseModel } from "./RepairCaseModel";
+
export { useCharacterAnimation } from "@/hooks/useCharacterAnimation";
export type { CharacterAnimationConfig } from "@/hooks/useCharacterAnimation";
diff --git a/src/data/mainFeature/modelCatalog.ts b/src/data/mainFeature/modelCatalog.ts
new file mode 100644
index 0000000..70240fd
--- /dev/null
+++ b/src/data/mainFeature/modelCatalog.ts
@@ -0,0 +1,13 @@
+export interface ModelCatalogItem {
+ name: string;
+ path: string;
+}
+
+export const MAIN_FEATURE_MODEL_CATALOG: ModelCatalogItem[] = [
+ { name: "Kit de relance", path: "/models/packderelance/model.gltf" },
+ { name: "Talkie", path: "/models/talkie/model.gltf" },
+ { name: "Refroidisseur", path: "/models/refroidisseur/model.gltf" },
+ { name: "Sapin", path: "/models/sapin/model.gltf" },
+ { name: "Gant", path: "/models/gant/model.gltf" },
+ { name: "Galet", path: "/models/galet/model.gltf" },
+];
diff --git a/src/hooks/useModelSelection.ts b/src/hooks/useModelSelection.ts
new file mode 100644
index 0000000..05b9a24
--- /dev/null
+++ b/src/hooks/useModelSelection.ts
@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useState } from "react";
+import type { ModelCatalogItem } from "@/data/mainFeature/modelCatalog";
+
+interface UseModelSelectionResult {
+ isOpen: boolean;
+ selectedIndex: number;
+ selectedModel: ModelCatalogItem;
+ open: () => void;
+ close: () => void;
+}
+
+export function useModelSelection(
+ models: ModelCatalogItem[],
+ onSelect: (model: ModelCatalogItem) => void,
+): UseModelSelectionResult {
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const close = useCallback(() => setIsOpen(false), []);
+ const open = useCallback(() => setIsOpen(true), []);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ const key = event.key.toLowerCase();
+
+ if (["arrowup", "arrowleft"].includes(key)) {
+ setSelectedIndex((index) =>
+ index === 0 ? models.length - 1 : index - 1,
+ );
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ if (["arrowdown", "arrowright"].includes(key)) {
+ setSelectedIndex((index) => (index + 1) % models.length);
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ if (key === "e" || key === "enter") {
+ onSelect(models[selectedIndex]);
+ close();
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ if (key === "escape") {
+ close();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown, { capture: true });
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown, { capture: true });
+ };
+ }, [close, isOpen, models, onSelect, selectedIndex]);
+
+ return {
+ isOpen,
+ selectedIndex,
+ selectedModel: models[selectedIndex],
+ open,
+ close,
+ };
+}
diff --git a/src/index.css b/src/index.css
index ae0fbb3..415b0d5 100644
--- a/src/index.css
+++ b/src/index.css
@@ -428,6 +428,45 @@ canvas {
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.55));
}
+.model-selector-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 190px;
+ padding: 12px;
+ color: rgba(255, 255, 255, 0.92);
+ background: rgba(4, 7, 13, 0.88);
+ border: 1px solid rgba(56, 189, 248, 0.5);
+ border-radius: 8px;
+ font-size: 12px;
+ pointer-events: none;
+ user-select: none;
+}
+
+.model-selector-panel strong {
+ color: white;
+ font-size: 13px;
+}
+
+.model-selector-panel ul {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ margin: 4px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.model-selector-panel li {
+ padding: 3px 6px;
+ border-radius: 4px;
+}
+
+.model-selector-panel li.is-selected {
+ color: #020617;
+ background: #38bdf8;
+}
+
/* Editor page */
.editor-container {
position: fixed;
diff --git a/src/utils/ExplodedModel.ts b/src/utils/ExplodedModel.ts
new file mode 100644
index 0000000..5df21d7
--- /dev/null
+++ b/src/utils/ExplodedModel.ts
@@ -0,0 +1,103 @@
+import * as THREE from "three";
+
+interface ExplodedPart {
+ object: THREE.Object3D;
+ originalPosition: THREE.Vector3;
+ targetPosition: THREE.Vector3;
+}
+
+interface ExplodedModelOptions {
+ distance?: number;
+ speed?: number;
+}
+
+const _center = new THREE.Vector3();
+const _direction = new THREE.Vector3();
+
+export class ExplodedModel {
+ private readonly parts: ExplodedPart[] = [];
+ private readonly distance: number;
+ private readonly speed: number;
+ private progress = 0;
+ private targetProgress = 0;
+
+ constructor(model: THREE.Object3D, options: ExplodedModelOptions = {}) {
+ this.distance = options.distance ?? 1.2;
+ this.speed = options.speed ?? 6;
+ this.parts = this.createParts(model);
+ }
+
+ setSplit(split: boolean): void {
+ this.targetProgress = split ? 1 : 0;
+ }
+
+ update(delta: number): void {
+ const diff = this.targetProgress - this.progress;
+ if (Math.abs(diff) < 0.001) {
+ this.progress = this.targetProgress;
+ } else {
+ this.progress += diff * Math.min(delta * this.speed, 1);
+ }
+
+ this.parts.forEach((part) => {
+ part.object.position.lerpVectors(
+ part.originalPosition,
+ part.targetPosition,
+ this.progress,
+ );
+ });
+ }
+
+ private createParts(model: THREE.Object3D): ExplodedPart[] {
+ const root = model.children.length === 1 ? model.children[0] : model;
+ const directChildren = root.children.filter((child) => hasMesh(child));
+ const sourceObjects =
+ directChildren.length > 1 ? directChildren : getMeshes(root);
+
+ if (sourceObjects.length === 0) return [];
+
+ _center.set(0, 0, 0);
+ sourceObjects.forEach((object) => _center.add(object.position));
+ _center.divideScalar(sourceObjects.length);
+
+ return sourceObjects.map((object, index) => {
+ const originalPosition = object.position.clone();
+ _direction.subVectors(originalPosition, _center);
+
+ if (_direction.lengthSq() < 0.0001) {
+ const angle = (index / sourceObjects.length) * Math.PI * 2;
+ _direction.set(Math.cos(angle), 0.25, Math.sin(angle));
+ }
+
+ _direction.normalize();
+
+ return {
+ object,
+ originalPosition,
+ targetPosition: originalPosition
+ .clone()
+ .addScaledVector(_direction, this.distance),
+ };
+ });
+ }
+}
+
+function hasMesh(object: THREE.Object3D): boolean {
+ let found = false;
+ object.traverse((child) => {
+ if (child instanceof THREE.Mesh) {
+ found = true;
+ }
+ });
+ return found;
+}
+
+function getMeshes(object: THREE.Object3D): THREE.Object3D[] {
+ const meshes: THREE.Object3D[] = [];
+ object.traverse((child) => {
+ if (child instanceof THREE.Mesh) {
+ meshes.push(child);
+ }
+ });
+ return meshes;
+}
diff --git a/src/world/debug/TestScene.tsx b/src/world/debug/TestScene.tsx
index da41d27..6b71af1 100644
--- a/src/world/debug/TestScene.tsx
+++ b/src/world/debug/TestScene.tsx
@@ -2,8 +2,8 @@ import { useRef } from "react";
import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { GrabbableObject } from "@/components/three/GrabbableObject";
+import { MainFeatureZone } from "@/components/three/MainFeatureZone";
import { TriggerObject } from "@/components/three/TriggerObject";
-import { AnimatedModel } from "@/components/three/AnimatedModel";
import {
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
TEST_SCENE_FLOOR_POSITION,
@@ -86,14 +86,17 @@ export function TestScene({
/>
+
+
+ {/* Temporary: re-enable when Git LFS downloads are available again.
+ /> */}
>
);
}