- RepairGame: lift a single ExplodableModel mounted across fragmented
-> done so the model loads once, animates from its real original
positions, and never re-instantiates between phases. Eliminates the
position/rotation jumps and re-explosion that occurred when each
step instantiated its own model.
- ExplodableModel: expose splitSpeed prop so the explode/reassemble
lerp can be slowed down (REPAIR_FRAGMENT_SPLIT_SPEED = 1.8) for a
more deliberate visual where each node is seen leaving its origin.
- RepairScanSequence: drop its own ExplodableModel, receive parts
from the upstream shared instance. Logs the available part names
when broken-part nodes can't be matched so config drift is visible.
- RepairReassemblyStep: reduced to the completion particles + a
delayed onSettled callback. The collapse animation is now driven by
the shared ExplodableModel switching split=false at the reassembling
phase. After REPAIR_REASSEMBLY_HOLD_MS (1500ms) the upstream flow
auto-advances to done.
- RepairEbikeRepairTrigger: new minimal interactable for the ebike
repairing step. Replaces the heavier grabbable-parts UX (cercles,
ranger pieces) with a single 'Changez le refroidisseur' prompt that
advances directly to reassembling. Pylon/farm keep RepairRepairingStep.
- RepairCompletionStep: drop the duplicated RepairObjectModel; the
shared ExplodableModel renders the repaired model at done.
- RepairGame ebike-done: play narrateur_ebikerepare and call
completeMission on the audio's ended event (with REPAIR_DONE_DIALOGUE_FALLBACK_MS
fallback). Hands off to pylon without a Validate button.
- EbikeRepairNarrator: drop the done entry; RepairGame owns it now so
the audio's end event can drive the mission completion handoff.
- RepairGame: drop the window.ebikeParkedPosition livePosition logic.
Ebike movement is disabled during the repair flow so the static zone
position is the source of truth, fixing the floating-bike issue
observed in TestMap.
The ebike refroidisseur diagnostic line used to fire on the
'repairing' step via EbikeRepairNarrator, AFTER the scan sequence had
already raced through every part on a fixed timer. Visually the red
broken-part highlight appeared and disappeared before the player ever
heard which part was actually broken.
Now the scan sequence itself can carry per-node voice lines via a new
optional config field on each broken part. When the scan lands on a
part that has a voice line:
- the audio is played immediately (with its subtitle);
- the red broken-part highlight is on screen the entire time
(existing cumulative highlight behaviour from RepairScanSequence);
- the next-part advance is gated on the audio's event;
- a 15s ceiling fallback (and per-part fallback when manifest is
missing) keeps the flow from stalling if the audio never resolves.
Cancel paths (component unmount, mission switch, debug rewind) pause
the audio, clear the subtitle, and drop both the listener and
the fallback timer to avoid leaks or double-advances.
Changes:
- types/repairMission: new optional .
- data/repairMissions: ebike refroidisseur broken part now declares
.
- RepairScanSequence: per-part effect now branches on .
Default per-part timer is preserved for parts without an audio line
(incl. all pylon/farm broken parts and ebike's non-diagnostic parts).
- EbikeRepairNarrator: drop the 'repairing' entry from the step ->
dialogue map (the diagnostic now plays earlier, during scanning).
'fragmented' (scan hint) and 'done' (repaired voiceover) are
unchanged.
End result: player hears 'le refroidisseur a laché...' exactly while
the red sphere is pulsing on the cooling node, and the case opens for
the repairing step the moment the line ends.
The fragmented -> scanning transition used to fire on a blind
setTimeout of REPAIR_FRAGMENTATION_SEQUENCE_SECONDS regardless of
whether the explode lerp had actually finished. With the new sphere-
reveal flow this got out of sync (sphere grows for 2.5s before
fragmented even mounts), so the timer could fire too early or while
the parts were still flying out.
Now the ExplodedModel emits a single 'settled' callback when its
internal lerp converges on its target (1 = fully exploded, 0 = fully
reassembled). RepairGame listens for settled-at-1 on the fragmented
ExplodableModel and advances to scanning on that event.
The legacy timer is kept as a generous safety net
(REPAIR_FRAGMENTATION_SEQUENCE_SECONDS + 2 seconds) so that if the
model fails to load (no parts -> no settled event ever fires) the
flow can never get stuck on the fragmented step.
Changes:
- ExplodedModel.ts:
- new ExplodedModelOptions.onSettled: (settledAt: 0 | 1) => void
- track settledAtTarget to ensure the callback fires exactly once
per lerp (re-armed when setSplit() flips the target).
- ExplodableModel.tsx: new onSplitSettled prop, forwarded to the
underlying ExplodedModel via a stable useCallback that reads the
latest prop through a ref so the instance is not recreated mid-anim.
- RepairGame.tsx:
- wire onSplitSettled on the fragmented ExplodableModel to
setMissionStep(mission, 'scanning').
- keep the existing setTimeout but extend it as a fallback only.
Pylon and farm benefit from the same fix automatically since they
share the same RepairGame fragmented branch.
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'.
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 <Ebike> 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.
Adds a dark expanding sphere around the repair model when the player
enters the immersive repair phases (fragmented / scanning / repairing /
reassembling). The bubble grows from 0 to 10m using GSAP expo.out over
2.5s and reverses on focus end, visually isolating the player from the
surrounding map.
- New useRepairFocusStore tracks active state + world center.
- New RepairFocusBubble renders a BackSide sphere shell + a soft cocoon
decor pass (grid floor + directional light + ambient) inside.
- RepairGame drives setFocus from its lifecycle effect.
- Mounted in both GameStageContent and TestMap so behaviour matches in
the production scene and the physics test scene.
Also drops the now-unused EBIKE_CONFIG_KEY constant in
GameStageContent.tsx (leftover from a previous remount-key strategy).
- ExplodedModel.createParts now descends recursively through single
mesh-bearing wrapper nodes (e.g. Scene > Moto > Eclatement) until
reaching a node with multiple mesh-bearing children. Previously the
first wrapper was used as root, so models with extra Empty/group
parents fell back to flat leaf meshes lerping in local space.
- Add optional modelRotation field on RepairMissionConfig so fragmented
+ repairing models can match the world-space rotation of the source
inspection model (parked Ebike).
- Ebike mission now uses EBIKE_WORLD_ROTATION_Y/EBIKE_WORLD_SCALE
directly so the fragmented bike lines up with the parked bike.
The Physique test scene now mounts the real Ebike component for the
ebike repair zone, mirroring GameStageContent so the bike model and
its interactions (mount/dismount, parked position tracking) are
available when testing the repair flow.
RepairGame derives its live world position from
window.ebikeParkedPosition once the ebike mission leaves the
locked/waiting phase, so the repair sequence happens wherever the
player parked the bike rather than at the static zone anchor.
Render the exploded mission model during the repairing step so broken
nodes (e.g. ebike refroidisseur) stay visible in the world, and surface
their world positions to RepairRepairingStep so broken pieces spawn
from where they belong on the model rather than from a static offset.
- ExplodableModel: add hideNodeNames (mesh visibility off, restored on
unmount) and nodeAnchorNames + onNodeAnchorsChange (per-frame world
positions debounced via signature so React state updates only when
the values actually move).
- RepairGame: render ExplodableModel during 'repairing' with the broken
node names hidden + anchors forwarded; threads brokenAnchors state
to RepairRepairingStep.
- RepairScanSequence + RepairRepairingStep: propagate targetNodeName
through scanned and fallback broken-part lists.
- RepairRepairingStep: broken parts spawn at brokenAnchors[targetNodeName]
when set, falling back to legacy BROKEN_PART_START_OFFSETS otherwise.
When a replacement part with a caseLockGroup is grabbed, sibling parts
sharing the same group become non-interactable and ghosted (35% opacity)
until the held part is released. This implements the pylon cable choice
where the player picks either cable1 or cable2 (both valid) without
being able to grab both simultaneously.
- GrabbableObject: add disabled prop (skips interaction frame logic and
unmounts InteractableObject so it does not register with the manager)
and onGrabChange callback fired on press, release, hand grab, and hand
release. Force-releases when disabled becomes true mid-grab.
- SimpleModel: add opacity prop, traversed onto cloned mesh materials
(safe because cloneResources clones materials per instance).
- RepairObjectModel: forward ghosted prop as opacity 0.35.
- RepairRepairingStep: track heldPartByLockGroup and pass disabled +
ghosted to siblings of the currently held part.
- Ebike replacement parts: cooling core (correct, anchored at refroidisseur)
+ four distractors anchored at cabledroit/cablegauche/pucehaut/pucebas.
Removes the ad-hoc gant_l/talkie distractors in favor of consistent
case-anchored visuals.
- Pylon replacement parts: cable1 + cable2 (alternative correct, both with
caseLockGroup 'pylon-cable' for upcoming soft-lock) + refroidisseur and
two puce distractors anchored to packderelance.
- Farm replacement parts kept as-is (caseAnchor undefined falls back to
placeholder slot positions for backward compatibility).
- RepairGame threads anchors from RepairCaseModel through RepairMissionCase
to RepairRepairingStep; replacement-part initial position now resolves
to the anchor world position when caseAnchor is set, falling back to the
legacy slot index otherwise.
- RepairMissionConfig.requiredReplacementPartId (string) is replaced by
requiredReplacementPartIds (readonly string[]) so a mission can accept
several alternative correct parts (e.g. pylon will accept either cable).
- RepairMissionPartConfig gains optional caseAnchor (where the standalone
spawns inside packderelance), caseLockGroup (mutually exclusive parts),
and targetNodeName (snap onto a node of the broken model rather than a
placeholder slot in the case).
- RepairScannedBrokenPart gains targetNodeName so scan results can carry
this hint through to the repairing step.
- RepairRepairingStep validation logic (placed/wrong/feedback) now matches
any id in requiredReplacementPartIds. Existing data is migrated mechanically
(single-element arrays); part-level new fields are wired in subsequent
commits.
- Fix REPAIR_CASE_LID_NODE_NAME from "partiesup" to "partsup" (the actual
node name in packderelance.gltf), restoring the lid open/close animation
that was silently no-oping since introduction.
- Add REPAIR_CASE_PART_ANCHOR_NAMES (cabledroit, cablegauche, pucehaut,
pucebas, refroidisseur) and REPAIR_CASE_PART_ANCHOR_FALLBACKS for
case-local positions used when the GLTF lacks a node (refroidisseur).
- RepairCaseModel now resolves these anchor nodes on mount, hides existing
meshes underneath them, and creates lightweight Object3D placeholders for
missing names so the anchoring pipeline is uniform.
- Each frame, anchor world positions are converted to step-local space and
emitted via the new onAnchorsChange callback (debounced via signature like
placeholders). Consumers added in subsequent commits.