Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7696519452 | |||
| a3e8e732f1 | |||
| 489499f5d2 | |||
| 39b996eb31 | |||
| 134c0aecb7 | |||
| b144dc1c18 | |||
| 69c720b86b | |||
| d975aac018 | |||
| cd0afcda8c | |||
| 813c10f3f7 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -44,30 +44,39 @@ through opaque geometry.
|
|||||||
## Shadow rendering intermittence
|
## Shadow rendering intermittence
|
||||||
|
|
||||||
Shadows occasionally failed to render on initial load and could disappear
|
Shadows occasionally failed to render on initial load and could disappear
|
||||||
mid-session even though the `Lighting` configuration ran to completion.
|
mid-session even though the `Lighting` configuration ran to completion. The
|
||||||
|
fix has two layers:
|
||||||
|
|
||||||
Root cause: the sun follows the camera (its world matrix is dirty every frame
|
### Per-frame refresh (steady state)
|
||||||
via `updateMatrixWorld()` inside `Lighting.useFrame`). With `shadow.autoUpdate`
|
|
||||||
alone, three.js can skip the shadow map re-render on a frame where the matrix
|
|
||||||
update has happened but the renderer's internal dirty tracking does not pick
|
|
||||||
it up, leaving the shadow map stale or unrendered.
|
|
||||||
|
|
||||||
Fix in `src/world/Lighting.tsx`: explicit `sun.shadow.needsUpdate = true` in
|
The sun follows the camera, so its world matrix is dirty every frame. With
|
||||||
two places, restoring the belt-and-suspenders pattern from `develop`:
|
`shadow.autoUpdate` alone, three.js can skip the shadow map re-render on a
|
||||||
|
frame where the matrix update has happened but the renderer's internal dirty
|
||||||
|
tracking does not pick it up. To prevent that, `Lighting.useFrame` sets
|
||||||
|
`sun.shadow.needsUpdate = true` after the per-frame matrix updates. Shadow
|
||||||
|
config is centralized in `src/data/world/lightingConfig.ts` (`bias=0`,
|
||||||
|
`normalBias=0`, `cameraSize=95`).
|
||||||
|
|
||||||
- After `configureSunShadow(...)` in the mount `useEffect`.
|
### Mount-time shadow map reallocation (`useShadowMapWarmup`)
|
||||||
- At the end of the `useFrame` block, right after `sun.updateMatrixWorld()`.
|
|
||||||
|
|
||||||
Mitigations also in place:
|
The merged static map and other GLTFs mount imperatively after `Lighting`,
|
||||||
|
so the shadow render target ends up linked to a renderer state that pre-dates
|
||||||
|
the final scene. Materials compiled at that point bake a "no shadow map"
|
||||||
|
permutation into their shader program and silently fail to render shadows
|
||||||
|
until a WebGL context-restore cycle (the kind triggered by Chrome DevTools
|
||||||
|
in `?debug` runs) reallocates everything.
|
||||||
|
|
||||||
- Shadow config centralized in `src/data/world/lightingConfig.ts`
|
`src/hooks/three/useShadowMapWarmup.ts` replays that cycle programmatically
|
||||||
(`bias=0`, `normalBias=0`, `cameraSize=95`).
|
without the cost of a full context loss. It runs a `useFrame` watchdog that
|
||||||
- Late-suspension Suspense boundaries in `World.tsx` to prevent global scene
|
samples the scene mesh count every 6 frames; once the count has been stable
|
||||||
remounts that would re-run shadow setup mid-load.
|
for ~1 s (or after a 5 s safety cap), it:
|
||||||
- `gl.shadowMap.needsUpdate = true` on `onCreated` and on
|
|
||||||
`webglcontextrestored` in `src/pages/page.tsx`.
|
|
||||||
|
|
||||||
If the issue reproduces, capture `[diag]`-style logs from `useOctreeGraphNode`,
|
1. Disposes the directional light shadow map and nulls it. three.js
|
||||||
`Lighting`, and `GameMapCollision` to confirm there is no extra configuration
|
reallocates the render target on the next render at the configured
|
||||||
pass (which would indicate a remaining suspending hook outside the existing
|
`mapSize`.
|
||||||
Suspense boundaries).
|
2. Marks every material's `needsUpdate = true`, forcing a shader recompile
|
||||||
|
that rebinds every program to the freshly created shadow sampler.
|
||||||
|
3. Forces a single shadow pass and invalidates the renderer.
|
||||||
|
|
||||||
|
The watchdog runs once per mount and adds a single traversal every 6 frames
|
||||||
|
during the warmup window, after which it self-terminates.
|
||||||
|
|||||||
Binary file not shown.
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Box3, BufferAttribute, BufferGeometry } from "three";
|
import { Box3, BufferAttribute, BufferGeometry } from "three";
|
||||||
import type { Octree } from "three-stdlib";
|
import type { Octree } from "three-stdlib";
|
||||||
|
import {
|
||||||
|
LA_FABRIK_CENTER,
|
||||||
|
isInsideLaFabrikFootprint,
|
||||||
|
} from "@/data/world/laFabrikConfig";
|
||||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||||
|
|
||||||
interface DebugOctreeVisualizationProps {
|
interface DebugOctreeVisualizationProps {
|
||||||
@@ -18,8 +22,12 @@ interface CollectOptions {
|
|||||||
minDepth: number;
|
minDepth: number;
|
||||||
maxDepth: number;
|
maxDepth: number;
|
||||||
leavesOnly: boolean;
|
leavesOnly: boolean;
|
||||||
|
fabrikOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FABRIK_FILTER_PADDING = 1.5;
|
||||||
|
const FABRIK_FILTER_VERTICAL = 8;
|
||||||
|
|
||||||
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[1, 3],
|
[1, 3],
|
||||||
@@ -35,6 +43,24 @@ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
|||||||
[3, 7],
|
[3, 7],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function boxIntersectsFabrik(box: Box3): boolean {
|
||||||
|
if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false;
|
||||||
|
if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false;
|
||||||
|
|
||||||
|
// Sample box corners + center on XZ plane against the rotated fabrik footprint.
|
||||||
|
const samples: ReadonlyArray<readonly [number, number]> = [
|
||||||
|
[box.min.x, box.min.z],
|
||||||
|
[box.min.x, box.max.z],
|
||||||
|
[box.max.x, box.min.z],
|
||||||
|
[box.max.x, box.max.z],
|
||||||
|
[(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5],
|
||||||
|
];
|
||||||
|
for (const [x, z] of samples) {
|
||||||
|
if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function collectOctreeBoxes(
|
function collectOctreeBoxes(
|
||||||
node: Octree,
|
node: Octree,
|
||||||
options: CollectOptions,
|
options: CollectOptions,
|
||||||
@@ -47,8 +73,10 @@ function collectOctreeBoxes(
|
|||||||
const passesDepth = depth >= options.minDepth;
|
const passesDepth = depth >= options.minDepth;
|
||||||
const passesLeafFilter = !options.leavesOnly || isLeaf;
|
const passesLeafFilter = !options.leavesOnly || isLeaf;
|
||||||
const hasTriangles = node.triangles.length > 0;
|
const hasTriangles = node.triangles.length > 0;
|
||||||
|
const passesFabrikFilter =
|
||||||
|
!options.fabrikOnly || boxIntersectsFabrik(node.box);
|
||||||
|
|
||||||
if (passesDepth && passesLeafFilter && hasTriangles) {
|
if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
|
||||||
acc.push({
|
acc.push({
|
||||||
box: node.box,
|
box: node.box,
|
||||||
depth,
|
depth,
|
||||||
@@ -114,6 +142,7 @@ export function DebugOctreeVisualization({
|
|||||||
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
||||||
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
|
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
|
||||||
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
|
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
|
||||||
|
const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly);
|
||||||
|
|
||||||
const geometry = useMemo(() => {
|
const geometry = useMemo(() => {
|
||||||
if (!octree || !showOctree) return null;
|
if (!octree || !showOctree) return null;
|
||||||
@@ -121,10 +150,11 @@ export function DebugOctreeVisualization({
|
|||||||
minDepth,
|
minDepth,
|
||||||
maxDepth,
|
maxDepth,
|
||||||
leavesOnly,
|
leavesOnly,
|
||||||
|
fabrikOnly,
|
||||||
});
|
});
|
||||||
if (boxes.length === 0) return null;
|
if (boxes.length === 0) return null;
|
||||||
return buildOctreeLineGeometry(boxes);
|
return buildOctreeLineGeometry(boxes);
|
||||||
}, [leavesOnly, maxDepth, minDepth, octree, showOctree]);
|
}, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
|
||||||
|
|
||||||
if (!geometry) return null;
|
if (!geometry) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -23,10 +23,14 @@ export const MAP_OCTREE_COLLISION_BOXES = {
|
|||||||
} as const satisfies Record<string, MapOctreeCollisionBox>;
|
} as const satisfies Record<string, MapOctreeCollisionBox>;
|
||||||
|
|
||||||
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
|
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
|
||||||
{
|
// NOTE: removed — this thin wall (size [0.2, 1.94, 3.71]) sat at x≈-6.93 and
|
||||||
center: [-6.9351, 2.278, -0.0001],
|
// sealed the doorway despite the geometry having a hole there. The fabrik
|
||||||
size: [0.2, 1.94, 3.711],
|
// mesh octree already provides the surrounding wall collision, so this
|
||||||
},
|
// proxy was both redundant and bug-causing.
|
||||||
|
// {
|
||||||
|
// center: [-6.9351, 2.278, -0.0001],
|
||||||
|
// size: [0.2, 1.94, 3.711],
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
center: [0.8026, 0.719, -3.639],
|
center: [0.8026, 0.719, -3.639],
|
||||||
size: [4.346, 1.108, 1.181],
|
size: [4.346, 1.108, 1.181],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function useDebugVisualsDebug(): void {
|
|||||||
octreeMaxDepth: state.octreeMaxDepth,
|
octreeMaxDepth: state.octreeMaxDepth,
|
||||||
octreeLeavesOnly: state.octreeLeavesOnly,
|
octreeLeavesOnly: state.octreeLeavesOnly,
|
||||||
octreeOpacity: state.octreeOpacity,
|
octreeOpacity: state.octreeOpacity,
|
||||||
|
octreeFabrikOnly: state.octreeFabrikOnly,
|
||||||
};
|
};
|
||||||
|
|
||||||
folder
|
folder
|
||||||
@@ -54,5 +55,12 @@ export function useDebugVisualsDebug(): void {
|
|||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
useDebugVisualsStore.getState().setOctreeOpacity(value);
|
useDebugVisualsStore.getState().setOctreeOpacity(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
folder
|
||||||
|
.add(controls, "octreeFabrikOnly")
|
||||||
|
.name("Octree Fabrik Only")
|
||||||
|
.onChange((value: boolean) => {
|
||||||
|
useDebugVisualsStore.getState().setOctreeFabrikOnly(value);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,29 +1,9 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { Mesh, type Object3D } from "three";
|
import { type Object3D } from "three";
|
||||||
import { Octree } from "three-stdlib";
|
import { Octree } from "three-stdlib";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
|
|
||||||
// [diag] temporary — count meshes/triangles captured in the octree graph node
|
|
||||||
function snapshotGraphNode(node: Object3D): {
|
|
||||||
meshCount: number;
|
|
||||||
triCount: number;
|
|
||||||
} {
|
|
||||||
let meshCount = 0;
|
|
||||||
let triCount = 0;
|
|
||||||
node.traverse((obj) => {
|
|
||||||
if (obj instanceof Mesh) {
|
|
||||||
meshCount += 1;
|
|
||||||
const geom = obj.geometry;
|
|
||||||
const idx = geom.index;
|
|
||||||
triCount += idx
|
|
||||||
? idx.count / 3
|
|
||||||
: (geom.attributes.position?.count ?? 0) / 3;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { meshCount, triCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOctreeGraphNode(
|
export function useOctreeGraphNode(
|
||||||
graphNodeRef: RefObject<Object3D | null>,
|
graphNodeRef: RefObject<Object3D | null>,
|
||||||
onOctreeReady: OctreeReadyHandler,
|
onOctreeReady: OctreeReadyHandler,
|
||||||
@@ -48,15 +28,6 @@ export function useOctreeGraphNode(
|
|||||||
const octree = new Octree();
|
const octree = new Octree();
|
||||||
octree.fromGraphNode(graphNode);
|
octree.fromGraphNode(graphNode);
|
||||||
|
|
||||||
// [diag] temporary — log octree contents to detect partial builds
|
|
||||||
const snapshot = snapshotGraphNode(graphNode);
|
|
||||||
console.log("[octree:build]", {
|
|
||||||
rebuildKey,
|
|
||||||
meshCount: snapshot.meshCount,
|
|
||||||
triCount: Math.round(snapshot.triCount),
|
|
||||||
timestamp: performance.now().toFixed(0),
|
|
||||||
});
|
|
||||||
|
|
||||||
onOctreeReady(octree);
|
onOctreeReady(octree);
|
||||||
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import {
|
||||||
|
Material,
|
||||||
|
Mesh,
|
||||||
|
type DirectionalLight,
|
||||||
|
type Scene,
|
||||||
|
type WebGLRenderer,
|
||||||
|
} from "three";
|
||||||
|
|
||||||
|
interface UseShadowMapWarmupOptions {
|
||||||
|
/** Light whose shadow map should be reallocated once the scene stabilizes. */
|
||||||
|
light: React.RefObject<DirectionalLight | null>;
|
||||||
|
scene: Scene;
|
||||||
|
gl: WebGLRenderer;
|
||||||
|
invalidate: () => void;
|
||||||
|
/** Frames the mesh count must remain unchanged to consider the scene stable. */
|
||||||
|
stableFramesThreshold?: number;
|
||||||
|
/** Hard cap on how long we keep watching, in frames (~5s @60fps). */
|
||||||
|
safetyCapFrames?: number;
|
||||||
|
/** Sample mesh count every N frames to keep the traversal cost negligible. */
|
||||||
|
sampleEveryFrames?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShadowMapWarmup({
|
||||||
|
light,
|
||||||
|
scene,
|
||||||
|
gl,
|
||||||
|
invalidate,
|
||||||
|
stableFramesThreshold = 60,
|
||||||
|
safetyCapFrames = 300,
|
||||||
|
sampleEveryFrames = 6,
|
||||||
|
}: UseShadowMapWarmupOptions): void {
|
||||||
|
const meshCountRef = useRef(0);
|
||||||
|
const stableFramesRef = useRef(0);
|
||||||
|
const watchFramesRef = useRef(0);
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (doneRef.current || !light.current) return;
|
||||||
|
|
||||||
|
watchFramesRef.current += 1;
|
||||||
|
|
||||||
|
if (watchFramesRef.current % sampleEveryFrames === 0) {
|
||||||
|
let meshCount = 0;
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (object instanceof Mesh) meshCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (meshCount !== meshCountRef.current) {
|
||||||
|
meshCountRef.current = meshCount;
|
||||||
|
stableFramesRef.current = 0;
|
||||||
|
} else {
|
||||||
|
stableFramesRef.current += sampleEveryFrames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stableEnough = stableFramesRef.current >= stableFramesThreshold;
|
||||||
|
const safetyCapReached = watchFramesRef.current >= safetyCapFrames;
|
||||||
|
if (!stableEnough && !safetyCapReached) return;
|
||||||
|
|
||||||
|
doneRef.current = true;
|
||||||
|
reallocateShadowMap(light.current);
|
||||||
|
invalidateAllMaterials(scene);
|
||||||
|
forceShadowPass(gl, scene, light.current);
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reallocateShadowMap(light: DirectionalLight): void {
|
||||||
|
const shadowMap = light.shadow.map;
|
||||||
|
if (!shadowMap) return;
|
||||||
|
|
||||||
|
shadowMap.dispose();
|
||||||
|
light.shadow.map = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateAllMaterials(scene: Scene): void {
|
||||||
|
const seen = new Set<Material>();
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (!(object instanceof Mesh)) return;
|
||||||
|
const materials = Array.isArray(object.material)
|
||||||
|
? object.material
|
||||||
|
: [object.material];
|
||||||
|
for (const material of materials) {
|
||||||
|
if (!material || seen.has(material)) continue;
|
||||||
|
seen.add(material);
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceShadowPass(
|
||||||
|
gl: WebGLRenderer,
|
||||||
|
scene: Scene,
|
||||||
|
light: DirectionalLight,
|
||||||
|
): void {
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
light.target.updateMatrixWorld(true);
|
||||||
|
light.updateMatrixWorld(true);
|
||||||
|
light.shadow.camera.updateMatrixWorld(true);
|
||||||
|
light.shadow.camera.updateProjectionMatrix();
|
||||||
|
light.shadow.needsUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ interface DebugVisualsStore {
|
|||||||
setOctreeLeavesOnly: (value: boolean) => void;
|
setOctreeLeavesOnly: (value: boolean) => void;
|
||||||
octreeOpacity: number;
|
octreeOpacity: number;
|
||||||
setOctreeOpacity: (value: number) => void;
|
setOctreeOpacity: (value: number) => void;
|
||||||
|
octreeFabrikOnly: boolean;
|
||||||
|
setOctreeFabrikOnly: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
|
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
|
||||||
@@ -28,4 +30,6 @@ export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
|
|||||||
setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }),
|
setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }),
|
||||||
octreeOpacity: 0.35,
|
octreeOpacity: 0.35,
|
||||||
setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }),
|
setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }),
|
||||||
|
octreeFabrikOnly: false,
|
||||||
|
setOctreeFabrikOnly: (octreeFabrikOnly) => set({ octreeFabrikOnly }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -272,37 +272,20 @@ function CollisionModelInstance({
|
|||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Strip the door slab from the la fabrik collision octree so the player
|
|
||||||
// can walk through the doorway. The visual model is rendered separately
|
|
||||||
// by MergedStaticMapModel and is unaffected.
|
|
||||||
if (node.name !== "lafabrik") return;
|
if (node.name !== "lafabrik") return;
|
||||||
|
|
||||||
// Strip the door slab (and any Blender-suffixed variant like `porte.001`,
|
|
||||||
// `porte_001`) from the la fabrik collision octree so the player can walk
|
|
||||||
// through the doorway. The visual model is rendered separately by
|
|
||||||
// MergedStaticMapModel and is unaffected. We exclude unrelated names like
|
|
||||||
// `porte stock` (a shelf of stocked doors) by requiring an exact match or
|
|
||||||
// a numeric suffix only.
|
|
||||||
const isDoorSlab = (name: string): boolean =>
|
const isDoorSlab = (name: string): boolean =>
|
||||||
name === "porte" || /^porte[._]\d+$/i.test(name);
|
name === "porte" || /^porte[._]\d+$/i.test(name);
|
||||||
|
const isDoorFrameThickenChild = (child: THREE.Object3D): boolean =>
|
||||||
|
child.parent?.name === "Thicken";
|
||||||
|
|
||||||
// [diag] temporary — collect all door-like candidate names to debug stripping
|
const doorMeshes: THREE.Object3D[] = [];
|
||||||
const candidates: string[] = [];
|
|
||||||
const removed: THREE.Object3D[] = [];
|
|
||||||
sceneInstance.traverse((child) => {
|
sceneInstance.traverse((child) => {
|
||||||
if (/porte/i.test(child.name)) {
|
if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) {
|
||||||
candidates.push(child.name);
|
doorMeshes.push(child);
|
||||||
}
|
|
||||||
if (isDoorSlab(child.name)) {
|
|
||||||
removed.push(child);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log("[lafabrik:porte-strip]", {
|
for (const child of doorMeshes) {
|
||||||
candidates,
|
|
||||||
strippedCount: removed.length,
|
|
||||||
strippedNames: removed.map((c) => c.name),
|
|
||||||
});
|
|
||||||
for (const child of removed) {
|
|
||||||
child.removeFromParent();
|
child.removeFromParent();
|
||||||
}
|
}
|
||||||
}, [node.name, sceneInstance]);
|
}, [node.name, sceneInstance]);
|
||||||
@@ -350,7 +333,6 @@ function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
|
|||||||
<boxGeometry args={box.size} />
|
<boxGeometry args={box.size} />
|
||||||
<meshBasicMaterial />
|
<meshBasicMaterial />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Octree ignores material.side, so rotate a second shell for X/Z collisions. */}
|
|
||||||
<mesh rotation={[0, Math.PI, 0]}>
|
<mesh rotation={[0, Math.PI, 0]}>
|
||||||
<boxGeometry args={box.size} />
|
<boxGeometry args={box.size} />
|
||||||
<meshBasicMaterial />
|
<meshBasicMaterial />
|
||||||
|
|||||||
@@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
+29
-85
@@ -1,12 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import {
|
import {
|
||||||
Mesh,
|
|
||||||
PCFShadowMap,
|
PCFShadowMap,
|
||||||
type AmbientLight,
|
type AmbientLight,
|
||||||
type DirectionalLight,
|
type DirectionalLight,
|
||||||
type Object3D,
|
type Object3D,
|
||||||
type Scene,
|
|
||||||
type WebGLRenderer,
|
type WebGLRenderer,
|
||||||
} from "three";
|
} from "three";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +27,7 @@ import {
|
|||||||
} from "@/data/world/lightingConfig";
|
} from "@/data/world/lightingConfig";
|
||||||
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup";
|
||||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||||
|
|
||||||
function configureRendererShadows(gl: WebGLRenderer): void {
|
function configureRendererShadows(gl: WebGLRenderer): void {
|
||||||
@@ -53,73 +52,41 @@ function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
|
|||||||
sun.shadow.camera.updateProjectionMatrix();
|
sun.shadow.camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
// [diag] temporary helper: count shadow-casting/receiving meshes in the scene
|
function placeSunRelativeToCamera(
|
||||||
function snapshotShadowMeshes(scene: Scene): {
|
sun: DirectionalLight,
|
||||||
meshCount: number;
|
sunTarget: Object3D,
|
||||||
castShadowCount: number;
|
cameraPosition: { x: number; z: number },
|
||||||
receiveShadowCount: number;
|
): void {
|
||||||
} {
|
sunTarget.position.set(cameraPosition.x, 0, cameraPosition.z);
|
||||||
let meshCount = 0;
|
sun.position.set(
|
||||||
let castShadowCount = 0;
|
cameraPosition.x + LIGHTING_STATE.sunX,
|
||||||
let receiveShadowCount = 0;
|
LIGHTING_STATE.sunY,
|
||||||
scene.traverse((obj) => {
|
cameraPosition.z + LIGHTING_STATE.sunZ,
|
||||||
if (obj instanceof Mesh) {
|
);
|
||||||
meshCount += 1;
|
|
||||||
if (obj.castShadow) castShadowCount += 1;
|
|
||||||
if (obj.receiveShadow) receiveShadowCount += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { meshCount, castShadowCount, receiveShadowCount };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Lighting(): React.JSX.Element {
|
export function Lighting(): React.JSX.Element {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const gl = useThree((state) => state.gl);
|
const gl = useThree((state) => state.gl);
|
||||||
const scene = useThree((state) => state.scene);
|
const scene = useThree((state) => state.scene);
|
||||||
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
const ambient = useRef<AmbientLight>(null);
|
const ambient = useRef<AmbientLight>(null);
|
||||||
const sun = useRef<DirectionalLight>(null);
|
const sun = useRef<DirectionalLight>(null);
|
||||||
const sunTarget = useRef<Object3D>(null);
|
const sunTarget = useRef<Object3D>(null);
|
||||||
const lastDiagAtRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sun.current || !sunTarget.current) return;
|
if (!sun.current || !sunTarget.current) return;
|
||||||
|
|
||||||
configureSunShadow(sun.current, sunTarget.current);
|
|
||||||
configureRendererShadows(gl);
|
configureRendererShadows(gl);
|
||||||
sun.current.shadow.needsUpdate = true;
|
configureSunShadow(sun.current, sunTarget.current);
|
||||||
|
// Prime the sun + target onto the camera before the first shadow pass so
|
||||||
|
// the initial shadow frustum already covers the visible scene; without
|
||||||
|
// this, the first frame is rendered with the default (origin-centered)
|
||||||
|
// frustum and shadows can appear absent until the player moves.
|
||||||
|
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||||
|
}, [camera, gl]);
|
||||||
|
|
||||||
// [diag] one-shot scene snapshot to count shadow casters/receivers
|
useShadowMapWarmup({ light: sun, scene, gl, invalidate });
|
||||||
const counts = snapshotShadowMeshes(scene);
|
|
||||||
console.log("[shadow:mount]", {
|
|
||||||
shadowMapEnabled: gl.shadowMap.enabled,
|
|
||||||
shadowMapType: gl.shadowMap.type,
|
|
||||||
shadowAutoUpdate: gl.shadowMap.autoUpdate,
|
|
||||||
sunCastShadow: sun.current.castShadow,
|
|
||||||
hasShadowMap: !!sun.current.shadow.map,
|
|
||||||
...counts,
|
|
||||||
});
|
|
||||||
|
|
||||||
// [diag] temporary — track WebGL context loss/restore to correlate with shadow drops
|
|
||||||
const canvas = gl.domElement;
|
|
||||||
const handleContextLost = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
console.log("[ctx:lost]", { timestamp: performance.now().toFixed(0) });
|
|
||||||
};
|
|
||||||
const handleContextRestored = () => {
|
|
||||||
console.log("[ctx:restored]", {
|
|
||||||
timestamp: performance.now().toFixed(0),
|
|
||||||
});
|
|
||||||
if (sun.current) {
|
|
||||||
sun.current.shadow.needsUpdate = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
|
||||||
canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
|
||||||
return () => {
|
|
||||||
canvas.removeEventListener("webglcontextlost", handleContextLost);
|
|
||||||
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
|
|
||||||
};
|
|
||||||
}, [gl, scene]);
|
|
||||||
|
|
||||||
useDebugFolder("Lighting", (folder) => {
|
useDebugFolder("Lighting", (folder) => {
|
||||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||||
@@ -153,43 +120,20 @@ export function Lighting(): React.JSX.Element {
|
|||||||
.name("Sun Z");
|
.name("Sun Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
useFrame(() => {
|
||||||
if (ambient.current) {
|
if (ambient.current) {
|
||||||
ambient.current.color.set(LIGHTING_STATE.ambientColor);
|
ambient.current.color.set(LIGHTING_STATE.ambientColor);
|
||||||
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sun.current && sunTarget.current) {
|
if (!sun.current || !sunTarget.current) return;
|
||||||
sunTarget.current.position.set(camera.position.x, 0, camera.position.z);
|
|
||||||
sunTarget.current.updateMatrixWorld();
|
|
||||||
sun.current.position.set(
|
|
||||||
camera.position.x + LIGHTING_STATE.sunX,
|
|
||||||
LIGHTING_STATE.sunY,
|
|
||||||
camera.position.z + LIGHTING_STATE.sunZ,
|
|
||||||
);
|
|
||||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
|
||||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
|
||||||
sun.current.updateMatrixWorld();
|
|
||||||
sun.current.shadow.needsUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// [diag] periodic shadow pipeline check (every 2s)
|
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||||
const now = clock.getElapsedTime();
|
sunTarget.current.updateMatrixWorld();
|
||||||
if (now - lastDiagAtRef.current > 2 && sun.current) {
|
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||||
lastDiagAtRef.current = now;
|
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||||
console.log("[shadow:tick]", {
|
sun.current.updateMatrixWorld();
|
||||||
shadowMapEnabled: gl.shadowMap.enabled,
|
sun.current.shadow.needsUpdate = true;
|
||||||
shadowAutoUpdate: gl.shadowMap.autoUpdate,
|
|
||||||
sunCastShadow: sun.current.castShadow,
|
|
||||||
sunIntensity: sun.current.intensity,
|
|
||||||
hasShadowMapTexture: !!sun.current.shadow.map?.texture,
|
|
||||||
sunPos: sun.current.position.toArray().map((n) => Number(n.toFixed(2))),
|
|
||||||
targetPos: sunTarget.current?.position
|
|
||||||
.toArray()
|
|
||||||
.map((n) => Number(n.toFixed(2))),
|
|
||||||
renderCalls: gl.info.render.calls,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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