From 2783b13488ebdc5046d78db96bfccd4b1e60901e Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 29 Apr 2026 23:30:31 +0200 Subject: [PATCH] feat: add main feature module selection --- src/components/three/ExplodableModel.tsx | 105 ++++++++++++++++++ src/components/three/MainFeatureZone.tsx | 75 +++++++++++++ .../three/ModelSelectorPlaceholder.tsx | 93 ++++++++++++++++ src/components/three/SimpleModel.tsx | 2 +- src/components/three/TriggerObject.tsx | 4 + src/components/three/index.ts | 5 + src/data/mainFeature/modelCatalog.ts | 13 +++ src/hooks/useModelSelection.ts | 72 ++++++++++++ src/index.css | 39 +++++++ src/utils/ExplodedModel.ts | 103 +++++++++++++++++ src/world/debug/TestScene.tsx | 7 +- 11 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 src/components/three/ExplodableModel.tsx create mode 100644 src/components/three/MainFeatureZone.tsx create mode 100644 src/components/three/ModelSelectorPlaceholder.tsx create mode 100644 src/data/mainFeature/modelCatalog.ts create mode 100644 src/hooks/useModelSelection.ts create mode 100644 src/utils/ExplodedModel.ts 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. + /> */} ); }