From fe30596a5a5ef22d0ef024b6ff67a5f6a40766b1 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 04:14:54 +0200 Subject: [PATCH] feat(ebike): auto-advance inspected -> fragmented after sphere reveal The ebike repair flow used to require the player to press E twice in 'inspected' to trigger fragmentation, which: - duplicated the entry interaction (waiting->inspected was already E) - gave no visual confirmation that the inspect step did anything - left the dark focus bubble hidden until *after* the explode happened Now the bubble's GSAP grow tween (expo.out, 2.5s) starts as soon as the mission enters 'inspected', visually engulfing the parked bike inside its dark cocoon before the explode animation kicks in. After the tween finishes the mission auto-advances to 'fragmented', which mounts the ExplodableModel at the same parked world position. Changes: - RepairFocusBubble: export BUBBLE_GROW_DURATION_SECONDS so the timer in RepairGame stays in sync with the actual tween duration. - RepairGame.shouldFocusBubbleBeActive(step, mission): ebike opens the bubble one phase earlier ('inspected'); pylon/farm keep their original 'fragmented'+ behaviour to avoid changing their UX. - RepairGame: add a setTimeout(BUBBLE_GROW_DURATION_SECONDS * 1000) scoped to ebike + 'inspected' that calls setMissionStep('fragmented'). - RepairGame: hide the RepairMissionCase + 'press to fragment' prompt during ebike's 'inspected' phase (auto-flow doesn't need them). Pylon/farm still see the case + prompt during their 'inspected'. - Ebike.handleInteract: drop the manual 'inspected -> fragmented' press-E branch (now dead). The 'waiting -> inspected' E entry is preserved as the single mission entry trigger. useRepairFragmentationInput stays wired for pylon/farm (and as a both-fists short-circuit for ebike) since its keyboardEnabled is false and it can only fire on a deliberate gesture during 'inspected'. --- src/components/ebike/Ebike.tsx | 8 ++-- .../three/gameplay/RepairFocusBubble.tsx | 7 ++- src/components/three/gameplay/RepairGame.tsx | 45 ++++++++++++++++--- 3 files changed, 50 insertions(+), 10 deletions(-) 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" ||