Compare commits
2 Commits
develop
...
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 { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
@@ -33,13 +33,27 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
}, [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));
|
||||
group.rotation.set(
|
||||
...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION),
|
||||
);
|
||||
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";
|
||||
|
||||
// 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;
|
||||
@@ -80,17 +94,19 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
setIsStraightening(false);
|
||||
pylonStraighteningSignal.started = false;
|
||||
setCanMove(true);
|
||||
setMissionStep("pylon", "inspected");
|
||||
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={scene.clone(true)} />
|
||||
<primitive object={clonedScene} />
|
||||
{isPylonInteractive ? (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
@@ -117,7 +133,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
void (async () => {
|
||||
const m = await loadDialogueManifest();
|
||||
if (!m) return;
|
||||
await playDialogueById(m, PYLON_NARRATIVE_DIALOGUES.demandeAide);
|
||||
await playDialogueById(
|
||||
m,
|
||||
PYLON_NARRATIVE_DIALOGUES.demandeAide,
|
||||
);
|
||||
})();
|
||||
},
|
||||
{ once: true },
|
||||
@@ -127,7 +146,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) return;
|
||||
await playDialogueById(manifest, PYLON_NARRATIVE_DIALOGUES.demandeAide);
|
||||
await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.demandeAide,
|
||||
);
|
||||
})();
|
||||
}
|
||||
} else if (step === "npc-return" && !isStraightening) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
|
||||
import { ZoneDetection } from "@/components/zone/ZoneDetection";
|
||||
@@ -28,6 +29,16 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
|
||||
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") {
|
||||
|
||||
@@ -219,7 +219,11 @@ export function RepairCaseModel({
|
||||
parsedScale[2] * pop.current.scale,
|
||||
);
|
||||
|
||||
if (placeholderNodes.current.length > 0) {
|
||||
// Placeholders are only consumed when the case is open (repairing). While
|
||||
// floating (inspected/scanning) the case bobs every frame, so emitting here
|
||||
// would fire a React setState on every frame, re-rendering the whole
|
||||
// RepairGame subtree continuously. Only compute when not floating.
|
||||
if (!floating && placeholderNodes.current.length > 0) {
|
||||
const placeholders: RepairCasePlaceholder[] = [];
|
||||
placeholderNodes.current.forEach((child) => {
|
||||
child.getWorldPosition(placeholderPosition.current);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||
@@ -10,7 +9,9 @@ import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemb
|
||||
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import { getNextMissionStep } from "@/data/gameplay/repairMissionState";
|
||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||
@@ -30,6 +31,8 @@ interface RepairGameProps extends Required<
|
||||
mission: RepairMissionId;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: ModelTransformProps["scale"];
|
||||
/** Set to false in isolated scenes with no terrain (e.g. RepairGameScene). */
|
||||
snapToTerrain?: boolean;
|
||||
}
|
||||
|
||||
interface RepairMissionAssetPreloaderProps {
|
||||
@@ -54,6 +57,7 @@ export function RepairGame({
|
||||
position,
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
snapToTerrain = true,
|
||||
}: RepairGameProps): React.JSX.Element | null {
|
||||
const config = REPAIR_MISSIONS[mission];
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
@@ -67,7 +71,11 @@ export function RepairGame({
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const snappedPosition = useTerrainSnappedPosition(position);
|
||||
// useTerrainSnappedPosition must always be called (rules of hooks) but we
|
||||
// only use its result when snapToTerrain is true — in the isolated repair
|
||||
// scene there is no terrain, so we use the raw position directly.
|
||||
const snappedByTerrain = useTerrainSnappedPosition(position);
|
||||
const snappedPosition = snapToTerrain ? snappedByTerrain : position;
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
@@ -103,6 +111,24 @@ export function RepairGame({
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
// When "done" is reached: set pendingCompletion in the transition store.
|
||||
// useRepairGameStatus detects this and triggers the fade back to world.
|
||||
// page.tsx waits for the world to fully load, THEN executes the completion.
|
||||
// This ensures the player sees a loading screen rather than a black flash.
|
||||
useEffect(() => {
|
||||
if (mainState !== mission || step !== "done") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const nextStep = getNextMissionStep("done", mission);
|
||||
useRepairTransitionStore.getState().setPendingCompletion({
|
||||
mission,
|
||||
nextStep,
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [mainState, mission, step]);
|
||||
|
||||
if (mainState !== mission) return null;
|
||||
if (step === "locked") return null;
|
||||
|
||||
@@ -149,12 +175,9 @@ export function RepairGame({
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
/>
|
||||
) : null}
|
||||
{/* done step: auto-advance is handled by useEffect above — no manual
|
||||
case-closing interaction needed. Scene is intentionally empty
|
||||
for the 200ms before completeMission/setMissionStep fires. */}
|
||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
function getPreloadPaths(mission: RepairMissionId): string[] {
|
||||
const config = REPAIR_MISSIONS[mission];
|
||||
return [
|
||||
...new Set([
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
config.modelPath,
|
||||
...config.brokenParts.flatMap((p) => p.modelPath ?? []),
|
||||
...config.replacementParts.flatMap((p) => p.modelPath ?? []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
interface RepairGamePreloaderProps {
|
||||
mission: RepairMissionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires useGLTF.preload() for every asset used by a repair mission.
|
||||
* Renders nothing — pure background loading.
|
||||
*/
|
||||
export function RepairGamePreloader({
|
||||
mission,
|
||||
}: RepairGamePreloaderProps): null {
|
||||
useEffect(() => {
|
||||
for (const path of getPreloadPaths(mission)) {
|
||||
useGLTF.preload(path);
|
||||
}
|
||||
}, [mission]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -41,16 +41,16 @@ export function RepairScanSequence({
|
||||
useEffect(() => {
|
||||
if (parts.length === 0) return undefined;
|
||||
|
||||
// Do NOT call onComplete inside a setState updater — updaters run during
|
||||
// React's render phase, which would trigger a setState on RepairGame and
|
||||
// cause a "setState during render" error. Call it directly in the timeout.
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setActivePartIndex((currentIndex) => {
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= parts.length) {
|
||||
onComplete(getScannedBrokenParts(parts, config));
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
});
|
||||
const nextIndex = activePartIndex + 1;
|
||||
if (nextIndex >= parts.length) {
|
||||
onComplete(getScannedBrokenParts(parts, config));
|
||||
} else {
|
||||
setActivePartIndex(nextIndex);
|
||||
}
|
||||
}, scanPartSeconds * 1000);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,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 { persist } from "zustand/middleware";
|
||||
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
||||
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
|
||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||
@@ -46,7 +47,9 @@ const DEFAULT_STATE: WorldSettingsState = {
|
||||
graphics: { ...GRAPHICS_DEFAULTS },
|
||||
};
|
||||
|
||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||
export const useWorldSettingsStore = create<WorldSettingsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_STATE,
|
||||
|
||||
setClouds: (cloudsUpdate) =>
|
||||
@@ -115,4 +118,12 @@ export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||
})),
|
||||
|
||||
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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
+131
-18
@@ -15,25 +15,28 @@ import {
|
||||
} from "@/components/ui/intro";
|
||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
||||
import { useRepairGameStatus } from "@/hooks/gameplay/useRepairGameStatus";
|
||||
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { RepairGameScene } from "@/world/RepairGameScene";
|
||||
import { World } from "@/world/World";
|
||||
|
||||
const LOADING_TO_VIDEO_FADE_MS = 500;
|
||||
// Duration (ms) of each half of the repair scene cross-fade
|
||||
const REPAIR_FADE_MS = 250;
|
||||
|
||||
export function HomePage(): React.JSX.Element | null {
|
||||
const navigate = useNavigate();
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const farmStep = useGameStore((state) => state.farm.currentStep);
|
||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||
const graphicsPreset = useWorldSettingsStore(
|
||||
(state) => state.graphics.preset,
|
||||
@@ -48,9 +51,92 @@ export function HomePage(): React.JSX.Element | null {
|
||||
INITIAL_SCENE_LOADING_STATE,
|
||||
);
|
||||
const sceneReadyRef = useRef(false);
|
||||
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`;
|
||||
// Only trigger the transient loading indicator on mission-level transitions
|
||||
// (mainState) or graphics changes — not on every repair game sub-step.
|
||||
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}`;
|
||||
const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal);
|
||||
|
||||
// --- Repair scene swap ---------------------------------------------------
|
||||
const repairStatus = useRepairGameStatus();
|
||||
const pendingCompletion = useRepairTransitionStore(
|
||||
(s) => s.pendingCompletion,
|
||||
);
|
||||
const setPendingCompletion = useRepairTransitionStore(
|
||||
(s) => s.setPendingCompletion,
|
||||
);
|
||||
const setSavedPlayerPosition = useRepairTransitionStore(
|
||||
(s) => s.setSavedPlayerPosition,
|
||||
);
|
||||
|
||||
const [showRepairScene, setShowRepairScene] = useState(repairStatus.active);
|
||||
const [renderedMission, setRenderedMission] =
|
||||
useState<RepairMissionId | null>(
|
||||
repairStatus.active ? repairStatus.mission : null,
|
||||
);
|
||||
const [isFading, setIsFading] = useState(false);
|
||||
// True while the world is reloading after a repair scene (shows loading overlay).
|
||||
const [isPostRepairLoading, setIsPostRepairLoading] = useState(false);
|
||||
const lastRepairActiveRef = useRef(repairStatus.active);
|
||||
|
||||
useEffect(() => {
|
||||
if (repairStatus.active === lastRepairActiveRef.current) return;
|
||||
lastRepairActiveRef.current = repairStatus.active;
|
||||
|
||||
if (repairStatus.active) {
|
||||
// Entering repair scene — capture the player's current world position
|
||||
// so we can restore it when returning.
|
||||
const pos = (window as Window & { playerPos?: [number, number, number] })
|
||||
.playerPos;
|
||||
if (pos) setSavedPlayerPosition([pos[0], pos[1], pos[2]]);
|
||||
}
|
||||
|
||||
setIsFading(true);
|
||||
|
||||
const swapTimer = window.setTimeout(() => {
|
||||
setShowRepairScene(repairStatus.active);
|
||||
setRenderedMission(repairStatus.active ? repairStatus.mission : null);
|
||||
|
||||
if (!repairStatus.active) {
|
||||
// Returning from repair scene — reset loading state so the overlay
|
||||
// shows while the world reloads from scratch (new WebGL context).
|
||||
sceneReadyRef.current = false;
|
||||
setSceneLoadingState(INITIAL_SCENE_LOADING_STATE);
|
||||
setIsPostRepairLoading(true);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
setIsFading(false);
|
||||
}, 50);
|
||||
}, REPAIR_FADE_MS);
|
||||
|
||||
return () => window.clearTimeout(swapTimer);
|
||||
}, [repairStatus.active, repairStatus.mission, setSavedPlayerPosition]);
|
||||
|
||||
// Execute the pending repair completion once the world is fully loaded.
|
||||
useEffect(() => {
|
||||
if (!isPostRepairLoading) return;
|
||||
if (sceneLoadingState.status !== "ready") return;
|
||||
if (!pendingCompletion) return;
|
||||
|
||||
const { mission, nextStep } = pendingCompletion;
|
||||
const store = useGameStore.getState();
|
||||
|
||||
if (nextStep === "done") {
|
||||
store.completeMission(mission);
|
||||
} else {
|
||||
store.setMissionStep(mission, nextStep);
|
||||
}
|
||||
|
||||
setPendingCompletion(null);
|
||||
setIsPostRepairLoading(false);
|
||||
}, [
|
||||
isPostRepairLoading,
|
||||
sceneLoadingState.status,
|
||||
pendingCompletion,
|
||||
setPendingCompletion,
|
||||
]);
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
sceneReadyRef.current = sceneLoadingState.status === "ready";
|
||||
}, [sceneLoadingState.status]);
|
||||
@@ -168,7 +254,9 @@ export function HomePage(): React.JSX.Element | null {
|
||||
introStep === "fade-to-video" ||
|
||||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
|
||||
const showSceneLoadingOverlay =
|
||||
introStep === "loading-map" || introStep === "fade-to-video";
|
||||
introStep === "loading-map" ||
|
||||
introStep === "fade-to-video" ||
|
||||
isPostRepairLoading;
|
||||
|
||||
const renderIntroOverlay = () => {
|
||||
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
|
||||
@@ -187,21 +275,46 @@ export function HomePage(): React.JSX.Element | null {
|
||||
|
||||
return (
|
||||
<HandTrackingProvider>
|
||||
<Canvas
|
||||
camera={{ position: [85, 60, 85], fov: 42 }}
|
||||
shadows={{ type: THREE.PCFShadowMap }}
|
||||
gl={{
|
||||
powerPreference: "high-performance",
|
||||
antialias: true,
|
||||
stencil: false,
|
||||
{showRepairScene && renderedMission !== null ? (
|
||||
/* Isolated repair scene — no map, no player, no physics world.
|
||||
Unmounting the main Canvas here frees the full GPU budget. */
|
||||
<RepairGameScene mission={renderedMission} />
|
||||
) : (
|
||||
<Canvas
|
||||
camera={{ position: [85, 60, 85], fov: 42 }}
|
||||
shadows={{ type: THREE.PCFShadowMap }}
|
||||
gl={{
|
||||
powerPreference: "high-performance",
|
||||
antialias: true,
|
||||
stencil: false,
|
||||
}}
|
||||
onCreated={handleCanvasCreated}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
||||
<DebugPerf />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
)}
|
||||
|
||||
{/* Black fade overlay — covers the WebGL context swap.
|
||||
The AppLoadingIndicator lives INSIDE this div so it inherits the
|
||||
stacking context (z-index 60) and always paints above the black. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "#000",
|
||||
zIndex: 60,
|
||||
opacity: isFading ? 1 : 0,
|
||||
transition: `opacity ${REPAIR_FADE_MS}ms ease-in-out`,
|
||||
pointerEvents: isFading ? "all" : "none",
|
||||
}}
|
||||
onCreated={handleCanvasCreated}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
||||
<DebugPerf />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
{isFading ? <AppLoadingIndicator floating /> : null}
|
||||
</div>
|
||||
|
||||
<GameUI />
|
||||
{dialogMessage ? (
|
||||
<DialogMessage
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { Ebike } from "@/components/ebike/Ebike";
|
||||
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 { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
||||
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
||||
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
import {
|
||||
REPAIR_MISSION_POSITION_ENTRIES,
|
||||
REPAIR_MISSION_TRIGGERS,
|
||||
} from "@/data/gameplay/repairMissionAnchors";
|
||||
import { REPAIR_MISSION_TRIGGERS } from "@/data/gameplay/repairMissionAnchors";
|
||||
import {
|
||||
INTRO_STAGE_ANCHOR,
|
||||
OUTRO_STAGE_ANCHOR,
|
||||
} from "@/data/gameplay/gameStageAnchors";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||
import { isPylonNarrativeStep } from "@/types/gameplay/repairMission";
|
||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||
@@ -83,14 +79,18 @@ function RepairMissionTrigger({
|
||||
|
||||
export function GameStageContent(): React.JSX.Element {
|
||||
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 (
|
||||
<>
|
||||
{/* Pre-load the next mission's repair assets while the player is still
|
||||
in the world, so the isolated repair scene mounts instantly.
|
||||
Only load pylon during ebike (not before) to avoid holding a
|
||||
finished mission's textures in VRAM. */}
|
||||
{mainState === "intro" || mainState === "ebike" ? (
|
||||
<RepairGamePreloader mission="pylon" />
|
||||
) : null}
|
||||
{mainState === "pylon" ? <RepairGamePreloader mission="farm" /> : null}
|
||||
|
||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
<PylonDownedPylon />
|
||||
@@ -101,14 +101,13 @@ export function GameStageContent(): React.JSX.Element {
|
||||
</>
|
||||
) : null}
|
||||
{mainState === "pylon" ? <PylonNarrativeFlow /> : null}
|
||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||
const position = getRepairMissionPosition(mission, anchors);
|
||||
if (!position) return null;
|
||||
if (mission === "pylon" && pylonInNarrative) return null;
|
||||
return (
|
||||
<RepairGame key={mission} mission={mission} position={position} />
|
||||
);
|
||||
})}
|
||||
|
||||
{/* RepairGame is NO LONGER rendered here. When a repair step becomes
|
||||
active, page.tsx unmounts this whole world and mounts the isolated
|
||||
RepairGameScene instead, freeing all map/character VRAM. */}
|
||||
|
||||
{/* Trigger sphere that starts the ebike repair (locked → waiting).
|
||||
The repair scene swap is then handled by useRepairGameStatus. */}
|
||||
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
||||
<RepairMissionTrigger key={config.mission} config={config} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Suspense, useCallback, useEffect } from "react";
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { Physics } from "@react-three/rapier";
|
||||
import * as THREE from "three";
|
||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
// Isolated scene — no world offset, no terrain. The repair game runs
|
||||
// fully centred in its own context so the heavy map never loads here.
|
||||
const REPAIR_SCENE_POSITION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
// Background: very dark blue-grey to match Altera's night-time mood
|
||||
const REPAIR_SCENE_BG = "#0b0d14";
|
||||
|
||||
// Lighting tuned to match the main world defaults from lightingConfig.ts
|
||||
const AMBIENT_COLOR = "#dfe7d8";
|
||||
const AMBIENT_INTENSITY = 0.9;
|
||||
const SUN_COLOR = "#ffe2bf";
|
||||
const SUN_INTENSITY = 2.2;
|
||||
const SUN_POSITION: Vector3Tuple = [5, 8, 4];
|
||||
|
||||
// Mimic the first-person view from the main world:
|
||||
// - PLAYER_EYE_HEIGHT = 1.75 → camera Y
|
||||
// - Case floats at [0, 0.4, 1.8] (inspected) → [0, 1.05, 2.05] (repairing)
|
||||
// - Look-at target averaged between those two states
|
||||
const CAMERA_POSITION: Vector3Tuple = [5, 2, 2];
|
||||
const CAMERA_LOOK_AT: Vector3Tuple = [0, 0.7, 1.9];
|
||||
|
||||
function RepairSceneCamera(): null {
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
camera.lookAt(...CAMERA_LOOK_AT);
|
||||
}, [camera]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface RepairGameSceneProps {
|
||||
mission: RepairMissionId;
|
||||
}
|
||||
|
||||
export function RepairGameScene({
|
||||
mission,
|
||||
}: RepairGameSceneProps): React.JSX.Element {
|
||||
const handleCreated = useCallback(({ gl }: { gl: THREE.WebGLRenderer }) => {
|
||||
const canvas = gl.domElement;
|
||||
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
|
||||
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
logger.error("WebGL", "Repair scene context lost — attempting restore");
|
||||
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
|
||||
};
|
||||
|
||||
const handleContextRestored = () => {
|
||||
logger.info("WebGL", "Repair scene context restored");
|
||||
};
|
||||
|
||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||
canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: CAMERA_POSITION, fov: 42 }}
|
||||
shadows={{ type: THREE.PCFShadowMap }}
|
||||
gl={{
|
||||
powerPreference: "high-performance",
|
||||
antialias: true,
|
||||
stencil: false,
|
||||
}}
|
||||
onCreated={handleCreated}
|
||||
>
|
||||
<color attach="background" args={[REPAIR_SCENE_BG]} />
|
||||
|
||||
<RepairSceneCamera />
|
||||
|
||||
{/* Lighting — mirrors the game world defaults */}
|
||||
<ambientLight intensity={AMBIENT_INTENSITY} color={AMBIENT_COLOR} />
|
||||
<directionalLight
|
||||
position={SUN_POSITION}
|
||||
intensity={SUN_INTENSITY}
|
||||
color={SUN_COLOR}
|
||||
castShadow
|
||||
shadow-mapSize-width={1024}
|
||||
shadow-mapSize-height={1024}
|
||||
/>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{/* Physics is required: TriggerObject and GrabbableObject both use
|
||||
RigidBody. The world is minimal — no octree, no character bodies. */}
|
||||
<Physics>
|
||||
<RepairGame
|
||||
mission={mission}
|
||||
position={REPAIR_SCENE_POSITION}
|
||||
snapToTerrain={false}
|
||||
/>
|
||||
</Physics>
|
||||
</Suspense>
|
||||
|
||||
<DebugPerf />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
+27
-5
@@ -1,9 +1,10 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Suspense, useEffect, useRef } from "react";
|
||||
import { Physics } from "@react-three/rapier";
|
||||
import {
|
||||
PLAYER_SPAWN_POSITION_GAME,
|
||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||
@@ -53,10 +54,31 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
handleShadowWarmupStarted,
|
||||
shouldWarmUpShadows,
|
||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||
const playerSpawnPosition =
|
||||
sceneMode === "game"
|
||||
? PLAYER_SPAWN_POSITION_GAME
|
||||
: PLAYER_SPAWN_POSITION_PHYSICS;
|
||||
// Capture the spawn position once on mount via a ref so it never changes
|
||||
// mid-session (spawnPosition is reactive in Player and would re-spawn the
|
||||
// character on every prop change). If the player returns from a repair
|
||||
// scene, savedPlayerPosition holds their world position; otherwise fall
|
||||
// back to the default spawn from playerConfig.
|
||||
const savedPlayerPosition = useRepairTransitionStore(
|
||||
(s) => s.savedPlayerPosition,
|
||||
);
|
||||
const playerSpawnPositionRef = useRef(
|
||||
savedPlayerPosition ??
|
||||
(sceneMode === "game"
|
||||
? PLAYER_SPAWN_POSITION_GAME
|
||||
: PLAYER_SPAWN_POSITION_PHYSICS),
|
||||
);
|
||||
const playerSpawnPosition = playerSpawnPositionRef.current;
|
||||
|
||||
// Clear the saved position right after capturing it so the next world
|
||||
// mount uses the default spawn instead of the stale repair-exit position.
|
||||
useEffect(() => {
|
||||
if (savedPlayerPosition !== null) {
|
||||
useRepairTransitionStore.getState().setSavedPlayerPosition(null);
|
||||
}
|
||||
// Only on mount — intentionally no deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const showHandTrackingGloves =
|
||||
sceneMode === "physics" ||
|
||||
(status !== "idle" && usageStatus !== "inactive");
|
||||
|
||||
Reference in New Issue
Block a user