Compare commits
5 Commits
main
...
fix/repair-game
| Author | SHA1 | Date | |
|---|---|---|---|
| 7696519452 | |||
| a3e8e732f1 | |||
| d975aac018 | |||
| cd0afcda8c | |||
| 813c10f3f7 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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_courantréparé.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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface ZoneConfig {
|
||||
id: string;
|
||||
position: Vector3Tuple;
|
||||
radius: number;
|
||||
height: number;
|
||||
oneShot: boolean;
|
||||
}
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user