Compare commits
2 Commits
main
...
a3e8e732f1
| Author | SHA1 | Date | |
|---|---|---|---|
| a3e8e732f1 | |||
| d975aac018 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
@@ -33,13 +33,27 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
|||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
const { scene } = useGLTF(PYLON_MODEL_PATH);
|
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(() => {
|
useFrame(() => {
|
||||||
const group = groupRef.current;
|
const group = groupRef.current;
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
|
|
||||||
if (!isStraightening || straightenStartRef.current === null) {
|
if (!isStraightening || straightenStartRef.current === null) {
|
||||||
group.rotation.set(...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION));
|
group.rotation.set(
|
||||||
|
...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,19 +69,19 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const showUpright =
|
|
||||||
mainState !== "pylon" ||
|
|
||||||
step === "waiting" ||
|
|
||||||
step === "inspected" ||
|
|
||||||
step === "fragmented" ||
|
|
||||||
step === "scanning" ||
|
|
||||||
step === "repairing" ||
|
|
||||||
step === "reassembling" ||
|
|
||||||
step === "done" ||
|
|
||||||
step === "narrator-outro";
|
|
||||||
|
|
||||||
const isPylonInteractive = step === "arrived" || step === "npc-return";
|
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 => {
|
const beginStraighten = (): void => {
|
||||||
setIsStraightening(true);
|
setIsStraightening(true);
|
||||||
pylonStraighteningSignal.started = true;
|
pylonStraighteningSignal.started = true;
|
||||||
@@ -80,17 +94,19 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
|||||||
setIsStraightening(false);
|
setIsStraightening(false);
|
||||||
pylonStraighteningSignal.started = false;
|
pylonStraighteningSignal.started = false;
|
||||||
setCanMove(true);
|
setCanMove(true);
|
||||||
setMissionStep("pylon", "inspected");
|
setMissionStep("pylon", "waiting");
|
||||||
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
|
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (repairGameOwnsModel) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
ref={groupRef}
|
ref={groupRef}
|
||||||
position={PYLON_WORLD_POSITION}
|
position={PYLON_WORLD_POSITION}
|
||||||
rotation={PYLON_DOWNED_ROTATION}
|
rotation={PYLON_DOWNED_ROTATION}
|
||||||
>
|
>
|
||||||
<primitive object={scene.clone(true)} />
|
<primitive object={clonedScene} />
|
||||||
{isPylonInteractive ? (
|
{isPylonInteractive ? (
|
||||||
<InteractableObject
|
<InteractableObject
|
||||||
kind="trigger"
|
kind="trigger"
|
||||||
@@ -117,7 +133,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const m = await loadDialogueManifest();
|
const m = await loadDialogueManifest();
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
await playDialogueById(m, PYLON_NARRATIVE_DIALOGUES.demandeAide);
|
await playDialogueById(
|
||||||
|
m,
|
||||||
|
PYLON_NARRATIVE_DIALOGUES.demandeAide,
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
{ once: true },
|
{ once: true },
|
||||||
@@ -127,7 +146,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const manifest = await loadDialogueManifest();
|
const manifest = await loadDialogueManifest();
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
await playDialogueById(manifest, PYLON_NARRATIVE_DIALOGUES.demandeAide);
|
await playDialogueById(
|
||||||
|
manifest,
|
||||||
|
PYLON_NARRATIVE_DIALOGUES.demandeAide,
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
} else if (step === "npc-return" && !isStraightening) {
|
} else if (step === "npc-return" && !isStraightening) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
|
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
|
||||||
import { ZoneDetection } from "@/components/zone/ZoneDetection";
|
import { ZoneDetection } from "@/components/zone/ZoneDetection";
|
||||||
@@ -28,6 +29,16 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
|
|||||||
onComplete: () => completeMission("pylon"),
|
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 (mainState !== "pylon") return null;
|
||||||
|
|
||||||
if (step === "locked") {
|
if (step === "locked") {
|
||||||
|
|||||||
@@ -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));
|
||||||
return currentIndex;
|
} else {
|
||||||
|
setActivePartIndex(nextIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextIndex;
|
|
||||||
});
|
|
||||||
}, scanPartSeconds * 1000);
|
}, scanPartSeconds * 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
||||||
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
|
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
|
||||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||||
@@ -46,7 +47,9 @@ const DEFAULT_STATE: WorldSettingsState = {
|
|||||||
graphics: { ...GRAPHICS_DEFAULTS },
|
graphics: { ...GRAPHICS_DEFAULTS },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
export const useWorldSettingsStore = create<WorldSettingsStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
|
|
||||||
setClouds: (cloudsUpdate) =>
|
setClouds: (cloudsUpdate) =>
|
||||||
@@ -115,4 +118,12 @@ export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
resetToDefaults: () => set(DEFAULT_STATE),
|
resetToDefaults: () => set(DEFAULT_STATE),
|
||||||
}));
|
}),
|
||||||
|
{
|
||||||
|
name: "la-fabrik-world-settings",
|
||||||
|
// Persist only graphics settings (preset + options) — fog/wind/clouds
|
||||||
|
// reset to defaults on each session.
|
||||||
|
partialize: (state) => ({ graphics: state.graphics }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
+118
-5
@@ -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]);
|
||||||
@@ -168,7 +254,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 />;
|
||||||
@@ -187,6 +275,11 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HandTrackingProvider>
|
<HandTrackingProvider>
|
||||||
|
{showRepairScene && renderedMission !== null ? (
|
||||||
|
/* Isolated repair scene — no map, no player, no physics world.
|
||||||
|
Unmounting the main Canvas here frees the full GPU budget. */
|
||||||
|
<RepairGameScene mission={renderedMission} />
|
||||||
|
) : (
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [85, 60, 85], fov: 42 }}
|
camera={{ position: [85, 60, 85], fov: 42 }}
|
||||||
shadows={{ type: THREE.PCFShadowMap }}
|
shadows={{ type: THREE.PCFShadowMap }}
|
||||||
@@ -202,6 +295,26 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
<DebugPerf />
|
<DebugPerf />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFading ? <AppLoadingIndicator floating /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<GameUI />
|
<GameUI />
|
||||||
{dialogMessage ? (
|
{dialogMessage ? (
|
||||||
<DialogMessage
|
<DialogMessage
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
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 { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
||||||
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
||||||
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
||||||
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
||||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||||
import {
|
import { REPAIR_MISSION_TRIGGERS } from "@/data/gameplay/repairMissionAnchors";
|
||||||
REPAIR_MISSION_POSITION_ENTRIES,
|
|
||||||
REPAIR_MISSION_TRIGGERS,
|
|
||||||
} from "@/data/gameplay/repairMissionAnchors";
|
|
||||||
import {
|
import {
|
||||||
INTRO_STAGE_ANCHOR,
|
INTRO_STAGE_ANCHOR,
|
||||||
OUTRO_STAGE_ANCHOR,
|
OUTRO_STAGE_ANCHOR,
|
||||||
} from "@/data/gameplay/gameStageAnchors";
|
} from "@/data/gameplay/gameStageAnchors";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||||
import { isPylonNarrativeStep } from "@/types/gameplay/repairMission";
|
|
||||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||||
@@ -83,14 +79,18 @@ 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 pylonStep = useGameStore((state) => state.pylon.currentStep);
|
|
||||||
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
|
|
||||||
|
|
||||||
const pylonInNarrative =
|
|
||||||
mainState === "pylon" && isPylonNarrativeStep(pylonStep);
|
|
||||||
|
|
||||||
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 position={EBIKE_WORLD_POSITION} />
|
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||||
<PylonDownedPylon />
|
<PylonDownedPylon />
|
||||||
@@ -101,14 +101,13 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{mainState === "pylon" ? <PylonNarrativeFlow /> : null}
|
{mainState === "pylon" ? <PylonNarrativeFlow /> : null}
|
||||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
|
||||||
const position = getRepairMissionPosition(mission, anchors);
|
{/* RepairGame is NO LONGER rendered here. When a repair step becomes
|
||||||
if (!position) return null;
|
active, page.tsx unmounts this whole world and mounts the isolated
|
||||||
if (mission === "pylon" && pylonInNarrative) return null;
|
RepairGameScene instead, freeing all map/character VRAM. */}
|
||||||
return (
|
|
||||||
<RepairGame key={mission} mission={mission} position={position} />
|
{/* Trigger sphere that starts the ebike repair (locked → waiting).
|
||||||
);
|
The repair scene swap is then handled by useRepairGameStatus. */}
|
||||||
})}
|
|
||||||
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
||||||
<RepairMissionTrigger key={config.mission} config={config} />
|
<RepairMissionTrigger key={config.mission} config={config} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Suspense, useCallback, useEffect } from "react";
|
||||||
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
|
import { Physics } from "@react-three/rapier";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||||
|
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
// Isolated scene — no world offset, no terrain. The repair game runs
|
||||||
|
// fully centred in its own context so the heavy map never loads here.
|
||||||
|
const REPAIR_SCENE_POSITION: Vector3Tuple = [0, 0, 0];
|
||||||
|
|
||||||
|
// Background: very dark blue-grey to match Altera's night-time mood
|
||||||
|
const REPAIR_SCENE_BG = "#0b0d14";
|
||||||
|
|
||||||
|
// Lighting tuned to match the main world defaults from lightingConfig.ts
|
||||||
|
const AMBIENT_COLOR = "#dfe7d8";
|
||||||
|
const AMBIENT_INTENSITY = 0.9;
|
||||||
|
const SUN_COLOR = "#ffe2bf";
|
||||||
|
const SUN_INTENSITY = 2.2;
|
||||||
|
const SUN_POSITION: Vector3Tuple = [5, 8, 4];
|
||||||
|
|
||||||
|
// Mimic the first-person view from the main world:
|
||||||
|
// - PLAYER_EYE_HEIGHT = 1.75 → camera Y
|
||||||
|
// - Case floats at [0, 0.4, 1.8] (inspected) → [0, 1.05, 2.05] (repairing)
|
||||||
|
// - Look-at target averaged between those two states
|
||||||
|
const CAMERA_POSITION: Vector3Tuple = [5, 2, 2];
|
||||||
|
const CAMERA_LOOK_AT: Vector3Tuple = [0, 0.7, 1.9];
|
||||||
|
|
||||||
|
function RepairSceneCamera(): null {
|
||||||
|
const { camera } = useThree();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
camera.lookAt(...CAMERA_LOOK_AT);
|
||||||
|
}, [camera]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepairGameSceneProps {
|
||||||
|
mission: RepairMissionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepairGameScene({
|
||||||
|
mission,
|
||||||
|
}: RepairGameSceneProps): React.JSX.Element {
|
||||||
|
const handleCreated = useCallback(({ gl }: { gl: THREE.WebGLRenderer }) => {
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
|
||||||
|
|
||||||
|
const handleContextLost = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
logger.error("WebGL", "Repair scene context lost — attempting restore");
|
||||||
|
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextRestored = () => {
|
||||||
|
logger.info("WebGL", "Repair scene context restored");
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||||
|
canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: CAMERA_POSITION, fov: 42 }}
|
||||||
|
shadows={{ type: THREE.PCFShadowMap }}
|
||||||
|
gl={{
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
antialias: true,
|
||||||
|
stencil: false,
|
||||||
|
}}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
>
|
||||||
|
<color attach="background" args={[REPAIR_SCENE_BG]} />
|
||||||
|
|
||||||
|
<RepairSceneCamera />
|
||||||
|
|
||||||
|
{/* Lighting — mirrors the game world defaults */}
|
||||||
|
<ambientLight intensity={AMBIENT_INTENSITY} color={AMBIENT_COLOR} />
|
||||||
|
<directionalLight
|
||||||
|
position={SUN_POSITION}
|
||||||
|
intensity={SUN_INTENSITY}
|
||||||
|
color={SUN_COLOR}
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize-width={1024}
|
||||||
|
shadow-mapSize-height={1024}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{/* Physics is required: TriggerObject and GrabbableObject both use
|
||||||
|
RigidBody. The world is minimal — no octree, no character bodies. */}
|
||||||
|
<Physics>
|
||||||
|
<RepairGame
|
||||||
|
mission={mission}
|
||||||
|
position={REPAIR_SCENE_POSITION}
|
||||||
|
snapToTerrain={false}
|
||||||
|
/>
|
||||||
|
</Physics>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<DebugPerf />
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
+26
-4
@@ -1,9 +1,10 @@
|
|||||||
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 { 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";
|
||||||
@@ -53,10 +54,31 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
handleShadowWarmupStarted,
|
handleShadowWarmupStarted,
|
||||||
shouldWarmUpShadows,
|
shouldWarmUpShadows,
|
||||||
} = 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
|
||||||
|
// character on every prop change). If the player returns from a repair
|
||||||
|
// scene, savedPlayerPosition holds their world position; otherwise fall
|
||||||
|
// back to the default spawn from playerConfig.
|
||||||
|
const savedPlayerPosition = useRepairTransitionStore(
|
||||||
|
(s) => s.savedPlayerPosition,
|
||||||
|
);
|
||||||
|
const playerSpawnPositionRef = useRef(
|
||||||
|
savedPlayerPosition ??
|
||||||
|
(sceneMode === "game"
|
||||||
? PLAYER_SPAWN_POSITION_GAME
|
? PLAYER_SPAWN_POSITION_GAME
|
||||||
: PLAYER_SPAWN_POSITION_PHYSICS;
|
: 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