connect repair gameplay to zustand progression

This commit is contained in:
Tom Boullay
2026-04-30 16:25:54 +02:00
parent 1625895708
commit bb08054722
8 changed files with 143 additions and 29 deletions
Binary file not shown.
Binary file not shown.
@@ -1,42 +1,93 @@
import type { ReactNode } from "react";
import { Component } from "react";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel"; import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel";
import { import {
REPAIR_CASE_CLOSE_SOUND_PATH,
REPAIR_CASE_MODEL_PATH, REPAIR_CASE_MODEL_PATH,
REPAIR_CASE_OPEN_SOUND_PATH, REPAIR_CASE_OPEN_SOUND_PATH,
} from "@/data/gameplay/repairCaseConfig"; } from "@/data/gameplay/repairCaseConfig";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
interface RepairCaseErrorBoundaryProps {
children: ReactNode;
}
interface RepairCaseErrorBoundaryState {
hasError: boolean;
}
class RepairCaseErrorBoundary extends Component<
RepairCaseErrorBoundaryProps,
RepairCaseErrorBoundaryState
> {
constructor(props: RepairCaseErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): RepairCaseErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
console.warn("Failed to load repair case model", error);
}
render(): ReactNode {
if (this.state.hasError) {
return <RepairCaseFallback />;
}
return this.props.children;
}
}
interface RepairCaseObjectProps { interface RepairCaseObjectProps {
position: Vector3Tuple; position: Vector3Tuple;
open: boolean; open: boolean;
onToggle: () => void; onInspect: () => void;
} }
export function RepairCaseObject({ export function RepairCaseObject({
position, position,
open, open,
onToggle, onInspect,
}: RepairCaseObjectProps): React.JSX.Element { }: RepairCaseObjectProps): React.JSX.Element {
return ( return (
<TriggerObject <TriggerObject
position={position} position={position}
colliders="cuboid" colliders="cuboid"
label={open ? "Fermer la mallette" : "Ouvrir la mallette"} label={open ? "Mallette inspectée" : "Inspecter la mallette"}
onTrigger={() => { onTrigger={() => {
AudioManager.getInstance().playSound( if (open) return;
open ? REPAIR_CASE_CLOSE_SOUND_PATH : REPAIR_CASE_OPEN_SOUND_PATH, AudioManager.getInstance().playSound(REPAIR_CASE_OPEN_SOUND_PATH);
); onInspect();
onToggle();
}} }}
> >
<RepairCaseErrorBoundary>
<RepairCaseModel <RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH} modelPath={REPAIR_CASE_MODEL_PATH}
open={open} open={open}
position={[0, -0.45, 0]} position={[0, -0.45, 0]}
scale={1.5} scale={1.5}
/> />
</RepairCaseErrorBoundary>
</TriggerObject> </TriggerObject>
); );
} }
function RepairCaseFallback(): React.JSX.Element {
return (
<group position={[0, -0.25, 0]}>
<mesh castShadow receiveShadow>
<boxGeometry args={[1.5, 0.5, 1]} />
<meshStandardMaterial color="#2563eb" roughness={0.55} />
</mesh>
<mesh position={[0, 0.35, -0.25]} castShadow receiveShadow>
<boxGeometry args={[1.5, 0.12, 0.65]} />
<meshStandardMaterial color="#1d4ed8" roughness={0.55} />
</mesh>
</group>
);
}
@@ -1,4 +1,3 @@
import { useState } from "react";
import { Text } from "@react-three/drei"; import { Text } from "@react-three/drei";
import { RepairCaseObject } from "@/components/three/gameplay/RepairCaseObject"; import { RepairCaseObject } from "@/components/three/gameplay/RepairCaseObject";
import { RepairModuleSlot } from "@/components/three/gameplay/RepairModuleSlot"; import { RepairModuleSlot } from "@/components/three/gameplay/RepairModuleSlot";
@@ -8,9 +7,47 @@ import {
REPAIR_GAME_ZONE_ORIGIN, REPAIR_GAME_ZONE_ORIGIN,
REPAIR_GAME_ZONE_RADIUS, REPAIR_GAME_ZONE_RADIUS,
} from "@/data/gameplay/repairGameConfig"; } from "@/data/gameplay/repairGameConfig";
import { useGameStore } from "@/managers/stores/useGameStore";
const CASE_CLOSED_STEPS = new Set(["locked", "waiting"]);
export function RepairGameZone(): React.JSX.Element { export function RepairGameZone(): React.JSX.Element {
const [caseOpen, setCaseOpen] = useState(false); const mainState = useGameStore((state) => state.mainState);
const bikeStep = useGameStore((state) => state.bike.currentStep);
const setMainState = useGameStore((state) => state.setMainState);
const setBikeState = useGameStore((state) => state.setBikeState);
const caseOpen = !CASE_CLOSED_STEPS.has(bikeStep);
const slotsDisabled = !caseOpen;
const inspectRepairCase = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (CASE_CLOSED_STEPS.has(bikeStep)) {
setBikeState({ currentStep: "inspected" });
}
};
const markModelSelected = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (bikeStep === "inspected") {
setBikeState({ currentStep: "fragmented" });
}
};
const markModuleSplit = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (bikeStep === "fragmented") {
setBikeState({ currentStep: "scanning" });
}
};
return ( return (
<group> <group>
@@ -62,7 +99,7 @@ export function RepairGameZone(): React.JSX.Element {
<RepairCaseObject <RepairCaseObject
position={REPAIR_GAME_ZONE_ORIGIN} position={REPAIR_GAME_ZONE_ORIGIN}
open={caseOpen} open={caseOpen}
onToggle={() => setCaseOpen((value) => !value)} onInspect={inspectRepairCase}
/> />
{REPAIR_GAME_MODULE_SLOTS.map((slot) => ( {REPAIR_GAME_MODULE_SLOTS.map((slot) => (
@@ -74,6 +111,9 @@ export function RepairGameZone(): React.JSX.Element {
REPAIR_GAME_ZONE_ORIGIN[1] + slot.offset[1], REPAIR_GAME_ZONE_ORIGIN[1] + slot.offset[1],
REPAIR_GAME_ZONE_ORIGIN[2] + slot.offset[2], REPAIR_GAME_ZONE_ORIGIN[2] + slot.offset[2],
]} ]}
disabled={slotsDisabled}
onModelSelected={markModelSelected}
onSplit={markModuleSplit}
/> />
))} ))}
</group> </group>
@@ -10,22 +10,34 @@ import type { Vector3Tuple } from "@/types/three/three";
interface RepairModuleSlotProps { interface RepairModuleSlotProps {
position: Vector3Tuple; position: Vector3Tuple;
label: string; label: string;
disabled?: boolean;
onModelSelected?: () => void;
onSplit?: () => void;
} }
export function RepairModuleSlot({ export function RepairModuleSlot({
position, position,
label, label,
disabled = false,
onModelSelected,
onSplit,
}: RepairModuleSlotProps): React.JSX.Element { }: RepairModuleSlotProps): React.JSX.Element {
const [selectedModel, setSelectedModel] = useState<ModelCatalogItem | null>( const [selectedModel, setSelectedModel] = useState<ModelCatalogItem | null>(
null, null,
); );
const [split, setSplit] = useState(false); const [split, setSplit] = useState(false);
const handleSelect = useCallback((model: ModelCatalogItem) => { const handleSelect = useCallback(
(model: ModelCatalogItem) => {
setSelectedModel(model); setSelectedModel(model);
setSplit(false); setSplit(false);
}, []); onModelSelected?.();
},
[onModelSelected],
);
const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect); const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect);
const triggerLabel = selectedModel const triggerLabel = disabled
? "Ouvrir la mallette d'abord"
: selectedModel
? split ? split
? `Réassembler ${label}` ? `Réassembler ${label}`
: `Démonter ${label}` : `Démonter ${label}`
@@ -38,8 +50,16 @@ export function RepairModuleSlot({
colliders="cuboid" colliders="cuboid"
label={triggerLabel} label={triggerLabel}
onTrigger={() => { onTrigger={() => {
if (disabled) return;
if (selectedModel) { if (selectedModel) {
setSplit((value) => !value); setSplit((value) => {
const nextSplit = !value;
if (nextSplit) {
onSplit?.();
}
return nextSplit;
});
return; return;
} }
+2 -2
View File
@@ -3,6 +3,7 @@ import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { RepairGameZone } from "@/components/three/gameplay/RepairGameZone"; import { RepairGameZone } from "@/components/three/gameplay/RepairGameZone";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { import {
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS, TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
@@ -88,13 +89,12 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
<RepairGameZone /> <RepairGameZone />
</Physics> </Physics>
{/* Temporary: re-enable when Git LFS downloads are available again.
<AnimatedModel <AnimatedModel
modelPath="/models/elec/model.gltf" modelPath="/models/elec/model.gltf"
defaultAnimation="Idle" defaultAnimation="Idle"
position={[0, 0, -5]} position={[0, 0, -5]}
scale={1} scale={1}
/> */} />
</> </>
); );
} }