diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index 929b1b0..9a144d5 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -366,10 +366,10 @@ export function Ebike({ return; } - if (mainState === "ebike" && ebikeStep === "inspected") { - setMissionStep("ebike", "fragmented"); - return; - } + // Note: inspected -> fragmented is no longer driven by press-E. + // It auto-advances after the focus bubble's grow tween (see + // RepairGame, gated on BUBBLE_GROW_DURATION_SECONDS), so the + // sphere visibly engulfs the bike before the explode animation. const cameraOffset = new THREE.Vector3( ...EBIKE_CAMERA_TRANSFORM.position, diff --git a/src/components/three/gameplay/RepairFocusBubble.tsx b/src/components/three/gameplay/RepairFocusBubble.tsx index b98872d..16f8252 100644 --- a/src/components/three/gameplay/RepairFocusBubble.tsx +++ b/src/components/three/gameplay/RepairFocusBubble.tsx @@ -4,7 +4,12 @@ import * as THREE from "three"; import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore"; const BUBBLE_RADIUS_METERS = 10; -const BUBBLE_GROW_DURATION_SECONDS = 2.5; +/** + * Duration of the GSAP `expo.out` grow tween. Exported so step-driven + * code (e.g. `RepairGame` advancing inspected -> fragmented) can wait + * the same amount of time before triggering the next phase. + */ +export const BUBBLE_GROW_DURATION_SECONDS = 2.5; const BUBBLE_SHRINK_DURATION_SECONDS = 1.2; const BUBBLE_COLOR = "#060814"; const BUBBLE_OPACITY = 0.92; diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index c6ab500..ca0b0d4 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -9,6 +9,7 @@ import type { import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep"; import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; +import { BUBBLE_GROW_DURATION_SECONDS } from "@/components/three/gameplay/RepairFocusBubble"; import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep"; import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep"; import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence"; @@ -118,7 +119,7 @@ export function RepairGame({ const focusCenterZ = snappedPosition[2]; useEffect(() => { const inFocusPhase = - mainState === mission && shouldFocusBubbleBeActive(step); + mainState === mission && shouldFocusBubbleBeActive(step, mission); if (inFocusPhase) { useRepairFocusStore .getState() @@ -130,6 +131,24 @@ export function RepairGame({ return undefined; }, [mainState, mission, step, focusCenterX, focusCenterY, focusCenterZ]); + // Ebike-only: auto-advance inspected -> fragmented once the focus + // bubble's grow tween has finished isolating the bike inside the dark + // cocoon. The 2.5s delay matches BUBBLE_GROW_DURATION_SECONDS so the + // fragmentation visual coincides with the fully-formed shroud. + useEffect(() => { + if (mainState !== mission) return undefined; + if (mission !== "ebike") return undefined; + if (step !== "inspected") return undefined; + + const timeoutId = window.setTimeout(() => { + setMissionStep(mission, "fragmented"); + }, BUBBLE_GROW_DURATION_SECONDS * 1000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [mainState, mission, setMissionStep, step]); + useEffect(() => { if (mainState !== mission) return undefined; @@ -210,16 +229,24 @@ export function RepairGame({ onComplete={() => completeMission(mission)} /> ) : null} - {step !== "waiting" && step !== "done" && step !== "reassembling" ? ( + {step !== "waiting" && + step !== "done" && + step !== "reassembling" && + // Ebike's inspected phase is a 2.5s sphere-reveal cinematic that + // auto-advances to fragmented; the case + "press to fragment" + // prompt would only flash on screen, so suppress them here. + !(mission === "ebike" && step === "inspected") ? ( setMissionStep(mission, "fragmented") : undefined } @@ -234,7 +261,15 @@ function shouldKeepRepairRuntimeState(step: MissionStep): boolean { return step === "repairing" || step === "reassembling" || step === "done"; } -function shouldFocusBubbleBeActive(step: MissionStep): boolean { +function shouldFocusBubbleBeActive( + step: MissionStep, + mission: RepairMissionId, +): boolean { + // Ebike opens the focus bubble one phase earlier (inspected) so the + // sphere visibly engulfs the bike during the inspect-then-explode + // build-up. Pylon/farm keep their original behaviour where the bubble + // appears once the model has fragmented. + if (mission === "ebike" && step === "inspected") return true; return ( step === "fragmented" || step === "scanning" ||