refactor: clean architecture and remove unused code

This commit is contained in:
Tom Boullay
2026-04-30 13:33:28 +02:00
parent b1187b68ae
commit cfb1eaf39a
30 changed files with 303 additions and 696 deletions
+6 -6
View File
@@ -1,8 +1,9 @@
import { useMemo, useRef, useEffect, useState } from "react";
import { useRef, useEffect, useState } from "react";
import { Grid, TransformControls, useGLTF } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
interface EditorMapProps {
@@ -138,7 +139,7 @@ export function EditorMap({
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
const handleTransformMouseDown = () => {
onTransformStart?.();
onTransformStart();
};
const handleTransformMouseUp = () => {
@@ -153,10 +154,10 @@ export function EditorMap({
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
};
onNodeTransform?.(selectedNodeIndex, updatedNode);
onNodeTransform(selectedNodeIndex, updatedNode);
}
}
onTransformEnd?.();
onTransformEnd();
};
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
@@ -258,8 +259,7 @@ function EditorModelNode({
new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(),
);
const { scene } = useGLTF(modelUrl);
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
const sceneInstance = useClonedObject(scene);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import gsap from "gsap";
@@ -15,14 +15,13 @@ import {
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
REPAIR_CASE_ROTATION_RESET_SPEED,
} from "@/data/gameplay/repairCaseConfig";
import type { Vector3Tuple } from "@/types/three/three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import type { ModelTransformProps } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
interface RepairCaseModelProps {
interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string;
open: boolean;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: number | Vector3Tuple;
}
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
@@ -44,7 +43,7 @@ export function RepairCaseModel({
}: RepairCaseModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]);
const model = useClonedObject(scene);
const groupRef = useRef<THREE.Group>(null);
const lidRef = useRef<THREE.Object3D | null>(null);
const worldPosition = useRef(new THREE.Vector3());
@@ -53,8 +52,7 @@ export function RepairCaseModel({
const phase = useRef({ x: 0, y: 0, z: 0 });
const initialOpen = useRef(open);
const openedRotationZ = useRef(0);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
const parsedScale = toVector3Scale(scale);
useEffect(() => {
phase.current = {
@@ -1,7 +1,8 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { useGLTF } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import {
TRIGGER_DEFAULT_COLLIDERS,
TRIGGER_DEFAULT_LABEL,
@@ -38,7 +39,7 @@ function SpawnedModelInstance({
position: Vector3Tuple;
}): React.JSX.Element {
const { scene } = useGLTF(path);
const model = useMemo(() => scene.clone(true), [scene]);
const model = useClonedObject(scene);
return <primitive object={model} position={position} />;
}
+13 -15
View File
@@ -2,12 +2,14 @@ import type { ReactNode } from "react";
import { Component, useEffect, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { ExplodedModel } from "@/utils/three/ExplodedModel";
import type { Vector3Tuple } from "@/types/three/three";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
interface ModelErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
position?: Vector3Tuple | undefined;
}
interface ModelErrorBoundaryState {
@@ -32,17 +34,17 @@ class ModelErrorBoundary extends Component<
}
render(): ReactNode {
if (this.state.hasError) return this.props.fallback ?? null;
if (this.state.hasError) {
return <MissingModelFallback position={this.props.position} />;
}
return this.props.children;
}
}
interface ExplodableModelInnerProps {
interface ExplodableModelInnerProps extends ModelTransformProps {
modelPath: string;
split: boolean;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: number | Vector3Tuple;
splitDistance?: number;
}
@@ -50,10 +52,7 @@ export function ExplodableModel(
props: ExplodableModelInnerProps,
): React.JSX.Element {
return (
<ModelErrorBoundary
key={props.modelPath}
fallback={<MissingModelFallback position={props.position ?? [0, 0, 0]} />}
>
<ModelErrorBoundary key={props.modelPath} position={props.position}>
<ExplodableModelInner {...props} />
</ModelErrorBoundary>
);
@@ -68,13 +67,12 @@ function ExplodableModelInner({
splitDistance = 1.2,
}: ExplodableModelInnerProps): React.JSX.Element {
const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]);
const model = useClonedObject(scene);
const explodedModel = useMemo(
() => new ExplodedModel(model, { distance: splitDistance }),
[model, splitDistance],
);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
const parsedScale = toVector3Scale(scale);
useEffect(() => {
explodedModel.setSplit(split);
@@ -94,7 +92,7 @@ function ExplodableModelInner({
function MissingModelFallback({
position = [0, 0, 0],
}: {
position?: Vector3Tuple;
position?: Vector3Tuple | undefined;
}): React.JSX.Element {
return (
<mesh position={position}>
+3 -2
View File
@@ -1,7 +1,8 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { useMemo, useRef } from "react";
import { useRef } from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
interface SkyModelProps {
modelPath: string;
@@ -13,7 +14,7 @@ export function SkyModel({ modelPath }: SkyModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]);
const model = useClonedObject(scene);
useFrame(() => {
groupRef.current?.position.copy(camera.position);
+8 -9
View File
@@ -24,7 +24,6 @@ Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans inst
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
| [@react-three/drei](https://pmndrs.github.io/drei) |
| [@react-three/rapier](https://rapier.rs/docs/) |
| [@react-three/postprocessing](https://github.com/pmndrs/postprocessing) |
| [GSAP](https://gsap.com/docs/v3/Installation/) |
### Performance et effets
@@ -48,17 +47,17 @@ la-fabrik/
│ └── sounds/
└── src/
├── world/ # Monde 3D persistant
│ ├── World.tsx # Composition principale de la scène
│ ├── Map.tsx # Carte de base, toujours montée
├── world/ # Composition du monde 3D persistant
│ ├── World.tsx # Composition de la scène active
│ ├── GameMap.tsx # Chargement de carte et collision octree
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
│ ├── Environment.tsx # HDRI, brouillard, ciel
│ ├── PostFX.tsx # Bloom, SSAO, aberration chromatique
│ ├── zones/ # Zones spatiales, LOD par zone
│ ├── Environment.tsx # Arrière-plan et modèle de ciel
│ ├── GameMusic.tsx # Cycle de vie de la musique de jeu
│ ├── debug/ # Scène de test debug
│ └── player/ # Contrôleur joueur et caméra
├── components/
│ ├── 3d/ # Éléments 3D réutilisables
│ ├── three/ # Composants R3F par domaine
│ └── ui/ # Overlays HTML hors Canvas
├── managers/ # Logique, état et orchestration
@@ -142,7 +141,7 @@ Ce document décrit l'architecture visée à moyen terme pour le projet.
## Relation avec le code actuel
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
- Ce document est volontairement aspirational.
- Ce document décrit une direction d'architecture, pas un comportement implémenté.
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
## Objectifs
+2 -2
View File
@@ -1,5 +1,5 @@
export const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
export const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
export const HAND_TRACKING_FRAME_WIDTH = 320;
export const HAND_TRACKING_FRAME_HEIGHT = 240;
+10 -3
View File
@@ -15,6 +15,13 @@ export function useModelSelection(
): UseModelSelectionResult {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const firstModel = models[0];
if (!firstModel) {
throw new Error("useModelSelection requires at least one model");
}
const selectedModel = models[selectedIndex] ?? firstModel;
const close = useCallback(() => setIsOpen(false), []);
const open = useCallback(() => setIsOpen(true), []);
@@ -42,7 +49,7 @@ export function useModelSelection(
}
if (key === "e" || key === "enter") {
onSelect(models[selectedIndex]);
onSelect(selectedModel);
close();
event.preventDefault();
event.stopPropagation();
@@ -60,12 +67,12 @@ export function useModelSelection(
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
};
}, [close, isOpen, models, onSelect, selectedIndex]);
}, [close, isOpen, models, onSelect, selectedModel]);
return {
isOpen,
selectedIndex,
selectedModel: models[selectedIndex],
selectedModel,
open,
close,
};
@@ -10,6 +10,7 @@ import {
} from "@/data/handTrackingConfig";
import type {
HandTrackingFrameMessage,
HandTrackingHand,
HandTrackingServerMessage,
HandTrackingSnapshot,
} from "@/types/handTracking/handTracking";
@@ -31,6 +32,58 @@ function getBase64Payload(dataUrl: string): string {
return dataUrl.slice(dataUrl.indexOf(",") + 1);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isHandTrackingLandmark(value: unknown): boolean {
return (
isRecord(value) &&
isFiniteNumber(value.x) &&
isFiniteNumber(value.y) &&
isFiniteNumber(value.z)
);
}
function isHandTrackingHand(value: unknown): value is HandTrackingHand {
return (
isRecord(value) &&
isFiniteNumber(value.x) &&
isFiniteNumber(value.y) &&
isFiniteNumber(value.z) &&
Array.isArray(value.landmarks) &&
value.landmarks.every(isHandTrackingLandmark) &&
typeof value.handedness === "string" &&
typeof value.isFist === "boolean" &&
isFiniteNumber(value.score)
);
}
function isHandTrackingServerMessage(
value: unknown,
): value is HandTrackingServerMessage {
if (!isRecord(value) || !isFiniteNumber(value.timestamp)) return false;
if (value.type === "hands") {
return Array.isArray(value.hands) && value.hands.every(isHandTrackingHand);
}
if (value.type === "status") {
return typeof value.status === "string";
}
return (
value.type === "error" &&
Array.isArray(value.hands) &&
value.hands.every(isHandTrackingHand) &&
typeof value.message === "string"
);
}
function getCameraStreamWithTimeout(
constraints: MediaStreamConstraints,
): Promise<MediaStream> {
@@ -106,6 +159,16 @@ export function useRemoteHandTracking({
clearResponseTimeout();
};
const markInvalidResponse = (): void => {
setSnapshot((current) => ({
...current,
hands: [],
status: "error",
usageStatus: "inactive",
error: "Invalid hand tracking response",
}));
};
const sendFrame = (): void => {
const ws = wsRef.current;
const video = videoRef.current;
@@ -201,7 +264,23 @@ export function useRemoteHandTracking({
};
ws.onmessage = (event) => {
markResponseReceived();
const data = JSON.parse(event.data) as HandTrackingServerMessage;
if (typeof event.data !== "string") {
markInvalidResponse();
return;
}
let data: unknown;
try {
data = JSON.parse(event.data);
} catch {
markInvalidResponse();
return;
}
if (!isHandTrackingServerMessage(data)) {
markInvalidResponse();
return;
}
if (data.type === "hands") {
setSnapshot((current) => ({
+6
View File
@@ -0,0 +1,6 @@
import { useMemo } from "react";
import type * as THREE from "three";
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
return useMemo(() => object.clone(true) as T, [object]);
}
+59 -99
View File
@@ -1,141 +1,101 @@
import { Suspense, lazy } from "react";
const LazyDocsLayout = lazy(() =>
import("@/components/docs/DocsLayout").then((module) => ({
default: module.DocsLayout,
})),
);
function lazyNamed<T extends Record<string, React.ComponentType>>(
loader: () => Promise<T>,
exportName: keyof T,
): React.LazyExoticComponent<T[keyof T]> {
return lazy(() =>
loader().then((module) => ({ default: module[exportName] })),
);
}
const LazyDocsReadmePage = lazy(() =>
import("@/pages/docs/page").then((module) => ({
default: module.DocsReadmePage,
})),
);
function withDocsSuspense(
Component: React.LazyExoticComponent<React.ComponentType>,
): React.JSX.Element {
return (
<Suspense fallback={null}>
<Component />
</Suspense>
);
}
const LazyDocsArchitecturePage = lazy(() =>
import("@/pages/docs/architecture/page").then((module) => ({
default: module.DocsArchitecturePage,
})),
const LazyDocsLayout = lazyNamed(
() => import("@/components/docs/DocsLayout"),
"DocsLayout",
);
const LazyDocsTargetArchitecturePage = lazy(() =>
import("@/pages/docs/target-architecture/page").then((module) => ({
default: module.DocsTargetArchitecturePage,
})),
const LazyDocsReadmePage = lazyNamed(
() => import("@/pages/docs/page"),
"DocsReadmePage",
);
const LazyDocsTechnicalEditorPage = lazy(() =>
import("@/pages/docs/technical-editor/page").then((module) => ({
default: module.DocsTechnicalEditorPage,
})),
const LazyDocsArchitecturePage = lazyNamed(
() => import("@/pages/docs/architecture/page"),
"DocsArchitecturePage",
);
const LazyDocsHandTrackingPage = lazy(() =>
import("@/pages/docs/hand-tracking/page").then((module) => ({
default: module.DocsHandTrackingPage,
})),
const LazyDocsTargetArchitecturePage = lazyNamed(
() => import("@/pages/docs/target-architecture/page"),
"DocsTargetArchitecturePage",
);
const LazyDocsFeaturesPage = lazy(() =>
import("@/pages/docs/features/page").then((module) => ({
default: module.DocsFeaturesPage,
})),
const LazyDocsTechnicalEditorPage = lazyNamed(
() => import("@/pages/docs/technical-editor/page"),
"DocsTechnicalEditorPage",
);
const LazyDocsMainFeaturePage = lazy(() =>
import("@/pages/docs/main-feature/page").then((module) => ({
default: module.DocsMainFeaturePage,
})),
const LazyDocsHandTrackingPage = lazyNamed(
() => import("@/pages/docs/hand-tracking/page"),
"DocsHandTrackingPage",
);
const LazyDocsEditorPage = lazy(() =>
import("@/pages/docs/editor/page").then((module) => ({
default: module.DocsEditorPage,
})),
const LazyDocsFeaturesPage = lazyNamed(
() => import("@/pages/docs/features/page"),
"DocsFeaturesPage",
);
const LazyDocsAnimationPage = lazy(() =>
import("@/pages/docs/animation/page").then((module) => ({
default: module.DocsAnimationPage,
})),
const LazyDocsMainFeaturePage = lazyNamed(
() => import("@/pages/docs/main-feature/page"),
"DocsMainFeaturePage",
);
const LazyDocsEditorPage = lazyNamed(
() => import("@/pages/docs/editor/page"),
"DocsEditorPage",
);
const LazyDocsAnimationPage = lazyNamed(
() => import("@/pages/docs/animation/page"),
"DocsAnimationPage",
);
export function DocsLayoutRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsLayout />
</Suspense>
);
return withDocsSuspense(LazyDocsLayout);
}
export function DocsReadmeRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsReadmePage />
</Suspense>
);
return withDocsSuspense(LazyDocsReadmePage);
}
export function DocsArchitectureRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsArchitecturePage />
</Suspense>
);
return withDocsSuspense(LazyDocsArchitecturePage);
}
export function DocsTargetArchitectureRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsTargetArchitecturePage />
</Suspense>
);
return withDocsSuspense(LazyDocsTargetArchitecturePage);
}
export function DocsTechnicalEditorRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsTechnicalEditorPage />
</Suspense>
);
return withDocsSuspense(LazyDocsTechnicalEditorPage);
}
export function DocsHandTrackingRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsHandTrackingPage />
</Suspense>
);
return withDocsSuspense(LazyDocsHandTrackingPage);
}
export function DocsFeaturesRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsFeaturesPage />
</Suspense>
);
return withDocsSuspense(LazyDocsFeaturesPage);
}
export function DocsMainFeatureRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsMainFeaturePage />
</Suspense>
);
return withDocsSuspense(LazyDocsMainFeaturePage);
}
export function DocsEditorRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsEditorPage />
</Suspense>
);
return withDocsSuspense(LazyDocsEditorPage);
}
export function DocsAnimationRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsAnimationPage />
</Suspense>
);
return withDocsSuspense(LazyDocsAnimationPage);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Vector3Tuple } from "@/types/three/three";
import type { Vector3Tuple } from "../three/three";
export interface MapNode {
name: string;
+4 -4
View File
@@ -14,7 +14,7 @@ export interface HandTrackingHand {
score: number;
}
export type HandTrackingUsageStatus = "inactive" | "available" | "active";
type HandTrackingUsageStatus = "inactive" | "available" | "active";
export type HandTrackingStatus =
| "idle"
@@ -42,19 +42,19 @@ export interface HandTrackingFrameMessage {
image: string;
}
export interface HandTrackingHandsMessage {
interface HandTrackingHandsMessage {
type: "hands";
timestamp: number;
hands: HandTrackingHand[];
}
export interface HandTrackingStatusMessage {
interface HandTrackingStatusMessage {
type: "status";
timestamp: number;
status: string;
}
export interface HandTrackingErrorMessage {
interface HandTrackingErrorMessage {
type: "error";
timestamp: number;
hands: HandTrackingHand[];
-2
View File
@@ -1,5 +1,3 @@
export type InteractableKind = "grab" | "trigger";
interface TriggerInteractableHandle {
kind: "trigger";
label: string;
+8
View File
@@ -2,6 +2,14 @@ import type { Octree } from "three/addons/math/Octree.js";
export type Vector3Tuple = [number, number, number];
export type Vector3Scale = Vector3Tuple | number;
export interface ModelTransformProps {
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Scale;
}
export type ColliderShape = "cuboid" | "ball" | "hull";
export type OctreeReadyHandler = (octree: Octree) => void;
+6 -1
View File
@@ -9,6 +9,10 @@ interface StoredDebugControls {
sceneMode: SceneMode;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isCameraMode(value: unknown): value is CameraMode {
return value === "player" || value === "debug";
}
@@ -22,7 +26,8 @@ function getStoredDebugControls(): Partial<StoredDebugControls> {
const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY);
if (!rawValue) return {};
const parsedValue = JSON.parse(rawValue) as Partial<StoredDebugControls>;
const parsedValue: unknown = JSON.parse(rawValue);
if (!isRecord(parsedValue)) return {};
return {
...(isCameraMode(parsedValue.cameraMode)
+12 -9
View File
@@ -1,4 +1,8 @@
import type { MapNode } from "@/types/editor/editor";
import type { MapNode } from "../../types/editor/editor";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isVector3Tuple(value: unknown): value is [number, number, number] {
return (
@@ -8,18 +12,17 @@ function isVector3Tuple(value: unknown): value is [number, number, number] {
);
}
export function isMapNode(value: unknown): value is MapNode {
if (typeof value !== "object" || value === null) {
function isMapNode(value: unknown): value is MapNode {
if (!isRecord(value)) {
return false;
}
const node = value as Record<string, unknown>;
return (
typeof node.name === "string" &&
typeof node.type === "string" &&
isVector3Tuple(node.position) &&
isVector3Tuple(node.rotation) &&
isVector3Tuple(node.scale)
typeof value.name === "string" &&
typeof value.type === "string" &&
isVector3Tuple(value.position) &&
isVector3Tuple(value.rotation) &&
isVector3Tuple(value.scale)
);
}
+4 -1
View File
@@ -49,7 +49,10 @@ export class ExplodedModel {
}
private createParts(model: THREE.Object3D): ExplodedPart[] {
const root = model.children.length === 1 ? model.children[0] : model;
const root =
model.children.length === 1 && model.children[0]
? model.children[0]
: model;
const directChildren = root.children.filter((child) => hasMesh(child));
const sourceObjects =
directChildren.length > 1 ? directChildren : getMeshes(root);
+5
View File
@@ -0,0 +1,5 @@
import type { Vector3Scale, Vector3Tuple } from "@/types/three/three";
export function toVector3Scale(scale: Vector3Scale): Vector3Tuple {
return typeof scale === "number" ? [scale, scale, scale] : scale;
}
+9 -24
View File
@@ -1,7 +1,8 @@
import type { ReactNode } from "react";
import { Component, useEffect, useMemo, useRef, useState } from "react";
import { Component, useEffect, useRef, useState } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import type { MapNode } from "@/types/editor/editor";
@@ -14,7 +15,6 @@ interface LoadedMapNode {
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
@@ -40,7 +40,7 @@ class ModelErrorBoundary extends Component<
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback ?? null;
return null;
}
return this.props.children;
@@ -53,7 +53,6 @@ interface GameMapProps {
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
const [isLoading, setIsLoading] = useState(true);
const groupRef = useRef<THREE.Group>(null);
useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length);
@@ -64,7 +63,6 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
const sceneData = await loadMapSceneData();
if (!sceneData) {
console.warn("map.json not found");
setIsLoading(false);
return;
}
@@ -84,8 +82,6 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
setMapNodes(loadedMapNodes);
} catch (error) {
console.error("Error loading map:", error);
} finally {
setIsLoading(false);
}
};
@@ -94,12 +90,11 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
return (
<group ref={groupRef}>
{!isLoading &&
mapNodes.map((mapNode, index) => (
<ModelErrorBoundary key={index}>
<ModelInstance node={mapNode.node} modelUrl={mapNode.modelUrl} />
</ModelErrorBoundary>
))}
{mapNodes.map((mapNode, index) => (
<ModelErrorBoundary key={index}>
<ModelInstance node={mapNode.node} modelUrl={mapNode.modelUrl} />
</ModelErrorBoundary>
))}
</group>
);
}
@@ -111,22 +106,12 @@ function ModelInstance({
node: MapNode;
modelUrl: string;
}): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelUrl);
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
const sceneInstance = useClonedObject(scene);
const { position, rotation, scale } = node;
useEffect(() => {
if (groupRef.current) {
groupRef.current.position.set(...position);
groupRef.current.rotation.set(...rotation);
groupRef.current.scale.set(...scale);
}
}, [position, rotation, scale]);
return (
<primitive
ref={groupRef}
object={sceneInstance}
position={position}
rotation={rotation}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useRef } from "react";
import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { RepairGameZone } from "@/components/three/gameplay/repairGame/RepairGameZone";
import { RepairGameZone } from "@/components/three/gameplay/RepairGameZone";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {