Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7696519452 | |||
| a3e8e732f1 | |||
| 489499f5d2 | |||
| 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",
|
"id": "narrateur_coupureelec",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_coupureélec.mp3",
|
"audio": "/sounds/dialogue/narrateur_coupure_elec.mp3",
|
||||||
"subtitleCueIndex": 9
|
"subtitleCueIndex": 9
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "narrateur_poteaueleccasse",
|
"id": "narrateur_poteaueleccasse",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3",
|
"audio": "/sounds/dialogue/narrateur_poteau_elec_casse.mp3",
|
||||||
"subtitleCueIndex": 10
|
"subtitleCueIndex": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "narrateur_courantrepare",
|
"id": "narrateur_courantrepare",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_courantréparé.mp3",
|
"audio": "/sounds/dialogue/narrateur_courant_repare.mp3",
|
||||||
"subtitleCueIndex": 11
|
"subtitleCueIndex": 11
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -165,6 +165,12 @@
|
|||||||
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
|
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
|
||||||
"subtitleCueIndex": 23
|
"subtitleCueIndex": 23
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_demande_aide",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_demande_aide.mp3",
|
||||||
|
"subtitleCueIndex": 24
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "fermier_coupdemain",
|
"id": "fermier_coupdemain",
|
||||||
"voice": "fermier",
|
"voice": "fermier",
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
|||||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
export function EbikeIntroSequence(): React.JSX.Element | null {
|
export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||||
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
||||||
@@ -134,6 +136,16 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
|||||||
}
|
}
|
||||||
}, [introStep]);
|
}, [introStep]);
|
||||||
|
|
||||||
|
if (mainState === "pylon") {
|
||||||
|
if (pylonStep === "approaching") {
|
||||||
|
return <MissionNotification mission="pylon" visible />;
|
||||||
|
}
|
||||||
|
if (pylonStep === "narrator-outro") {
|
||||||
|
return <MissionNotification mission="farm" visible />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
introStep !== "reveal" &&
|
introStep !== "reveal" &&
|
||||||
introStep !== "await-ebike-mount" &&
|
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,
|
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[] = [];
|
const placeholders: RepairCasePlaceholder[] = [];
|
||||||
placeholderNodes.current.forEach((child) => {
|
placeholderNodes.current.forEach((child) => {
|
||||||
child.getWorldPosition(placeholderPosition.current);
|
child.getWorldPosition(placeholderPosition.current);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Suspense, useEffect, useMemo, useState } from "react";
|
|||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
|
||||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
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 { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
|
||||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||||
|
import { getNextMissionStep } from "@/data/gameplay/repairMissionState";
|
||||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||||
|
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||||
@@ -30,6 +31,8 @@ interface RepairGameProps extends Required<
|
|||||||
mission: RepairMissionId;
|
mission: RepairMissionId;
|
||||||
rotation?: Vector3Tuple;
|
rotation?: Vector3Tuple;
|
||||||
scale?: ModelTransformProps["scale"];
|
scale?: ModelTransformProps["scale"];
|
||||||
|
/** Set to false in isolated scenes with no terrain (e.g. RepairGameScene). */
|
||||||
|
snapToTerrain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RepairMissionAssetPreloaderProps {
|
interface RepairMissionAssetPreloaderProps {
|
||||||
@@ -54,6 +57,7 @@ export function RepairGame({
|
|||||||
position,
|
position,
|
||||||
rotation = [0, 0, 0],
|
rotation = [0, 0, 0],
|
||||||
scale = 1,
|
scale = 1,
|
||||||
|
snapToTerrain = true,
|
||||||
}: RepairGameProps): React.JSX.Element | null {
|
}: RepairGameProps): React.JSX.Element | null {
|
||||||
const config = REPAIR_MISSIONS[mission];
|
const config = REPAIR_MISSIONS[mission];
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
@@ -67,7 +71,11 @@ export function RepairGame({
|
|||||||
readonly RepairScannedBrokenPart[]
|
readonly RepairScannedBrokenPart[]
|
||||||
>([]);
|
>([]);
|
||||||
const parsedScale = toVector3Scale(scale);
|
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";
|
const readyForFragmentation = step === "inspected";
|
||||||
|
|
||||||
useRepairFragmentationInput({
|
useRepairFragmentationInput({
|
||||||
@@ -103,6 +111,24 @@ export function RepairGame({
|
|||||||
};
|
};
|
||||||
}, [mainState, mission, setMissionStep, step]);
|
}, [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 (mainState !== mission) return null;
|
||||||
if (step === "locked") return null;
|
if (step === "locked") return null;
|
||||||
|
|
||||||
@@ -149,12 +175,9 @@ export function RepairGame({
|
|||||||
onComplete={() => setMissionStep(mission, "done")}
|
onComplete={() => setMissionStep(mission, "done")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{step === "done" ? (
|
{/* done step: auto-advance is handled by useEffect above — no manual
|
||||||
<RepairCompletionStep
|
case-closing interaction needed. Scene is intentionally empty
|
||||||
config={config}
|
for the 200ms before completeMission/setMissionStep fires. */}
|
||||||
onComplete={() => completeMission(mission)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||||
<RepairMissionCase
|
<RepairMissionCase
|
||||||
config={config}
|
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(() => {
|
useEffect(() => {
|
||||||
if (parts.length === 0) return undefined;
|
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(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
setActivePartIndex((currentIndex) => {
|
const nextIndex = activePartIndex + 1;
|
||||||
const nextIndex = currentIndex + 1;
|
if (nextIndex >= parts.length) {
|
||||||
if (nextIndex >= parts.length) {
|
onComplete(getScannedBrokenParts(parts, config));
|
||||||
onComplete(getScannedBrokenParts(parts, config));
|
} else {
|
||||||
return currentIndex;
|
setActivePartIndex(nextIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextIndex;
|
|
||||||
});
|
|
||||||
}, scanPartSeconds * 1000);
|
}, scanPartSeconds * 1000);
|
||||||
|
|
||||||
return () => {
|
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,
|
RepairMissionTriggerConfig,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||||
|
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||||
|
|
||||||
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||||
Record<RepairMissionId, string>
|
Record<RepairMissionId, string>
|
||||||
@@ -15,7 +16,7 @@ const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
|
|||||||
|
|
||||||
const REPAIR_MISSION_POSITIONS = {
|
const REPAIR_MISSION_POSITIONS = {
|
||||||
ebike: EBIKE_REPAIR_POSITION,
|
ebike: EBIKE_REPAIR_POSITION,
|
||||||
pylon: [64, 0, -66],
|
pylon: PYLON_WORLD_POSITION,
|
||||||
farm: [-24, 0, 42],
|
farm: [-24, 0, 42],
|
||||||
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
|||||||
|
|
||||||
export const MISSION_STEPS = [
|
export const MISSION_STEPS = [
|
||||||
"locked",
|
"locked",
|
||||||
|
"approaching",
|
||||||
|
"arrived",
|
||||||
|
"npc-return",
|
||||||
"waiting",
|
"waiting",
|
||||||
"inspected",
|
"inspected",
|
||||||
"fragmented",
|
"fragmented",
|
||||||
@@ -17,6 +20,7 @@ export const MISSION_STEPS = [
|
|||||||
"repairing",
|
"repairing",
|
||||||
"reassembling",
|
"reassembling",
|
||||||
"done",
|
"done",
|
||||||
|
"narrator-outro",
|
||||||
] as const satisfies readonly MissionStep[];
|
] as const satisfies readonly MissionStep[];
|
||||||
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
|
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);
|
return MISSION_STEP_VALUES.has(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNextMissionStep(step: MissionStep): MissionStep {
|
export function getNextMissionStep(
|
||||||
|
step: MissionStep,
|
||||||
|
mission?: RepairMissionId,
|
||||||
|
): MissionStep {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case "locked":
|
case "locked":
|
||||||
|
return mission === "pylon" ? "approaching" : "waiting";
|
||||||
|
case "approaching":
|
||||||
|
return "arrived";
|
||||||
|
case "arrived":
|
||||||
|
return "npc-return";
|
||||||
|
case "npc-return":
|
||||||
return "waiting";
|
return "waiting";
|
||||||
case "waiting":
|
case "waiting":
|
||||||
return "inspected";
|
return "inspected";
|
||||||
@@ -43,16 +56,29 @@ export function getNextMissionStep(step: MissionStep): MissionStep {
|
|||||||
case "repairing":
|
case "repairing":
|
||||||
return "reassembling";
|
return "reassembling";
|
||||||
case "reassembling":
|
case "reassembling":
|
||||||
case "done":
|
|
||||||
return "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) {
|
switch (step) {
|
||||||
case "locked":
|
case "locked":
|
||||||
case "waiting":
|
|
||||||
return "locked";
|
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":
|
case "inspected":
|
||||||
return "waiting";
|
return "waiting";
|
||||||
case "fragmented":
|
case "fragmented":
|
||||||
@@ -65,5 +91,7 @@ export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
|||||||
return "repairing";
|
return "repairing";
|
||||||
case "done":
|
case "done":
|
||||||
return "reassembling";
|
return "reassembling";
|
||||||
|
case "narrator-outro":
|
||||||
|
return "done";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
{
|
{
|
||||||
id: "pylon-damaged-panel",
|
id: "pylon-damaged-panel",
|
||||||
label: "Damaged solar panel",
|
label: "Damaged solar panel",
|
||||||
nodeName: "panneau2",
|
nodeName: "pylone",
|
||||||
caseSlotName: "placeholder_2",
|
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: {
|
pylon: {
|
||||||
...state.pylon,
|
...state.pylon,
|
||||||
currentStep: "waiting",
|
currentStep: "approaching",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -212,7 +212,7 @@ function advanceRepairMissionState(
|
|||||||
state: GameState,
|
state: GameState,
|
||||||
mission: RepairMissionId,
|
mission: RepairMissionId,
|
||||||
): GameStateUpdate {
|
): GameStateUpdate {
|
||||||
const nextStep = getNextMissionStep(state[mission].currentStep);
|
const nextStep = getNextMissionStep(state[mission].currentStep, mission);
|
||||||
if (nextStep === "done") {
|
if (nextStep === "done") {
|
||||||
return completeMissionState(state, mission);
|
return completeMissionState(state, mission);
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ function rewindRepairMissionState(
|
|||||||
return setMissionStepState(
|
return setMissionStepState(
|
||||||
state,
|
state,
|
||||||
mission,
|
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";
|
} from "@/components/ui/intro";
|
||||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
||||||
|
import { useRepairGameStatus } from "@/hooks/gameplay/useRepairGameStatus";
|
||||||
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
|
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
import { RepairGameScene } from "@/world/RepairGameScene";
|
||||||
import { World } from "@/world/World";
|
import { World } from "@/world/World";
|
||||||
|
|
||||||
const LOADING_TO_VIDEO_FADE_MS = 500;
|
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 {
|
export function HomePage(): React.JSX.Element | null {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
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 setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
const graphicsPreset = useWorldSettingsStore(
|
const graphicsPreset = useWorldSettingsStore(
|
||||||
(state) => state.graphics.preset,
|
(state) => state.graphics.preset,
|
||||||
@@ -48,9 +51,92 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
INITIAL_SCENE_LOADING_STATE,
|
INITIAL_SCENE_LOADING_STATE,
|
||||||
);
|
);
|
||||||
const sceneReadyRef = useRef(false);
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
sceneReadyRef.current = sceneLoadingState.status === "ready";
|
sceneReadyRef.current = sceneLoadingState.status === "ready";
|
||||||
}, [sceneLoadingState.status]);
|
}, [sceneLoadingState.status]);
|
||||||
@@ -170,7 +256,9 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
introStep === "fade-to-video" ||
|
introStep === "fade-to-video" ||
|
||||||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
|
(introStep === "loading-map" && sceneLoadingState.status === "ready");
|
||||||
const showSceneLoadingOverlay =
|
const showSceneLoadingOverlay =
|
||||||
introStep === "loading-map" || introStep === "fade-to-video";
|
introStep === "loading-map" ||
|
||||||
|
introStep === "fade-to-video" ||
|
||||||
|
isPostRepairLoading;
|
||||||
|
|
||||||
const renderIntroOverlay = () => {
|
const renderIntroOverlay = () => {
|
||||||
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
|
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
|
||||||
@@ -189,21 +277,46 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HandTrackingProvider>
|
<HandTrackingProvider>
|
||||||
<Canvas
|
{showRepairScene && renderedMission !== null ? (
|
||||||
camera={{ position: [85, 60, 85], fov: 42 }}
|
/* Isolated repair scene — no map, no player, no physics world.
|
||||||
shadows={{ type: THREE.PCFShadowMap }}
|
Unmounting the main Canvas here frees the full GPU budget. */
|
||||||
gl={{
|
<RepairGameScene mission={renderedMission} />
|
||||||
powerPreference: "high-performance",
|
) : (
|
||||||
antialias: true,
|
<Canvas
|
||||||
stencil: false,
|
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}>
|
{isFading ? <AppLoadingIndicator floating /> : null}
|
||||||
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
</div>
|
||||||
<DebugPerf />
|
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
|
||||||
<GameUI />
|
<GameUI />
|
||||||
{dialogMessage ? (
|
{dialogMessage ? (
|
||||||
<DialogMessage
|
<DialogMessage
|
||||||
|
|||||||
@@ -54,10 +54,39 @@ export interface RepairMissionConfig {
|
|||||||
|
|
||||||
export type MissionStep =
|
export type MissionStep =
|
||||||
| "locked"
|
| "locked"
|
||||||
|
| "approaching"
|
||||||
|
| "arrived"
|
||||||
|
| "npc-return"
|
||||||
| "waiting"
|
| "waiting"
|
||||||
| "inspected"
|
| "inspected"
|
||||||
| "fragmented"
|
| "fragmented"
|
||||||
| "scanning"
|
| "scanning"
|
||||||
| "repairing"
|
| "repairing"
|
||||||
| "reassembling"
|
| "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 { Ebike } from "@/components/ebike/Ebike";
|
||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
import { RepairGamePreloader } from "@/components/three/gameplay/RepairGamePreloader";
|
||||||
import {
|
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
||||||
REPAIR_MISSION_POSITION_ENTRIES,
|
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
||||||
REPAIR_MISSION_TRIGGERS,
|
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
||||||
} from "@/data/gameplay/repairMissionAnchors";
|
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 {
|
import {
|
||||||
INTRO_STAGE_ANCHOR,
|
INTRO_STAGE_ANCHOR,
|
||||||
OUTRO_STAGE_ANCHOR,
|
OUTRO_STAGE_ANCHOR,
|
||||||
@@ -83,19 +85,35 @@ function RepairMissionTrigger({
|
|||||||
|
|
||||||
export function GameStageContent(): React.JSX.Element {
|
export function GameStageContent(): React.JSX.Element {
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
|
|
||||||
|
|
||||||
return (
|
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}
|
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||||
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
||||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
<PylonDownedPylon />
|
||||||
const position = getRepairMissionPosition(mission, anchors);
|
{isDebugEnabled() ? (
|
||||||
if (!position) return null;
|
<>
|
||||||
return (
|
<ZoneDebugVisual zone={PYLON_APPROACH_ZONE} active={false} />
|
||||||
<RepairGame key={mission} mission={mission} position={position} />
|
<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) => (
|
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
||||||
<RepairMissionTrigger key={config.mission} config={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 { Physics } from "@react-three/rapier";
|
||||||
import {
|
import {
|
||||||
PLAYER_SPAWN_POSITION_GAME,
|
PLAYER_SPAWN_POSITION_GAME,
|
||||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
|
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||||
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
|
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
|
||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||||
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
||||||
|
import { usePlayerPositionDebug } from "@/hooks/debug/usePlayerPositionDebug";
|
||||||
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
|
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
|
||||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
@@ -40,6 +42,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
useEnvironmentDebug();
|
useEnvironmentDebug();
|
||||||
useMapPerformanceDebug();
|
useMapPerformanceDebug();
|
||||||
useCharacterDebug();
|
useCharacterDebug();
|
||||||
|
usePlayerPositionDebug();
|
||||||
useDebugVisualsDebug();
|
useDebugVisualsDebug();
|
||||||
|
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
@@ -58,10 +61,31 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
handleGameMapLoaded,
|
handleGameMapLoaded,
|
||||||
handleOctreeReady,
|
handleOctreeReady,
|
||||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||||
const playerSpawnPosition =
|
// Capture the spawn position once on mount via a ref so it never changes
|
||||||
sceneMode === "game"
|
// mid-session (spawnPosition is reactive in Player and would re-spawn the
|
||||||
? PLAYER_SPAWN_POSITION_GAME
|
// character on every prop change). If the player returns from a repair
|
||||||
: PLAYER_SPAWN_POSITION_PHYSICS;
|
// 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 =
|
const showHandTrackingGloves =
|
||||||
sceneMode === "physics" ||
|
sceneMode === "physics" ||
|
||||||
(status !== "idle" && usageStatus !== "inactive");
|
(status !== "idle" && usageStatus !== "inactive");
|
||||||
|
|||||||
Reference in New Issue
Block a user