diff --git a/src/components/three/gameplay/RepairFocusBubble.tsx b/src/components/three/gameplay/RepairFocusBubble.tsx new file mode 100644 index 0000000..b98872d --- /dev/null +++ b/src/components/three/gameplay/RepairFocusBubble.tsx @@ -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(null); + const meshRef = useRef(null); + const decorRef = useRef(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 ( + + + + {/* Subtle grid floor visible only inside the bubble */} + + {/* Soft directional light for the repair model */} + + + + + ); +} diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index 615d1de..4516e87 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -25,6 +25,7 @@ import type { RepairScannedBrokenPart, } from "@/types/gameplay/repairMission"; import { useGameStore } from "@/managers/stores/useGameStore"; +import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import { toVector3Scale } from "@/utils/three/scale"; @@ -110,6 +111,25 @@ export function RepairGame({ }; }, [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(() => { if (mainState !== mission) return undefined; @@ -214,6 +234,15 @@ function shouldKeepRepairRuntimeState(step: MissionStep): boolean { 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[] { return [ ...new Set([ diff --git a/src/managers/stores/useRepairFocusStore.ts b/src/managers/stores/useRepairFocusStore.ts new file mode 100644 index 0000000..a9c11a2 --- /dev/null +++ b/src/managers/stores/useRepairFocusStore.ts @@ -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((set) => ({ + active: false, + center: [0, 0, 0], + setFocus: (active, center) => + set((state) => ({ + active, + center: center ?? state.center, + })), +})); diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx index 830575d..3b18fee 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -1,5 +1,6 @@ import { Ebike } from "@/components/ebike/Ebike"; import { InteractableObject } from "@/components/three/interaction/InteractableObject"; +import { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble"; import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon"; 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 { Vector3Tuple } from "@/types/three/three"; import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition"; -import { - 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}`; +import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig"; interface StageAnchorProps { color: string; @@ -119,6 +114,7 @@ export function GameStageContent(): React.JSX.Element { ))} {mainState === "outro" ? : null} + ); } diff --git a/src/world/debug/TestMap.tsx b/src/world/debug/TestMap.tsx index 7a124f6..7eba1c7 100644 --- a/src/world/debug/TestMap.tsx +++ b/src/world/debug/TestMap.tsx @@ -5,6 +5,7 @@ import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; import { Line } from "@react-three/drei"; import { Ebike } from "@/components/ebike/Ebike"; import { RepairGame } from "@/components/three/gameplay/RepairGame"; +import { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { AnimatedModel } from "@/components/three/models/AnimatedModel"; import { TriggerObject } from "@/components/three/interaction/TriggerObject"; @@ -248,6 +249,8 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { ))} + + {/* Dynamic Futuristic 3D GPS Dashboard Preview */}