From acdcb5515b4d4d25fe0b717006ec09a9e78f7a9e Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 04:02:32 +0200 Subject: [PATCH] refactor(ebike): drop redundant 'locked' substate, single entry trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ebike mission previously had two redundant entry-point sub-states ('locked' and 'waiting') that were behaviorally identical from the player's perspective: - both showed the same 'Lancer le Repair Game' prompt - both allowed the press-E handler in Ebike.tsx to jump to 'inspected' In addition, the locked state caused two latent bugs: - the static-map ebike node (GameMap) and the live component were rendered simultaneously at the same world position - a generic RepairMissionTrigger anchor sphere was rendered in parallel to Ebike's own InteractableObject (two triggers, same area) Changes: - useGameStore: ebike's initial currentStep + completeIntroState target is now 'waiting' (pylon/farm still init at 'locked' — they need it). - Ebike.tsx: drop dead === 'locked' branches in repairGameOwnsEbikeModel and the press-E handler. - EbikeRepairNarrator: only reset the played-set on 'waiting'. - RepairGame: drop 'locked' from the ebike livePosition guard. - REPAIR_MISSION_TRIGGERS: empty array (the duplicate ebike anchor sphere is gone). Keep the array + RepairMissionTrigger component for future re-use. - GameMap: hide the static-map ebike node as soon as mainState === 'ebike' (was: only when ebikeStep !== 'locked'). - repairMissionState.getPreviousMissionStep: ebike rewinds from 'waiting' to 'waiting' (cap), pylon to 'npc-return', farm to 'locked'. The 'locked' value is intentionally kept in the MissionStep type union because the farm mission still uses it as a meaningful kickoff state driving FarmNarrativeFlow's auto-transition to electricienne_history. --- src/components/ebike/Ebike.tsx | 6 +----- src/components/game/EbikeRepairNarrator.tsx | 6 +++--- src/components/three/gameplay/RepairGame.tsx | 4 ++-- src/data/gameplay/repairMissionAnchors.ts | 15 ++++++++------- src/data/gameplay/repairMissionState.ts | 7 ++++++- src/managers/stores/useGameStore.ts | 4 ++-- src/world/GameMap.tsx | 8 +++++--- 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index ef323d3..929b1b0 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -73,7 +73,6 @@ export function Ebike({ const updateEbikeSounds = useEbikeSounds(); const repairGameOwnsEbikeModel = mainState === "ebike" && - ebikeStep !== "locked" && ebikeStep !== "waiting" && ebikeStep !== "inspected"; @@ -362,10 +361,7 @@ export function Ebike({ if (window.ebikeBreakdownActive === true) return; if (movementMode === "walk") { - if ( - mainState === "ebike" && - (ebikeStep === "locked" || ebikeStep === "waiting") - ) { + if (mainState === "ebike" && ebikeStep === "waiting") { setMissionStep("ebike", "inspected"); return; } diff --git a/src/components/game/EbikeRepairNarrator.tsx b/src/components/game/EbikeRepairNarrator.tsx index 477f108..cba4b5f 100644 --- a/src/components/game/EbikeRepairNarrator.tsx +++ b/src/components/game/EbikeRepairNarrator.tsx @@ -17,8 +17,8 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue"; * - `done` -> "Eeeet voilà! Il fonctionne comme une horloge!..." * * Each cue is one-shot per mission run; the played-set resets when the - * mission state rolls back to `locked`/`waiting` so debug-panel replays - * still trigger the narration. + * mission state rolls back to `waiting` so debug-panel replays still + * trigger the narration. * * Audio AND subtitles are strictly scoped to `mainState === "ebike"`. If * the player leaves the ebike main state mid-line (debug panel jump, @@ -46,7 +46,7 @@ export function EbikeRepairNarrator(): null { const activeAudioRef = useRef(null); useEffect(() => { - if (ebikeStep === "locked" || ebikeStep === "waiting") { + if (ebikeStep === "waiting") { playedRef.current.clear(); } }, [ebikeStep]); diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index ca5a6bd..c6ab500 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -74,13 +74,13 @@ export function RepairGame({ readonly RepairScannedBrokenPart[] >([]); // For the ebike mission, use the bike's live parked world position once - // the repair flow leaves the waiting/locked phase so the repair happens + // the repair flow leaves the waiting phase so the repair happens // wherever the player parked the bike, not at the static zone anchor. // window.ebikeParkedPosition is set by Ebike when the player drops the // bike and stays stable through the rest of the repair flow. const livePosition = useMemo(() => { if (mission !== "ebike" || mainState !== mission) return position; - if (step === "locked" || step === "waiting") return position; + if (step === "waiting") return position; const parked = window.ebikeParkedPosition; if (!parked) return position; return [parked[0], parked[1], parked[2]]; diff --git a/src/data/gameplay/repairMissionAnchors.ts b/src/data/gameplay/repairMissionAnchors.ts index 351fbf5..034e5a1 100644 --- a/src/data/gameplay/repairMissionAnchors.ts +++ b/src/data/gameplay/repairMissionAnchors.ts @@ -20,13 +20,14 @@ const REPAIR_MISSION_POSITIONS = { farm: [-24, 0, 42], } as const satisfies Record; -export const REPAIR_MISSION_TRIGGERS = [ - { - mission: "ebike", - label: "Réparer l'e-bike", - radius: 4, - }, -] as const satisfies readonly RepairMissionTriggerConfig[]; +// Currently empty: the ebike mission entry point is handled directly by +// `Ebike.tsx`'s own InteractableObject ("Lancer le Repair Game"), and the +// pylon/farm missions transition through their narrative flows +// (PylonNarrativeFlow / FarmNarrativeFlow). Keep the array typed so we +// can re-introduce a generic anchor trigger in the future without +// touching the consumer in `GameStageContent.tsx`. +export const REPAIR_MISSION_TRIGGERS: readonly RepairMissionTriggerConfig[] = + []; export const REPAIR_MISSION_POSITION_ENTRIES = Object.entries( REPAIR_MISSION_POSITIONS, diff --git a/src/data/gameplay/repairMissionState.ts b/src/data/gameplay/repairMissionState.ts index a9caf70..59745b5 100644 --- a/src/data/gameplay/repairMissionState.ts +++ b/src/data/gameplay/repairMissionState.ts @@ -105,7 +105,12 @@ export function getPreviousMissionStep( case "npc-return": return "arrived"; case "waiting": - return mission === "pylon" ? "npc-return" : "locked"; + // Ebike no longer has a "locked" entry state — its mission starts + // directly at "waiting". Pylon rewinds to its NPC return loop, farm + // rewinds to its narrative-driven locked kickoff. + if (mission === "pylon") return "npc-return"; + if (mission === "farm") return "locked"; + return "waiting"; case "inspected": return "waiting"; case "fragmented": diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index d898828..0584abe 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -131,7 +131,7 @@ function completeIntroState(state: GameState): GameStateUpdate { }, ebike: { ...state.ebike, - currentStep: "locked", + currentStep: "waiting", }, }; } @@ -265,7 +265,7 @@ function createInitialGameState(): GameState { isEbikeUnlocked: false, }, ebike: { - currentStep: "locked", + currentStep: "waiting", dialogueAudio: null, isRepaired: false, }, diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 3b437d5..dc58bdf 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -323,9 +323,11 @@ function MapNodeInstance({ }): React.JSX.Element | null { const isGeneratedModel = isGeneratedMapModelName(node.name); const mainState = useGameStore((state) => state.mainState); - const ebikeStep = useGameStore((state) => state.ebike.currentStep); - const hideEbikeMapModel = - node.name === "ebike" && mainState === "ebike" && ebikeStep !== "locked"; + // The static-map ebike node is replaced by the live `Ebike` component + // (rendered from GameStageContent) as soon as the ebike mission begins, + // so hide the static one to avoid a dual-render at the same world + // position. + const hideEbikeMapModel = node.name === "ebike" && mainState === "ebike"; useEffect(() => { if (modelUrl !== null || isGeneratedModel) return;