6 Commits

Author SHA1 Message Date
math-pixel 7696519452 Merge branch 'develop' into fix/repair-game 2026-06-02 09:26:40 +02:00
math-pixel a3e8e732f1 its functionning 2026-06-02 00:23:43 +02:00
math-pixel 489499f5d2 Merge pull request 'Feat/polish-mission1' (#12) from feat/polish-mission1 into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
Reviewed-on: #12
2026-06-01 21:51:09 +00:00
math-pixel d975aac018 o think is not that 2026-06-01 22:26:58 +02:00
math-pixel cd0afcda8c feat mission-2 2026-06-01 14:40:17 +02:00
math-pixel 813c10f3f7 wip mission 2 refine 2026-06-01 11:49:48 +02:00
33 changed files with 1175 additions and 67 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
+9 -3
View File
@@ -78,19 +78,19 @@
{
"id": "narrateur_coupureelec",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_coupureélec.mp3",
"audio": "/sounds/dialogue/narrateur_coupure_elec.mp3",
"subtitleCueIndex": 9
},
{
"id": "narrateur_poteaueleccasse",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3",
"audio": "/sounds/dialogue/narrateur_poteau_elec_casse.mp3",
"subtitleCueIndex": 10
},
{
"id": "narrateur_courantrepare",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_courantparé.mp3",
"audio": "/sounds/dialogue/narrateur_courant_repare.mp3",
"subtitleCueIndex": 11
},
{
@@ -165,6 +165,12 @@
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
"subtitleCueIndex": 23
},
{
"id": "narrateur_demande_aide",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_demande_aide.mp3",
"subtitleCueIndex": 24
},
{
"id": "fermier_coupdemain",
"voice": "fermier",
@@ -14,8 +14,10 @@ import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
export function EbikeIntroSequence(): React.JSX.Element | null {
const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const movementMode = useGameStore((state) => state.player.movementMode);
const pylonStep = useGameStore((state) => state.pylon.currentStep);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const completeIntro = useGameStore((state) => state.completeIntro);
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
@@ -134,6 +136,16 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
}
}, [introStep]);
if (mainState === "pylon") {
if (pylonStep === "approaching") {
return <MissionNotification mission="pylon" visible />;
}
if (pylonStep === "narrator-outro") {
return <MissionNotification mission="farm" visible />;
}
return null;
}
if (
introStep !== "reveal" &&
introStep !== "await-ebike-mount" &&
@@ -0,0 +1,170 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useGameStore } from "@/managers/stores/useGameStore";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import {
PYLON_DOWNED_ROTATION,
PYLON_NARRATIVE_INTERACT_RADIUS,
PYLON_NARRATIVE_DIALOGUES,
PYLON_STRAIGHTEN_ANIMATION_DURATION_MS,
PYLON_UPRIGHT_ROTATION,
PYLON_WORLD_POSITION,
} from "@/data/gameplay/pylonConfig";
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
const PYLON_MODEL_PATH = "/models/pylone/model.gltf";
export function PylonDownedPylon(): React.JSX.Element | null {
const mainState = useGameStore((state) => state.mainState);
const step = useGameStore((state) => state.pylon.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const setCanMove = useGameStore((state) => state.setCanMove);
const [isStraightening, setIsStraightening] = useState(false);
const groupRef = useRef<THREE.Group>(null);
const straightenStartRef = useRef<number | null>(null);
const hasPlayedFirstAudioRef = useRef(false);
useEffect(() => {
if (step === "arrived") hasPlayedFirstAudioRef.current = false;
}, [step]);
const { scene } = useGLTF(PYLON_MODEL_PATH);
const clonedScene = useMemo(() => scene.clone(true), [scene]);
const showUpright =
mainState !== "pylon" ||
step === "waiting" ||
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling" ||
step === "done" ||
step === "narrator-outro";
useFrame(() => {
const group = groupRef.current;
if (!group) return;
if (!isStraightening || straightenStartRef.current === null) {
group.rotation.set(
...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION),
);
return;
}
const elapsed = performance.now() - straightenStartRef.current;
const t = Math.min(elapsed / PYLON_STRAIGHTEN_ANIMATION_DURATION_MS, 1);
const eased = 1 - Math.pow(1 - t, 3);
const startEuler = new THREE.Euler(...PYLON_DOWNED_ROTATION);
group.rotation.set(
THREE.MathUtils.lerp(startEuler.x, 0, eased),
startEuler.y,
THREE.MathUtils.lerp(startEuler.z, 0, eased),
);
});
const isPylonInteractive = step === "arrived" || step === "npc-return";
// During these steps the RepairGame renders its own pylon model
// (exploded / reassembling / completion). Rendering the solid world
// pylon on top would double the heaviest model's GPU cost at the same
// spot — a prime cause of WebGL context loss. Let RepairGame own it.
const repairGameOwnsModel =
mainState === "pylon" &&
(step === "fragmented" ||
step === "scanning" ||
step === "reassembling" ||
step === "done");
const beginStraighten = (): void => {
setIsStraightening(true);
pylonStraighteningSignal.started = true;
straightenStartRef.current = performance.now();
setCanMove(false);
if (groupRef.current) {
groupRef.current.rotation.set(...PYLON_DOWNED_ROTATION);
}
window.setTimeout(() => {
setIsStraightening(false);
pylonStraighteningSignal.started = false;
setCanMove(true);
setMissionStep("pylon", "waiting");
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
};
if (repairGameOwnsModel) return null;
return (
<group
ref={groupRef}
position={PYLON_WORLD_POSITION}
rotation={PYLON_DOWNED_ROTATION}
>
<primitive object={clonedScene} />
{isPylonInteractive ? (
<InteractableObject
kind="trigger"
label={
step === "arrived" ? "Inspecter le pylône" : "Redresser le pylône"
}
position={PYLON_WORLD_POSITION}
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
onPress={() => {
if (step === "arrived") {
if (!hasPlayedFirstAudioRef.current) {
hasPlayedFirstAudioRef.current = true;
void (async () => {
const manifest = await loadDialogueManifest();
if (!manifest) return;
const audio = await playDialogueById(
manifest,
PYLON_NARRATIVE_DIALOGUES.brokenPylon,
);
if (!audio) return;
audio.addEventListener(
"ended",
() => {
void (async () => {
const m = await loadDialogueManifest();
if (!m) return;
await playDialogueById(
m,
PYLON_NARRATIVE_DIALOGUES.demandeAide,
);
})();
},
{ once: true },
);
})();
} else {
void (async () => {
const manifest = await loadDialogueManifest();
if (!manifest) return;
await playDialogueById(
manifest,
PYLON_NARRATIVE_DIALOGUES.demandeAide,
);
})();
}
} else if (step === "npc-return" && !isStraightening) {
beginStraighten();
}
}}
>
<mesh>
<sphereGeometry args={[1, 8, 8]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
</mesh>
</InteractableObject>
) : null}
</group>
);
}
useGLTF.preload(PYLON_MODEL_PATH);
@@ -0,0 +1,111 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useGameStore } from "@/managers/stores/useGameStore";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import {
PYLON_FARMER_NPC_AFTER_POSITION,
PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight,
PYLON_FARMER_NPC_AFTER_ROTATION,
PYLON_FARMER_NPC_AFTER_SCALE,
PYLON_FARMER_NPC_POSITION,
PYLON_FARMER_NPC_WALK_SPEED,
PYLON_NARRATIVE_DIALOGUES,
PYLON_NARRATIVE_INTERACT_RADIUS,
} from "@/data/gameplay/pylonConfig";
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
const _target = new THREE.Vector3();
export function PylonFarmerNPC(): React.JSX.Element | null {
const mainState = useGameStore((state) => state.mainState);
const step = useGameStore((state) => state.pylon.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const groupRef = useRef<THREE.Group>(null);
const currentPosRef = useRef(
new THREE.Vector3(...PYLON_FARMER_NPC_POSITION),
);
// Reset position when entering arrived, set target when entering npc-return
useEffect(() => {
if (step === "arrived") {
currentPosRef.current.set(...PYLON_FARMER_NPC_POSITION);
}
}, [step]);
useFrame((_, delta) => {
const group = groupRef.current;
if (!group) return;
if (step === "npc-return") {
const targetPos = pylonStraighteningSignal.started
? PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight
: PYLON_FARMER_NPC_AFTER_POSITION;
_target.set(...targetPos);
currentPosRef.current.lerp(_target, Math.min(PYLON_FARMER_NPC_WALK_SPEED * delta, 1));
group.position.copy(currentPosRef.current);
group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
} else if (step === "inspected") {
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
} else {
group.position.set(...PYLON_FARMER_NPC_POSITION);
}
});
if (mainState !== "pylon") return null;
if (step !== "arrived" && step !== "npc-return" && step !== "inspected") return null;
return (
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
<mesh position={[0, 1, 0]}>
<capsuleGeometry args={[0.4, 1.2, 6, 12]} />
<meshStandardMaterial color="#a16207" />
</mesh>
<mesh position={[0, 1.95, 0]}>
<sphereGeometry args={[0.28, 12, 12]} />
<meshStandardMaterial color="#fde68a" />
</mesh>
{step === "arrived" ? (
<InteractableObject
kind="trigger"
label="Parler au fermier"
position={PYLON_FARMER_NPC_POSITION}
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
onPress={() => {
void (async () => {
const manifest = await loadDialogueManifest();
if (!manifest) {
setMissionStep("pylon", "npc-return");
return;
}
const audio = await playDialogueById(
manifest,
PYLON_NARRATIVE_DIALOGUES.farmerHelp,
);
if (!audio) {
setMissionStep("pylon", "npc-return");
return;
}
audio.addEventListener(
"ended",
() => setMissionStep("pylon", "npc-return"),
{ once: true },
);
})();
}}
>
<mesh>
<sphereGeometry args={[1, 8, 8]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
</mesh>
</InteractableObject>
) : null}
</group>
);
}
@@ -0,0 +1,73 @@
import { useEffect } from "react";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
import { ZoneDetection } from "@/components/zone/ZoneDetection";
import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC";
import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro";
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
import { PYLON_NARRATIVE_DIALOGUES } from "@/data/gameplay/pylonConfig";
export function PylonNarrativeFlow(): React.JSX.Element | null {
const mainState = useGameStore((state) => state.mainState);
const step = useGameStore((state) => state.pylon.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const completeMission = useGameStore((state) => state.completeMission);
useDialoguePlayback({
enabled: mainState === "pylon" && step === "approaching",
dialogueId: PYLON_NARRATIVE_DIALOGUES.electricOutage,
});
useDialoguePlayback({
enabled: mainState === "pylon" && step === "arrived",
dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral,
});
useDialoguePlayback({
enabled: mainState === "pylon" && step === "narrator-outro",
dialogueId: PYLON_NARRATIVE_DIALOGUES.powerRestored,
onComplete: () => completeMission("pylon"),
});
// Advance waiting → inspected in a separate macrotask so React and Rapier
// finish their current commit before RigidBody colliders are created.
useEffect(() => {
if (mainState !== "pylon" || step !== "waiting") return undefined;
const id = window.setTimeout(() => {
setMissionStep("pylon", "inspected");
}, 0);
return () => window.clearTimeout(id);
}, [mainState, step, setMissionStep]);
if (mainState !== "pylon") return null;
if (step === "locked") {
return (
<ZoneDetection
key="pylon-approach"
zone={PYLON_APPROACH_ZONE}
onEnter={() => setMissionStep("pylon", "approaching")}
/>
);
}
if (step === "approaching") {
return (
<ZoneDetection
key="pylon-arrived"
zone={PYLON_ARRIVED_ZONE}
onEnter={() => setMissionStep("pylon", "arrived")}
/>
);
}
if (step === "arrived" || step === "npc-return" || step === "inspected") {
return <PylonFarmerNPC />;
}
if (step === "narrator-outro") {
return <PylonNarratorOutro />;
}
return null;
}
@@ -0,0 +1,11 @@
import { useGameStore } from "@/managers/stores/useGameStore";
export function PylonNarratorOutro(): React.JSX.Element | null {
const mainState = useGameStore((state) => state.mainState);
const step = useGameStore((state) => state.pylon.currentStep);
if (mainState !== "pylon") return null;
if (step !== "narrator-outro") return null;
return null;
}
@@ -0,0 +1,5 @@
/**
* Shared runtime signal set by PylonDownedPylon when the straighten
* animation starts, so PylonFarmerNPC can switch its lerp target.
*/
export const pylonStraighteningSignal = { started: false };
@@ -219,7 +219,11 @@ export function RepairCaseModel({
parsedScale[2] * pop.current.scale,
);
if (placeholderNodes.current.length > 0) {
// Placeholders are only consumed when the case is open (repairing). While
// floating (inspected/scanning) the case bobs every frame, so emitting here
// would fire a React setState on every frame, re-rendering the whole
// RepairGame subtree continuously. Only compute when not floating.
if (!floating && placeholderNodes.current.length > 0) {
const placeholders: RepairCasePlaceholder[] = [];
placeholderNodes.current.forEach((child) => {
child.getWorldPosition(placeholderPosition.current);
+31 -8
View File
@@ -2,7 +2,6 @@ import { Suspense, useEffect, useMemo, useState } from "react";
import { useGLTF } from "@react-three/drei";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
@@ -10,7 +9,9 @@ import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemb
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
import { getNextMissionStep } from "@/data/gameplay/repairMissionState";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
@@ -30,6 +31,8 @@ interface RepairGameProps extends Required<
mission: RepairMissionId;
rotation?: Vector3Tuple;
scale?: ModelTransformProps["scale"];
/** Set to false in isolated scenes with no terrain (e.g. RepairGameScene). */
snapToTerrain?: boolean;
}
interface RepairMissionAssetPreloaderProps {
@@ -54,6 +57,7 @@ export function RepairGame({
position,
rotation = [0, 0, 0],
scale = 1,
snapToTerrain = true,
}: RepairGameProps): React.JSX.Element | null {
const config = REPAIR_MISSIONS[mission];
const mainState = useGameStore((state) => state.mainState);
@@ -67,7 +71,11 @@ export function RepairGame({
readonly RepairScannedBrokenPart[]
>([]);
const parsedScale = toVector3Scale(scale);
const snappedPosition = useTerrainSnappedPosition(position);
// useTerrainSnappedPosition must always be called (rules of hooks) but we
// only use its result when snapToTerrain is true — in the isolated repair
// scene there is no terrain, so we use the raw position directly.
const snappedByTerrain = useTerrainSnappedPosition(position);
const snappedPosition = snapToTerrain ? snappedByTerrain : position;
const readyForFragmentation = step === "inspected";
useRepairFragmentationInput({
@@ -103,6 +111,24 @@ export function RepairGame({
};
}, [mainState, mission, setMissionStep, step]);
// When "done" is reached: set pendingCompletion in the transition store.
// useRepairGameStatus detects this and triggers the fade back to world.
// page.tsx waits for the world to fully load, THEN executes the completion.
// This ensures the player sees a loading screen rather than a black flash.
useEffect(() => {
if (mainState !== mission || step !== "done") return undefined;
const timeoutId = window.setTimeout(() => {
const nextStep = getNextMissionStep("done", mission);
useRepairTransitionStore.getState().setPendingCompletion({
mission,
nextStep,
});
}, 200);
return () => window.clearTimeout(timeoutId);
}, [mainState, mission, step]);
if (mainState !== mission) return null;
if (step === "locked") return null;
@@ -149,12 +175,9 @@ export function RepairGame({
onComplete={() => setMissionStep(mission, "done")}
/>
) : null}
{step === "done" ? (
<RepairCompletionStep
config={config}
onComplete={() => completeMission(mission)}
/>
) : null}
{/* done step: auto-advance is handled by useEffect above — no manual
case-closing interaction needed. Scene is intentionally empty
for the 200ms before completeMission/setMissionStep fires. */}
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
<RepairMissionCase
config={config}
@@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useGLTF } from "@react-three/drei";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
function getPreloadPaths(mission: RepairMissionId): string[] {
const config = REPAIR_MISSIONS[mission];
return [
...new Set([
REPAIR_CASE_MODEL_PATH,
config.modelPath,
...config.brokenParts.flatMap((p) => p.modelPath ?? []),
...config.replacementParts.flatMap((p) => p.modelPath ?? []),
]),
];
}
interface RepairGamePreloaderProps {
mission: RepairMissionId;
}
/**
* Fires useGLTF.preload() for every asset used by a repair mission.
* Renders nothing — pure background loading.
*/
export function RepairGamePreloader({
mission,
}: RepairGamePreloaderProps): null {
useEffect(() => {
for (const path of getPreloadPaths(mission)) {
useGLTF.preload(path);
}
}, [mission]);
return null;
}
@@ -41,16 +41,16 @@ export function RepairScanSequence({
useEffect(() => {
if (parts.length === 0) return undefined;
// Do NOT call onComplete inside a setState updater — updaters run during
// React's render phase, which would trigger a setState on RepairGame and
// cause a "setState during render" error. Call it directly in the timeout.
const timeoutId = window.setTimeout(() => {
setActivePartIndex((currentIndex) => {
const nextIndex = currentIndex + 1;
if (nextIndex >= parts.length) {
onComplete(getScannedBrokenParts(parts, config));
return currentIndex;
}
return nextIndex;
});
const nextIndex = activePartIndex + 1;
if (nextIndex >= parts.length) {
onComplete(getScannedBrokenParts(parts, config));
} else {
setActivePartIndex(nextIndex);
}
}, scanPartSeconds * 1000);
return () => {
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
import type { ZoneConfig } from "@/types/gameplay/zone";
interface ZoneDetectionProps {
zone: ZoneConfig;
onEnter: () => void;
height?: number;
}
const _cameraPos = new THREE.Vector3();
export function ZoneDebugVisual({
zone,
active,
}: {
zone: ZoneConfig;
active: boolean;
}): React.JSX.Element | null {
if (!isDebugEnabled()) return null;
return (
<group position={zone.position}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[zone.radius - 0.2, zone.radius, 32]} />
<meshBasicMaterial
color={active ? "#22c55e" : "#fbbf24"}
transparent
opacity={0.6}
side={THREE.DoubleSide}
/>
</mesh>
<mesh>
<cylinderGeometry
args={[zone.radius, zone.radius, zone.height, 16, 1, true]}
/>
<meshBasicMaterial
color={active ? "#22c55e" : "#fbbf24"}
transparent
opacity={0.08}
side={THREE.DoubleSide}
/>
</mesh>
</group>
);
}
export function ZoneDetection({
zone,
onEnter,
height,
}: ZoneDetectionProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const hasTriggeredRef = useRef(false);
const onEnterRef = useRef(onEnter);
const [isActive, setIsActive] = useState(false);
useEffect(() => {
onEnterRef.current = onEnter;
}, [onEnter]);
useFrame(() => {
if (hasTriggeredRef.current) return;
camera.getWorldPosition(_cameraPos);
const dx = _cameraPos.x - zone.position[0];
const dz = _cameraPos.z - zone.position[2];
const horizontalDist = Math.sqrt(dx * dx + dz * dz);
if (horizontalDist > zone.radius) return;
const zoneHeight = height ?? zone.height;
if (_cameraPos.y < zone.position[1] - zoneHeight / 2) return;
if (_cameraPos.y > zone.position[1] + zoneHeight / 2) return;
hasTriggeredRef.current = true;
setIsActive(true);
onEnterRef.current();
});
return <ZoneDebugVisual zone={zone} active={isActive} />;
}
+48
View File
@@ -0,0 +1,48 @@
import type { Vector3Tuple } from "@/types/three/three";
export const PYLON_WORLD_POSITION: Vector3Tuple = [43, 5, 45];
export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
PYLON_WORLD_POSITION[0] - 6,
PYLON_WORLD_POSITION[1],
PYLON_WORLD_POSITION[2] + 4,
];
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
PYLON_WORLD_POSITION[0] + 3,
PYLON_WORLD_POSITION[1],
PYLON_WORLD_POSITION[2],
];
/** Position finale du PNJ quand le pylône se redresse */
export const PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight: Vector3Tuple = [
PYLON_WORLD_POSITION[0] + 1,
PYLON_WORLD_POSITION[1],
PYLON_WORLD_POSITION[2],
];
/** Rotation (X Y Z radians) du PNJ une fois arrivé sous le pylône */
export const PYLON_FARMER_NPC_AFTER_ROTATION: Vector3Tuple = [0, 0, 0];
/** Scale uniforme du PNJ une fois arrivé sous le pylône */
export const PYLON_FARMER_NPC_AFTER_SCALE = 1;
/** Vitesse du lerp de déplacement du PNJ (unités/s) */
export const PYLON_FARMER_NPC_WALK_SPEED = 2;
export const PYLON_NARRATIVE_INTERACT_RADIUS = 3.5;
export const PYLON_STRAIGHTEN_ANIMATION_DURATION_MS = 2200;
export const PYLON_NARRATIVE_DIALOGUES = {
electricOutage: "narrateur_coupureelec",
searchCentral: "narrateur_fouillelecentre",
brokenPylon: "narrateur_poteaueleccasse",
demandeAide: "narrateur_demande_aide",
farmerHelp: "fermier_coupdemain",
powerRestored: "narrateur_courantrepare",
} as const;
+2 -1
View File
@@ -4,6 +4,7 @@ import type {
RepairMissionTriggerConfig,
} from "@/types/gameplay/repairMission";
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
Record<RepairMissionId, string>
@@ -15,7 +16,7 @@ const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
const REPAIR_MISSION_POSITIONS = {
ebike: EBIKE_REPAIR_POSITION,
pylon: [64, 0, -66],
pylon: PYLON_WORLD_POSITION,
farm: [-24, 0, 42],
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
+32 -4
View File
@@ -10,6 +10,9 @@ const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
export const MISSION_STEPS = [
"locked",
"approaching",
"arrived",
"npc-return",
"waiting",
"inspected",
"fragmented",
@@ -17,6 +20,7 @@ export const MISSION_STEPS = [
"repairing",
"reassembling",
"done",
"narrator-outro",
] as const satisfies readonly MissionStep[];
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
@@ -28,9 +32,18 @@ export function isMissionStep(value: string): value is MissionStep {
return MISSION_STEP_VALUES.has(value);
}
export function getNextMissionStep(step: MissionStep): MissionStep {
export function getNextMissionStep(
step: MissionStep,
mission?: RepairMissionId,
): MissionStep {
switch (step) {
case "locked":
return mission === "pylon" ? "approaching" : "waiting";
case "approaching":
return "arrived";
case "arrived":
return "npc-return";
case "npc-return":
return "waiting";
case "waiting":
return "inspected";
@@ -43,16 +56,29 @@ export function getNextMissionStep(step: MissionStep): MissionStep {
case "repairing":
return "reassembling";
case "reassembling":
case "done":
return "done";
case "done":
return mission === "pylon" ? "narrator-outro" : "done";
case "narrator-outro":
return "narrator-outro";
}
}
export function getPreviousMissionStep(step: MissionStep): MissionStep {
export function getPreviousMissionStep(
step: MissionStep,
mission?: RepairMissionId,
): MissionStep {
switch (step) {
case "locked":
case "waiting":
return "locked";
case "approaching":
return "locked";
case "arrived":
return "approaching";
case "npc-return":
return "arrived";
case "waiting":
return mission === "pylon" ? "npc-return" : "locked";
case "inspected":
return "waiting";
case "fragmented":
@@ -65,5 +91,7 @@ export function getPreviousMissionStep(step: MissionStep): MissionStep {
return "repairing";
case "done":
return "reassembling";
case "narrator-outro":
return "done";
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
{
id: "pylon-damaged-panel",
label: "Damaged solar panel",
nodeName: "panneau2",
nodeName: "pylone",
caseSlotName: "placeholder_2",
},
],
+26
View File
@@ -0,0 +1,26 @@
import type { ZoneConfig } from "@/types/gameplay/zone";
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
export const PYLON_APPROACH_ZONE: ZoneConfig = {
id: "pylon-approach",
position: [
PYLON_WORLD_POSITION[0],
PYLON_WORLD_POSITION[1]- 5,
PYLON_WORLD_POSITION[2],
],
radius: 5,
height: 18,
oneShot: true,
};
export const PYLON_ARRIVED_ZONE: ZoneConfig = {
id: "pylon-arrived",
position: [
PYLON_WORLD_POSITION[0] + 5,
PYLON_WORLD_POSITION[1] - 5,
PYLON_WORLD_POSITION[2] + 5,
],
radius: 5,
height: 15,
oneShot: true,
};
+29
View File
@@ -0,0 +1,29 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import type GUI from "lil-gui";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
export function usePlayerPositionDebug(): void {
const pos = useRef({ x: 0, y: 0, z: 0 });
const controllers = useRef<{ updateDisplay: () => void }[]>([]);
useDebugFolder("Game", (folder: GUI) => {
const sub = folder.addFolder("Player Position");
sub.open();
controllers.current = [
sub.add(pos.current, "x").name("X").decimals(2).disable(),
sub.add(pos.current, "y").name("Y").decimals(2).disable(),
sub.add(pos.current, "z").name("Z").decimals(2).disable(),
];
});
useFrame(() => {
const p = window.playerPos;
if (!p) return;
pos.current.x = p[0];
pos.current.y = p[1];
pos.current.z = p[2];
for (const c of controllers.current) c.updateDisplay();
});
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect } from "react";
import { useGameStore } from "@/managers/stores/useGameStore";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
interface UseDialoguePlaybackOptions {
enabled: boolean;
dialogueId: string | null;
onComplete?: () => void;
}
export function useDialoguePlayback({
enabled,
dialogueId,
onComplete,
}: UseDialoguePlaybackOptions): void {
const setCanMove = useGameStore((state) => state.setCanMove);
useEffect(() => {
if (!enabled || !dialogueId) return undefined;
let isCancelled = false;
setCanMove(false);
void (async () => {
const manifest = await loadDialogueManifest();
if (isCancelled || !manifest) {
setCanMove(true);
return;
}
const audio = await playDialogueById(manifest, dialogueId);
if (isCancelled || !audio) {
setCanMove(true);
return;
}
audio.addEventListener(
"ended",
() => {
setCanMove(true);
onComplete?.();
},
{ once: true },
);
})();
return () => {
isCancelled = true;
setCanMove(true);
};
}, [enabled, dialogueId, onComplete, setCanMove]);
}
+43
View File
@@ -0,0 +1,43 @@
import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
import { isRepairGameStep } from "@/types/gameplay/repairMission";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
export interface RepairGameStatus {
active: boolean;
mission: RepairMissionId | null;
}
/**
* Returns whether a repair game is currently active and for which mission.
* Drives the scene swap in page.tsx: when active, the heavy 3D world is
* unmounted and a lightweight isolated repair scene is shown instead.
*/
export function useRepairGameStatus(): RepairGameStatus {
const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const pylonStep = useGameStore((state) => state.pylon.currentStep);
const farmStep = useGameStore((state) => state.farm.currentStep);
// When pendingCompletion is set the repair game is "done" but we want the
// world to finish loading before executing the completion. Returning
// active=false here triggers the fade back to the world scene.
const pendingCompletion = useRepairTransitionStore(
(s) => s.pendingCompletion,
);
if (pendingCompletion !== null) {
return { active: false, mission: null };
}
if (mainState === "ebike" && isRepairGameStep(ebikeStep)) {
return { active: true, mission: "ebike" };
}
if (mainState === "pylon" && isRepairGameStep(pylonStep)) {
return { active: true, mission: "pylon" };
}
if (mainState === "farm" && isRepairGameStep(farmStep)) {
return { active: true, mission: "farm" };
}
return { active: false, mission: null };
}
+3 -3
View File
@@ -146,7 +146,7 @@ function completeEbikeState(state: GameState): GameStateUpdate {
},
pylon: {
...state.pylon,
currentStep: "waiting",
currentStep: "approaching",
},
};
}
@@ -212,7 +212,7 @@ function advanceRepairMissionState(
state: GameState,
mission: RepairMissionId,
): GameStateUpdate {
const nextStep = getNextMissionStep(state[mission].currentStep);
const nextStep = getNextMissionStep(state[mission].currentStep, mission);
if (nextStep === "done") {
return completeMissionState(state, mission);
}
@@ -227,7 +227,7 @@ function rewindRepairMissionState(
return setMissionStepState(
state,
mission,
getPreviousMissionStep(state[mission].currentStep),
getPreviousMissionStep(state[mission].currentStep, mission),
);
}
@@ -0,0 +1,33 @@
import { create } from "zustand";
import type { MissionStep, RepairMissionId } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
export interface RepairPendingCompletion {
mission: RepairMissionId;
/** Next step to set. When it equals "done", completeMission() is called
* instead (ebike / farm have no further narrative sub-step). */
nextStep: MissionStep;
}
interface RepairTransitionState {
/** Set when the repair game reaches "done". page.tsx reads this and
* executes the completion only after the world has fully re-loaded. */
pendingCompletion: RepairPendingCompletion | null;
/** Player 3D position captured just before entering the repair scene,
* used to re-spawn the player at the correct location on return. */
savedPlayerPosition: Vector3Tuple | null;
}
interface RepairTransitionActions {
setPendingCompletion: (data: RepairPendingCompletion | null) => void;
setSavedPlayerPosition: (pos: Vector3Tuple | null) => void;
}
export const useRepairTransitionStore = create<
RepairTransitionState & RepairTransitionActions
>()((set) => ({
pendingCompletion: null,
savedPlayerPosition: null,
setPendingCompletion: (data) => set({ pendingCompletion: data }),
setSavedPlayerPosition: (pos) => set({ savedPlayerPosition: pos }),
}));
+131 -18
View File
@@ -15,25 +15,28 @@ import {
} from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useRepairGameStatus } from "@/hooks/gameplay/useRepairGameStatus";
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
import { logger } from "@/utils/core/Logger";
import { RepairGameScene } from "@/world/RepairGameScene";
import { World } from "@/world/World";
const LOADING_TO_VIDEO_FADE_MS = 500;
// Duration (ms) of each half of the repair scene cross-fade
const REPAIR_FADE_MS = 250;
export function HomePage(): React.JSX.Element | null {
const navigate = useNavigate();
const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const pylonStep = useGameStore((state) => state.pylon.currentStep);
const farmStep = useGameStore((state) => state.farm.currentStep);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const graphicsPreset = useWorldSettingsStore(
(state) => state.graphics.preset,
@@ -48,9 +51,92 @@ export function HomePage(): React.JSX.Element | null {
INITIAL_SCENE_LOADING_STATE,
);
const sceneReadyRef = useRef(false);
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`;
// Only trigger the transient loading indicator on mission-level transitions
// (mainState) or graphics changes — not on every repair game sub-step.
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}`;
const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal);
// --- Repair scene swap ---------------------------------------------------
const repairStatus = useRepairGameStatus();
const pendingCompletion = useRepairTransitionStore(
(s) => s.pendingCompletion,
);
const setPendingCompletion = useRepairTransitionStore(
(s) => s.setPendingCompletion,
);
const setSavedPlayerPosition = useRepairTransitionStore(
(s) => s.setSavedPlayerPosition,
);
const [showRepairScene, setShowRepairScene] = useState(repairStatus.active);
const [renderedMission, setRenderedMission] =
useState<RepairMissionId | null>(
repairStatus.active ? repairStatus.mission : null,
);
const [isFading, setIsFading] = useState(false);
// True while the world is reloading after a repair scene (shows loading overlay).
const [isPostRepairLoading, setIsPostRepairLoading] = useState(false);
const lastRepairActiveRef = useRef(repairStatus.active);
useEffect(() => {
if (repairStatus.active === lastRepairActiveRef.current) return;
lastRepairActiveRef.current = repairStatus.active;
if (repairStatus.active) {
// Entering repair scene — capture the player's current world position
// so we can restore it when returning.
const pos = (window as Window & { playerPos?: [number, number, number] })
.playerPos;
if (pos) setSavedPlayerPosition([pos[0], pos[1], pos[2]]);
}
setIsFading(true);
const swapTimer = window.setTimeout(() => {
setShowRepairScene(repairStatus.active);
setRenderedMission(repairStatus.active ? repairStatus.mission : null);
if (!repairStatus.active) {
// Returning from repair scene — reset loading state so the overlay
// shows while the world reloads from scratch (new WebGL context).
sceneReadyRef.current = false;
setSceneLoadingState(INITIAL_SCENE_LOADING_STATE);
setIsPostRepairLoading(true);
}
window.setTimeout(() => {
setIsFading(false);
}, 50);
}, REPAIR_FADE_MS);
return () => window.clearTimeout(swapTimer);
}, [repairStatus.active, repairStatus.mission, setSavedPlayerPosition]);
// Execute the pending repair completion once the world is fully loaded.
useEffect(() => {
if (!isPostRepairLoading) return;
if (sceneLoadingState.status !== "ready") return;
if (!pendingCompletion) return;
const { mission, nextStep } = pendingCompletion;
const store = useGameStore.getState();
if (nextStep === "done") {
store.completeMission(mission);
} else {
store.setMissionStep(mission, nextStep);
}
setPendingCompletion(null);
setIsPostRepairLoading(false);
}, [
isPostRepairLoading,
sceneLoadingState.status,
pendingCompletion,
setPendingCompletion,
]);
// -------------------------------------------------------------------------
useEffect(() => {
sceneReadyRef.current = sceneLoadingState.status === "ready";
}, [sceneLoadingState.status]);
@@ -170,7 +256,9 @@ export function HomePage(): React.JSX.Element | null {
introStep === "fade-to-video" ||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
const showSceneLoadingOverlay =
introStep === "loading-map" || introStep === "fade-to-video";
introStep === "loading-map" ||
introStep === "fade-to-video" ||
isPostRepairLoading;
const renderIntroOverlay = () => {
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
@@ -189,21 +277,46 @@ export function HomePage(): React.JSX.Element | null {
return (
<HandTrackingProvider>
<Canvas
camera={{ position: [85, 60, 85], fov: 42 }}
shadows={{ type: THREE.PCFShadowMap }}
gl={{
powerPreference: "high-performance",
antialias: true,
stencil: false,
{showRepairScene && renderedMission !== null ? (
/* Isolated repair scene — no map, no player, no physics world.
Unmounting the main Canvas here frees the full GPU budget. */
<RepairGameScene mission={renderedMission} />
) : (
<Canvas
camera={{ position: [85, 60, 85], fov: 42 }}
shadows={{ type: THREE.PCFShadowMap }}
gl={{
powerPreference: "high-performance",
antialias: true,
stencil: false,
}}
onCreated={handleCanvasCreated}
>
<Suspense fallback={null}>
<World onLoadingStateChange={handleSceneLoadingStateChange} />
<DebugPerf />
</Suspense>
</Canvas>
)}
{/* Black fade overlay — covers the WebGL context swap.
The AppLoadingIndicator lives INSIDE this div so it inherits the
stacking context (z-index 60) and always paints above the black. */}
<div
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
background: "#000",
zIndex: 60,
opacity: isFading ? 1 : 0,
transition: `opacity ${REPAIR_FADE_MS}ms ease-in-out`,
pointerEvents: isFading ? "all" : "none",
}}
onCreated={handleCanvasCreated}
>
<Suspense fallback={null}>
<World onLoadingStateChange={handleSceneLoadingStateChange} />
<DebugPerf />
</Suspense>
</Canvas>
{isFading ? <AppLoadingIndicator floating /> : null}
</div>
<GameUI />
{dialogMessage ? (
<DialogMessage
+30 -1
View File
@@ -54,10 +54,39 @@ export interface RepairMissionConfig {
export type MissionStep =
| "locked"
| "approaching"
| "arrived"
| "npc-return"
| "waiting"
| "inspected"
| "fragmented"
| "scanning"
| "repairing"
| "reassembling"
| "done";
| "done"
| "narrator-outro";
export const PYLON_NARRATIVE_STEPS = [
"approaching",
"arrived",
"npc-return",
"narrator-outro",
] as const;
export const REPAIR_GAME_STEPS = [
"waiting",
"inspected",
"fragmented",
"scanning",
"repairing",
"reassembling",
"done",
] as const;
export function isPylonNarrativeStep(step: MissionStep): boolean {
return (PYLON_NARRATIVE_STEPS as readonly MissionStep[]).includes(step);
}
export function isRepairGameStep(step: MissionStep): boolean {
return (REPAIR_GAME_STEPS as readonly MissionStep[]).includes(step);
}
+9
View File
@@ -0,0 +1,9 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface ZoneConfig {
id: string;
position: Vector3Tuple;
radius: number;
height: number;
oneShot: boolean;
}
+31 -13
View File
@@ -1,10 +1,12 @@
import { Ebike } from "@/components/ebike/Ebike";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairGame } from "@/components/three/gameplay/RepairGame";
import {
REPAIR_MISSION_POSITION_ENTRIES,
REPAIR_MISSION_TRIGGERS,
} from "@/data/gameplay/repairMissionAnchors";
import { RepairGamePreloader } from "@/components/three/gameplay/RepairGamePreloader";
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
import { REPAIR_MISSION_TRIGGERS } from "@/data/gameplay/repairMissionAnchors";
import {
INTRO_STAGE_ANCHOR,
OUTRO_STAGE_ANCHOR,
@@ -83,19 +85,35 @@ function RepairMissionTrigger({
export function GameStageContent(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
return (
<>
{/* Pre-load the next mission's repair assets while the player is still
in the world, so the isolated repair scene mounts instantly.
Only load pylon during ebike (not before) to avoid holding a
finished mission's textures in VRAM. */}
{mainState === "intro" || mainState === "ebike" ? (
<RepairGamePreloader mission="pylon" />
) : null}
{mainState === "pylon" ? <RepairGamePreloader mission="farm" /> : null}
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors);
if (!position) return null;
return (
<RepairGame key={mission} mission={mission} position={position} />
);
})}
<PylonDownedPylon />
{isDebugEnabled() ? (
<>
<ZoneDebugVisual zone={PYLON_APPROACH_ZONE} active={false} />
<ZoneDebugVisual zone={PYLON_ARRIVED_ZONE} active={false} />
</>
) : null}
{mainState === "pylon" ? <PylonNarrativeFlow /> : null}
{/* RepairGame is NO LONGER rendered here. When a repair step becomes
active, page.tsx unmounts this whole world and mounts the isolated
RepairGameScene instead, freeing all map/character VRAM. */}
{/* Trigger sphere that starts the ebike repair (locked → waiting).
The repair scene swap is then handled by useRepairGameStatus. */}
{REPAIR_MISSION_TRIGGERS.map((config) => (
<RepairMissionTrigger key={config.mission} config={config} />
))}
+108
View File
@@ -0,0 +1,108 @@
import { Suspense, useCallback, useEffect } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { Physics } from "@react-three/rapier";
import * as THREE from "three";
import { DebugPerf } from "@/components/debug/DebugPerf";
import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { logger } from "@/utils/core/Logger";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
// Isolated scene — no world offset, no terrain. The repair game runs
// fully centred in its own context so the heavy map never loads here.
const REPAIR_SCENE_POSITION: Vector3Tuple = [0, 0, 0];
// Background: very dark blue-grey to match Altera's night-time mood
const REPAIR_SCENE_BG = "#0b0d14";
// Lighting tuned to match the main world defaults from lightingConfig.ts
const AMBIENT_COLOR = "#dfe7d8";
const AMBIENT_INTENSITY = 0.9;
const SUN_COLOR = "#ffe2bf";
const SUN_INTENSITY = 2.2;
const SUN_POSITION: Vector3Tuple = [5, 8, 4];
// Mimic the first-person view from the main world:
// - PLAYER_EYE_HEIGHT = 1.75 → camera Y
// - Case floats at [0, 0.4, 1.8] (inspected) → [0, 1.05, 2.05] (repairing)
// - Look-at target averaged between those two states
const CAMERA_POSITION: Vector3Tuple = [5, 2, 2];
const CAMERA_LOOK_AT: Vector3Tuple = [0, 0.7, 1.9];
function RepairSceneCamera(): null {
const { camera } = useThree();
useEffect(() => {
camera.lookAt(...CAMERA_LOOK_AT);
}, [camera]);
return null;
}
interface RepairGameSceneProps {
mission: RepairMissionId;
}
export function RepairGameScene({
mission,
}: RepairGameSceneProps): React.JSX.Element {
const handleCreated = useCallback(({ gl }: { gl: THREE.WebGLRenderer }) => {
const canvas = gl.domElement;
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Repair scene context lost — attempting restore");
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
};
const handleContextRestored = () => {
logger.info("WebGL", "Repair scene context restored");
};
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored);
}, []);
return (
<Canvas
camera={{ position: CAMERA_POSITION, fov: 42 }}
shadows={{ type: THREE.PCFShadowMap }}
gl={{
powerPreference: "high-performance",
antialias: true,
stencil: false,
}}
onCreated={handleCreated}
>
<color attach="background" args={[REPAIR_SCENE_BG]} />
<RepairSceneCamera />
{/* Lighting — mirrors the game world defaults */}
<ambientLight intensity={AMBIENT_INTENSITY} color={AMBIENT_COLOR} />
<directionalLight
position={SUN_POSITION}
intensity={SUN_INTENSITY}
color={SUN_COLOR}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<Suspense fallback={null}>
{/* Physics is required: TriggerObject and GrabbableObject both use
RigidBody. The world is minimal — no octree, no character bodies. */}
<Physics>
<RepairGame
mission={mission}
position={REPAIR_SCENE_POSITION}
snapToTerrain={false}
/>
</Physics>
</Suspense>
<DebugPerf />
</Canvas>
);
}
+29 -5
View File
@@ -1,14 +1,16 @@
import { Suspense, useEffect } from "react";
import { Suspense, useEffect, useRef } from "react";
import { Physics } from "@react-three/rapier";
import {
PLAYER_SPAWN_POSITION_GAME,
PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/player/playerConfig";
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
import { usePlayerPositionDebug } from "@/hooks/debug/usePlayerPositionDebug";
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
@@ -40,6 +42,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
useEnvironmentDebug();
useMapPerformanceDebug();
useCharacterDebug();
usePlayerPositionDebug();
useDebugVisualsDebug();
const cameraMode = useCameraMode();
@@ -58,10 +61,31 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
handleGameMapLoaded,
handleOctreeReady,
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const playerSpawnPosition =
sceneMode === "game"
? PLAYER_SPAWN_POSITION_GAME
: PLAYER_SPAWN_POSITION_PHYSICS;
// Capture the spawn position once on mount via a ref so it never changes
// mid-session (spawnPosition is reactive in Player and would re-spawn the
// character on every prop change). If the player returns from a repair
// scene, savedPlayerPosition holds their world position; otherwise fall
// back to the default spawn from playerConfig.
const savedPlayerPosition = useRepairTransitionStore(
(s) => s.savedPlayerPosition,
);
const playerSpawnPositionRef = useRef(
savedPlayerPosition ??
(sceneMode === "game"
? PLAYER_SPAWN_POSITION_GAME
: PLAYER_SPAWN_POSITION_PHYSICS),
);
const playerSpawnPosition = playerSpawnPositionRef.current;
// Clear the saved position right after capturing it so the next world
// mount uses the default spawn instead of the stale repair-exit position.
useEffect(() => {
if (savedPlayerPosition !== null) {
useRepairTransitionStore.getState().setSavedPlayerPosition(null);
}
// Only on mount — intentionally no deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const showHandTrackingGloves =
sceneMode === "physics" ||
(status !== "idle" && usageStatus !== "inactive");