feat(repair): introduce focus bubble shroud for repair mini-game

Adds a dark expanding sphere around the repair model when the player
enters the immersive repair phases (fragmented / scanning / repairing /
reassembling). The bubble grows from 0 to 10m using GSAP expo.out over
2.5s and reverses on focus end, visually isolating the player from the
surrounding map.

- New useRepairFocusStore tracks active state + world center.
- New RepairFocusBubble renders a BackSide sphere shell + a soft cocoon
  decor pass (grid floor + directional light + ambient) inside.
- RepairGame drives setFocus from its lifecycle effect.
- Mounted in both GameStageContent and TestMap so behaviour matches in
  the production scene and the physics test scene.

Also drops the now-unused EBIKE_CONFIG_KEY constant in
GameStageContent.tsx (leftover from a previous remount-key strategy).
This commit is contained in:
Tom Boullay
2026-06-02 22:57:18 +02:00
parent be5d03a30c
commit 220a661d6d
5 changed files with 193 additions and 7 deletions
@@ -0,0 +1,133 @@
import { useEffect, useMemo, useRef } from "react";
import gsap from "gsap";
import * as THREE from "three";
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
const BUBBLE_RADIUS_METERS = 10;
const BUBBLE_GROW_DURATION_SECONDS = 2.5;
const BUBBLE_SHRINK_DURATION_SECONDS = 1.2;
const BUBBLE_COLOR = "#060814";
const BUBBLE_OPACITY = 0.92;
const BUBBLE_SHELL_RADIUS = 1; // sphere geometry baked at radius=1, scale = radius
/**
* Dark sphere shroud rendered around the active repair model when the
* focus state is active. Grows from 0 -> BUBBLE_RADIUS_METERS using a
* GSAP `expo.out` ease so the player visually transitions from the open
* map to an isolated repair "cocoon". Reverses on focus end.
*
* The sphere uses BackSide rendering so the player remains inside the
* shroud when they stand near the repair model. A subtle decor pass
* (grid floor + soft directional light + light fog) is rendered as a
* sibling group so it appears once the bubble has expanded.
*/
export function RepairFocusBubble(): React.JSX.Element | null {
const active = useRepairFocusStore((state) => state.active);
const center = useRepairFocusStore((state) => state.center);
const groupRef = useRef<THREE.Group>(null);
const meshRef = useRef<THREE.Mesh>(null);
const decorRef = useRef<THREE.Group>(null);
const scaleRef = useRef({ value: 0.0001 });
const decorOpacityRef = useRef({ value: 0 });
const sphereGeometry = useMemo(
() => new THREE.SphereGeometry(BUBBLE_SHELL_RADIUS, 48, 32),
[],
);
const sphereMaterial = useMemo(
() =>
new THREE.MeshBasicMaterial({
color: BUBBLE_COLOR,
side: THREE.BackSide,
transparent: true,
opacity: BUBBLE_OPACITY,
depthWrite: false,
fog: false,
}),
[],
);
useEffect(() => {
return () => {
sphereGeometry.dispose();
sphereMaterial.dispose();
};
}, [sphereGeometry, sphereMaterial]);
useEffect(() => {
const targetScale = active ? BUBBLE_RADIUS_METERS : 0.0001;
const targetDecor = active ? 1 : 0;
const duration = active
? BUBBLE_GROW_DURATION_SECONDS
: BUBBLE_SHRINK_DURATION_SECONDS;
const scaleTween = gsap.to(scaleRef.current, {
value: targetScale,
duration,
ease: active ? "expo.out" : "expo.in",
onUpdate: () => {
const mesh = meshRef.current;
if (mesh) mesh.scale.setScalar(scaleRef.current.value);
},
});
const decorTween = gsap.to(decorOpacityRef.current, {
value: targetDecor,
duration: duration * 0.8,
delay: active ? duration * 0.4 : 0,
ease: "power2.inOut",
onUpdate: () => {
const decor = decorRef.current;
if (!decor) return;
decor.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material instanceof THREE.Material
) {
const material = child.material as THREE.Material & {
opacity?: number;
transparent?: boolean;
};
if (typeof material.opacity === "number") {
material.opacity = decorOpacityRef.current.value;
material.transparent = true;
}
}
});
},
});
return () => {
scaleTween.kill();
decorTween.kill();
};
}, [active]);
// Render even when inactive so the shrink tween can play out; visibility
// is implicit via near-zero scale.
return (
<group ref={groupRef} position={center}>
<mesh
ref={meshRef}
geometry={sphereGeometry}
material={sphereMaterial}
renderOrder={-1}
frustumCulled={false}
/>
<group ref={decorRef}>
{/* Subtle grid floor visible only inside the bubble */}
<gridHelper
args={[BUBBLE_RADIUS_METERS * 1.6, 24, "#1f2937", "#111827"]}
position={[0, -0.5, 0]}
/>
{/* Soft directional light for the repair model */}
<directionalLight
position={[2, 4, 3]}
intensity={0.6}
color="#cbd5f5"
/>
<ambientLight intensity={0.25} color="#1e293b" />
</group>
</group>
);
}
@@ -25,6 +25,7 @@ import type {
RepairScannedBrokenPart, RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission"; } from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale"; import { toVector3Scale } from "@/utils/three/scale";
@@ -110,6 +111,25 @@ export function RepairGame({
}; };
}, [mainState, mission, step]); }, [mainState, mission, step]);
// Drive the global focus bubble: active during the immersive repair
// phases so the world dims/hides outside the dark sphere shroud.
const focusCenterX = snappedPosition[0];
const focusCenterY = snappedPosition[1];
const focusCenterZ = snappedPosition[2];
useEffect(() => {
const inFocusPhase =
mainState === mission && shouldFocusBubbleBeActive(step);
if (inFocusPhase) {
useRepairFocusStore
.getState()
.setFocus(true, [focusCenterX, focusCenterY, focusCenterZ]);
return () => {
useRepairFocusStore.getState().setFocus(false);
};
}
return undefined;
}, [mainState, mission, step, focusCenterX, focusCenterY, focusCenterZ]);
useEffect(() => { useEffect(() => {
if (mainState !== mission) return undefined; if (mainState !== mission) return undefined;
@@ -214,6 +234,15 @@ function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
return step === "repairing" || step === "reassembling" || step === "done"; return step === "repairing" || step === "reassembling" || step === "done";
} }
function shouldFocusBubbleBeActive(step: MissionStep): boolean {
return (
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling"
);
}
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] { function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
return [ return [
...new Set([ ...new Set([
@@ -0,0 +1,25 @@
import { create } from "zustand";
import type { Vector3Tuple } from "@/types/three/three";
/**
* Tracks whether a repair mini-game is currently in its "focused" phase
* (fragmented / scanning / repairing / reassembling). When active, a dark
* sphere expands around the repair model to visually isolate the player
* from the rest of the map. The store also exposes the world-space center
* of the bubble so map content can dim/hide content outside it if needed.
*/
interface RepairFocusStore {
active: boolean;
center: Vector3Tuple;
setFocus: (active: boolean, center?: Vector3Tuple) => void;
}
export const useRepairFocusStore = create<RepairFocusStore>((set) => ({
active: false,
center: [0, 0, 0],
setFocus: (active, center) =>
set((state) => ({
active,
center: center ?? state.center,
})),
}));
+3 -7
View File
@@ -1,5 +1,6 @@
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 { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble";
import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { RepairGame } from "@/components/three/gameplay/RepairGame";
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";
@@ -20,13 +21,7 @@ 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";
import { import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
EBIKE_WORLD_POSITION,
EBIKE_WORLD_ROTATION_Y,
EBIKE_WORLD_SCALE,
} from "@/data/ebike/ebikeConfig";
const EBIKE_CONFIG_KEY = `${EBIKE_WORLD_POSITION.join(",")}:${EBIKE_WORLD_ROTATION_Y}:${EBIKE_WORLD_SCALE}`;
interface StageAnchorProps { interface StageAnchorProps {
color: string; color: string;
@@ -119,6 +114,7 @@ export function GameStageContent(): React.JSX.Element {
<RepairMissionTrigger key={config.mission} config={config} /> <RepairMissionTrigger key={config.mission} config={config} />
))} ))}
{mainState === "outro" ? <StageAnchor {...OUTRO_STAGE_ANCHOR} /> : null} {mainState === "outro" ? <StageAnchor {...OUTRO_STAGE_ANCHOR} /> : null}
<RepairFocusBubble />
</> </>
); );
} }
+3
View File
@@ -5,6 +5,7 @@ import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { Line } from "@react-three/drei"; import { Line } from "@react-three/drei";
import { Ebike } from "@/components/ebike/Ebike"; import { Ebike } from "@/components/ebike/Ebike";
import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel"; import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
@@ -248,6 +249,8 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
))} ))}
</Physics> </Physics>
<RepairFocusBubble />
{/* Dynamic Futuristic 3D GPS Dashboard Preview */} {/* Dynamic Futuristic 3D GPS Dashboard Preview */}
<group <group
position={TEST_SCENE_GPS_PREVIEW_POSITION} position={TEST_SCENE_GPS_PREVIEW_POSITION}