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'.
This commit is contained in:
Tom Boullay
2026-06-03 04:14:54 +02:00
parent acdcb5515b
commit fe30596a5a
3 changed files with 50 additions and 10 deletions
+4 -4
View File
@@ -366,10 +366,10 @@ export function Ebike({
return; return;
} }
if (mainState === "ebike" && ebikeStep === "inspected") { // Note: inspected -> fragmented is no longer driven by press-E.
setMissionStep("ebike", "fragmented"); // It auto-advances after the focus bubble's grow tween (see
return; // RepairGame, gated on BUBBLE_GROW_DURATION_SECONDS), so the
} // sphere visibly engulfs the bike before the explode animation.
const cameraOffset = new THREE.Vector3( const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position, ...EBIKE_CAMERA_TRANSFORM.position,
@@ -4,7 +4,12 @@ import * as THREE from "three";
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore"; import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
const BUBBLE_RADIUS_METERS = 10; 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_SHRINK_DURATION_SECONDS = 1.2;
const BUBBLE_COLOR = "#060814"; const BUBBLE_COLOR = "#060814";
const BUBBLE_OPACITY = 0.92; const BUBBLE_OPACITY = 0.92;
+40 -5
View File
@@ -9,6 +9,7 @@ import type {
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep"; import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject"; import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; 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 { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep"; import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence"; import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
@@ -118,7 +119,7 @@ export function RepairGame({
const focusCenterZ = snappedPosition[2]; const focusCenterZ = snappedPosition[2];
useEffect(() => { useEffect(() => {
const inFocusPhase = const inFocusPhase =
mainState === mission && shouldFocusBubbleBeActive(step); mainState === mission && shouldFocusBubbleBeActive(step, mission);
if (inFocusPhase) { if (inFocusPhase) {
useRepairFocusStore useRepairFocusStore
.getState() .getState()
@@ -130,6 +131,24 @@ export function RepairGame({
return undefined; return undefined;
}, [mainState, mission, step, focusCenterX, focusCenterY, focusCenterZ]); }, [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(() => { useEffect(() => {
if (mainState !== mission) return undefined; if (mainState !== mission) return undefined;
@@ -210,16 +229,24 @@ export function RepairGame({
onComplete={() => completeMission(mission)} onComplete={() => completeMission(mission)}
/> />
) : null} ) : 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") ? (
<RepairMissionCase <RepairMissionCase
config={config} config={config}
onPlaceholdersChange={setCasePlaceholders} onPlaceholdersChange={setCasePlaceholders}
onAnchorsChange={setCaseAnchors} onAnchorsChange={setCaseAnchors}
open={step === "repairing"} open={step === "repairing"}
zoomed={step === "repairing"} zoomed={step === "repairing"}
showFragmentationPrompt={readyForFragmentation} showFragmentationPrompt={
readyForFragmentation && mission !== "ebike"
}
onInteract={ onInteract={
readyForFragmentation readyForFragmentation && mission !== "ebike"
? () => setMissionStep(mission, "fragmented") ? () => setMissionStep(mission, "fragmented")
: undefined : undefined
} }
@@ -234,7 +261,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 { 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 ( return (
step === "fragmented" || step === "fragmented" ||
step === "scanning" || step === "scanning" ||