5 Commits

Author SHA1 Message Date
Tom Boullay 296c0b233a fix(pylon): start post-ebike delay in tampon state
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
2026-06-03 07:31:10 +02:00
Tom Boullay d8da88246d fix(assets): match packderelance texture casing
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
2026-06-03 07:24:23 +02:00
Tom Boullay 063ee20202 fix(pylon): delay approach sequence trigger 2026-06-03 07:23:30 +02:00
Tom Boullay 5968f0f67c fix(repair-ebike): gate scanning on scan intro dialogue
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
2026-06-03 07:04:44 +02:00
Tom Boullay a0482aa04b fix(repair-ebike): freeze repair transform and case-driven cooling swap 2026-06-03 07:00:16 +02:00
11 changed files with 205 additions and 56 deletions
Binary file not shown.
+6 -6
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { EBIKE_SCAN_HINT_DIALOGUE_ID } from "@/data/ebike/ebikeConfig";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { MissionStep } from "@/types/gameplay/repairMission"; import type { MissionStep } from "@/types/gameplay/repairMission";
@@ -7,8 +6,11 @@ import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue"; import { playDialogueById } from "@/utils/dialogues/playDialogue";
/** /**
* Plays narrator cues during the ebike repair game: * Previously played the ebike repair cues directly. `RepairGame` now
* - `fragmented` -> "Alors? Pas magnifique ça?... ces galets vont scanner..." * owns the repair-game cue timings that gate gameplay transitions
* (`fragmented` waits for `narrateur_galetscan`, `done` waits for
* `narrateur_ebikerepare`). This component remains as the central
* safety cleanup for legacy/queued ebike narrator audio.
* *
* The `narrateur_refroidisseur_diagnostic` line is triggered by the * The `narrateur_refroidisseur_diagnostic` line is triggered by the
* scan sequence itself when it lands on the refroidisseur node * scan sequence itself when it lands on the refroidisseur node
@@ -26,9 +28,7 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue";
* mission transition, etc.), the active audio is paused and the * mission transition, etc.), the active audio is paused and the
* subtitle is force-cleared so nothing bleeds into pylon/farm/outro. * subtitle is force-cleared so nothing bleeds into pylon/farm/outro.
*/ */
const STEP_TO_DIALOGUE_ID: Partial<Record<MissionStep, string>> = { const STEP_TO_DIALOGUE_ID: Partial<Record<MissionStep, string>> = {};
fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID,
};
function stopAudio(audio: HTMLAudioElement | null): void { function stopAudio(audio: HTMLAudioElement | null): void {
if (!audio) return; if (!audio) return;
@@ -5,7 +5,10 @@ import { ZoneDetection } from "@/components/zone/ZoneDetection";
import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC"; import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC";
import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro"; import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro";
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones"; import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
import { PYLON_NARRATIVE_DIALOGUES } from "@/data/gameplay/pylonConfig"; import {
PYLON_APPROACH_DELAY_MS,
PYLON_NARRATIVE_DIALOGUES,
} from "@/data/gameplay/pylonConfig";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue"; import { playDialogueById } from "@/utils/dialogues/playDialogue";
@@ -19,6 +22,18 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
const setMissionStep = useGameStore((state) => state.setMissionStep); const setMissionStep = useGameStore((state) => state.setMissionStep);
const setCanMove = useGameStore((state) => state.setCanMove); const setCanMove = useGameStore((state) => state.setCanMove);
useEffect(() => {
if (mainState !== "pylon" || step !== "tampon") return undefined;
const timeoutId = window.setTimeout(() => {
setMissionStep("pylon", "approaching");
}, PYLON_APPROACH_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [mainState, setMissionStep, step]);
// ── approaching : powerdown sfx → then electricOutage dialogue ──────────── // ── approaching : powerdown sfx → then electricOutage dialogue ────────────
useEffect(() => { useEffect(() => {
if (mainState !== "pylon" || step !== "approaching") return undefined; if (mainState !== "pylon" || step !== "approaching") return undefined;
@@ -129,7 +144,7 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
<ZoneDetection <ZoneDetection
key="pylon-approach" key="pylon-approach"
zone={PYLON_APPROACH_ZONE} zone={PYLON_APPROACH_ZONE}
onEnter={() => setMissionStep("pylon", "approaching")} onEnter={() => setMissionStep("pylon", "tampon")}
/> />
); );
} }
@@ -1,43 +1,32 @@
import { useState } from "react";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel"; import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
interface RepairEbikeRepairTriggerProps { interface RepairEbikeRepairTriggerProps {
anchor: Vector3Tuple; anchor: Vector3Tuple;
onRepair: () => void; installed: boolean;
} }
const REPLACEMENT_MODEL_PATH = "/models/refroidisseur/model.gltf"; const REPLACEMENT_MODEL_PATH = "/models/refroidisseur/model.gltf";
const TRIGGER_OFFSET: Vector3Tuple = [0, 0.9, 0];
/** /**
* Ebike-specific fake replacement flow: the broken radiator node is * Ebike-specific fake replacement flow: the broken radiator node is
* hidden in the shared ExplodableModel, a grabbable copy appears at the * hidden in the shared ExplodableModel, a grabbable copy appears at the
* same anchor, then pressing E respawns a fresh part with a halo before * same anchor, then RepairGame/RepairMissionCase controls the install
* the reassembly step starts. * interaction and this component swaps the copy for a fresh glowing part.
*/ */
export function RepairEbikeRepairTrigger({ export function RepairEbikeRepairTrigger({
anchor, anchor,
onRepair, installed,
}: RepairEbikeRepairTriggerProps): React.JSX.Element { }: RepairEbikeRepairTriggerProps): React.JSX.Element {
const [isInstalled, setIsInstalled] = useState(false);
function handleRepair(): void {
if (isInstalled) return;
setIsInstalled(true);
window.setTimeout(onRepair, 450);
}
return ( return (
<group> <group>
{!isInstalled ? ( {!installed ? (
<GrabbableObject <GrabbableObject
position={anchor} position={anchor}
colliders="ball" colliders="ball"
handControlled handControlled
lockUntilGrab
label="Retirer le refroidisseur" label="Retirer le refroidisseur"
> >
<RepairObjectModel <RepairObjectModel
@@ -63,23 +52,6 @@ export function RepairEbikeRepairTrigger({
</mesh> </mesh>
</group> </group>
)} )}
<TriggerObject
position={[
anchor[0] + TRIGGER_OFFSET[0],
anchor[1] + TRIGGER_OFFSET[1],
anchor[2] + TRIGGER_OFFSET[2],
]}
colliders="ball"
label="Changez le refroidisseur"
radius={REPAIR_INTERACTION_RADIUS}
onTrigger={handleRepair}
>
<mesh>
<sphereGeometry args={[0.55, 16, 16]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</TriggerObject>
</group> </group>
); );
} }
+150 -5
View File
@@ -23,7 +23,10 @@ import {
REPAIR_REASSEMBLY_HOLD_MS, REPAIR_REASSEMBLY_HOLD_MS,
} from "@/data/gameplay/repairGameConfig"; } from "@/data/gameplay/repairGameConfig";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions"; import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { EBIKE_REPAIRED_DIALOGUE_ID } from "@/data/ebike/ebikeConfig"; import {
EBIKE_REPAIRED_DIALOGUE_ID,
EBIKE_SCAN_HINT_DIALOGUE_ID,
} from "@/data/ebike/ebikeConfig";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput"; import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep"; import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
@@ -58,6 +61,11 @@ interface RepairMissionAssetPreloaderProps {
config: RepairMissionConfig; config: RepairMissionConfig;
} }
interface EbikeRepairTransform {
position: Vector3Tuple;
rotationY: number;
}
function RepairMissionAssetPreloader({ function RepairMissionAssetPreloader({
config, config,
}: RepairMissionAssetPreloaderProps): null { }: RepairMissionAssetPreloaderProps): null {
@@ -107,6 +115,11 @@ export function RepairGame({
const [explodedParts, setExplodedParts] = useState<readonly ExplodedPart[]>( const [explodedParts, setExplodedParts] = useState<readonly ExplodedPart[]>(
[], [],
); );
const [ebikeRepairTransform, setEbikeRepairTransform] =
useState<EbikeRepairTransform | null>(null);
const [ebikeCoolingInstalled, setEbikeCoolingInstalled] = useState(false);
const fragmentedSplitSettledRef = useRef(false);
const fragmentedDialogueDoneRef = useRef(false);
const reassemblyDoneTimeoutRef = useRef<number | null>(null); const reassemblyDoneTimeoutRef = useRef<number | null>(null);
// Ebike-specific: once the repair starts, keep the entire repair flow // Ebike-specific: once the repair starts, keep the entire repair flow
// exactly where the bike currently is. `Ebike` owns the live parked // exactly where the bike currently is. `Ebike` owns the live parked
@@ -115,11 +128,13 @@ export function RepairGame({
const livePosition = useMemo<Vector3Tuple>(() => { const livePosition = useMemo<Vector3Tuple>(() => {
if (mission !== "ebike" || step === "waiting") return position; if (mission !== "ebike" || step === "waiting") return position;
if (ebikeRepairTransform) return ebikeRepairTransform.position;
const parked = window.ebikeParkedPosition; const parked = window.ebikeParkedPosition;
if (!parked) return position; if (!parked) return position;
return [parked[0], parked[1], parked[2]]; return [parked[0], parked[1], parked[2]];
}, [mission, position, step]); }, [ebikeRepairTransform, mission, position, step]);
const usesLiveEbikePosition = mission === "ebike" && step !== "waiting"; const usesLiveEbikePosition = mission === "ebike" && step !== "waiting";
const parsedScale = toVector3Scale(scale); const parsedScale = toVector3Scale(scale);
const terrainSnappedPosition = useTerrainSnappedPosition(livePosition); const terrainSnappedPosition = useTerrainSnappedPosition(livePosition);
@@ -133,6 +148,10 @@ export function RepairGame({
); );
const isSplitPhase = (SPLIT_PHASES as readonly MissionStep[]).includes(step); const isSplitPhase = (SPLIT_PHASES as readonly MissionStep[]).includes(step);
const isRepairing = step === "repairing"; const isRepairing = step === "repairing";
const repairModelRotation: Vector3Tuple =
mission === "ebike" && ebikeRepairTransform
? [0, ebikeRepairTransform.rotationY, 0]
: (config.modelRotation ?? [0, 0, 0]);
const ebikeBrokenNodeName = config.brokenParts[0]?.targetNodeName; const ebikeBrokenNodeName = config.brokenParts[0]?.targetNodeName;
const ebikeBrokenWorldAnchor = ebikeBrokenNodeName const ebikeBrokenWorldAnchor = ebikeBrokenNodeName
? brokenAnchors[ebikeBrokenNodeName] ? brokenAnchors[ebikeBrokenNodeName]
@@ -159,6 +178,7 @@ export function RepairGame({
setCaseAnchors({}); setCaseAnchors({});
setBrokenAnchors({}); setBrokenAnchors({});
setScannedBrokenParts([]); setScannedBrokenParts([]);
setEbikeCoolingInstalled(false);
}, 0); }, 0);
return () => { return () => {
@@ -166,6 +186,45 @@ export function RepairGame({
}; };
}, [mainState, mission, step]); }, [mainState, mission, step]);
useEffect(() => {
if (mission !== "ebike") return undefined;
if (mainState !== "ebike" || step === "waiting") {
const timeoutId = window.setTimeout(() => {
setEbikeRepairTransform(null);
setEbikeCoolingInstalled(false);
}, 0);
return () => {
window.clearTimeout(timeoutId);
};
}
if (ebikeRepairTransform) return undefined;
const parked = window.ebikeParkedPosition;
const rotationY =
window.ebikeParkedRotation ?? config.modelRotation?.[1] ?? 0;
const snapshot: EbikeRepairTransform = {
position: parked ? [parked[0], parked[1], parked[2]] : position,
rotationY,
};
const timeoutId = window.setTimeout(() => {
setEbikeRepairTransform(snapshot);
}, 0);
return () => {
window.clearTimeout(timeoutId);
};
}, [
config.modelRotation,
ebikeRepairTransform,
mainState,
mission,
position,
step,
]);
useEffect(() => { useEffect(() => {
if (mission !== "ebike") return; if (mission !== "ebike") return;
if (mainState === "ebike") return; if (mainState === "ebike") return;
@@ -221,6 +280,7 @@ export function RepairGame({
useEffect(() => { useEffect(() => {
if (mainState !== mission) return undefined; if (mainState !== mission) return undefined;
if (step !== "fragmented") return undefined; if (step !== "fragmented") return undefined;
if (mission === "ebike") return undefined;
const timeoutId = window.setTimeout( const timeoutId = window.setTimeout(
() => { () => {
@@ -234,6 +294,71 @@ export function RepairGame({
}; };
}, [mainState, mission, setMissionStep, step]); }, [mainState, mission, setMissionStep, step]);
useEffect(() => {
if (mainState !== mission) return undefined;
if (mission !== "ebike") return undefined;
if (step !== "fragmented") return undefined;
fragmentedSplitSettledRef.current = false;
fragmentedDialogueDoneRef.current = false;
let cancelled = false;
let activeAudio: HTMLAudioElement | null = null;
let fallbackTimeoutId: number | null = null;
const tryAdvance = (): void => {
if (cancelled) return;
if (!fragmentedSplitSettledRef.current) return;
if (!fragmentedDialogueDoneRef.current) return;
setMissionStep(mission, "scanning");
};
const markDialogueDone = (): void => {
if (cancelled) return;
fragmentedDialogueDoneRef.current = true;
tryAdvance();
};
void (async () => {
const manifest = await loadDialogueManifest();
if (cancelled) return;
const audio = manifest
? await playDialogueById(manifest, EBIKE_SCAN_HINT_DIALOGUE_ID)
: null;
if (cancelled) {
if (audio && !audio.paused) {
audio.pause();
audio.currentTime = 0;
}
useSubtitleStore.getState().clearActiveSubtitle();
return;
}
activeAudio = audio;
if (audio) {
audio.addEventListener("ended", markDialogueDone, { once: true });
fallbackTimeoutId = window.setTimeout(markDialogueDone, 15000);
} else {
fallbackTimeoutId = window.setTimeout(markDialogueDone, 1000);
}
})();
return () => {
cancelled = true;
if (activeAudio) {
activeAudio.removeEventListener("ended", markDialogueDone);
if (!activeAudio.paused) {
activeAudio.pause();
activeAudio.currentTime = 0;
}
}
if (fallbackTimeoutId !== null) {
window.clearTimeout(fallbackTimeoutId);
}
useSubtitleStore.getState().clearActiveSubtitle();
};
}, [mainState, mission, setMissionStep, step]);
useEffect(() => { useEffect(() => {
if (mainState !== mission) return undefined; if (mainState !== mission) return undefined;
if (step !== "reassembling") return undefined; if (step !== "reassembling") return undefined;
@@ -327,6 +452,13 @@ export function RepairGame({
() => (settledAt: 0 | 1) => { () => (settledAt: 0 | 1) => {
const currentStep = stepRef.current; const currentStep = stepRef.current;
if (settledAt === 1 && currentStep === "fragmented") { if (settledAt === 1 && currentStep === "fragmented") {
if (mission === "ebike") {
fragmentedSplitSettledRef.current = true;
if (fragmentedDialogueDoneRef.current) {
setMissionStep(mission, "scanning");
}
return;
}
setMissionStep(mission, "scanning"); setMissionStep(mission, "scanning");
} }
if (settledAt === 0 && currentStep === "reassembling") { if (settledAt === 0 && currentStep === "reassembling") {
@@ -350,6 +482,14 @@ export function RepairGame({
}; };
}, []); }, []);
function handleEbikeCoolingInstall(): void {
if (ebikeCoolingInstalled) return;
setEbikeCoolingInstalled(true);
window.setTimeout(() => {
setMissionStep(mission, "reassembling");
}, 450);
}
if (mainState !== mission) return null; if (mainState !== mission) return null;
if (step === "locked") return null; if (step === "locked") return null;
@@ -376,7 +516,7 @@ export function RepairGame({
{isRepairPhase ? ( {isRepairPhase ? (
<ExplodableModel <ExplodableModel
modelPath={config.modelPath} modelPath={config.modelPath}
rotation={config.modelRotation ?? [0, 0, 0]} rotation={repairModelRotation}
scale={config.modelScale ?? 1} scale={config.modelScale ?? 1}
split={isSplitPhase} split={isSplitPhase}
splitSpeed={REPAIR_FRAGMENT_SPLIT_SPEED} splitSpeed={REPAIR_FRAGMENT_SPLIT_SPEED}
@@ -405,7 +545,7 @@ export function RepairGame({
{step === "repairing" && mission === "ebike" ? ( {step === "repairing" && mission === "ebike" ? (
<RepairEbikeRepairTrigger <RepairEbikeRepairTrigger
anchor={ebikeBrokenLocalAnchor} anchor={ebikeBrokenLocalAnchor}
onRepair={() => setMissionStep(mission, "reassembling")} installed={ebikeCoolingInstalled}
/> />
) : null} ) : null}
{step === "repairing" && mission !== "ebike" ? ( {step === "repairing" && mission !== "ebike" ? (
@@ -441,8 +581,13 @@ export function RepairGame({
showFragmentationPrompt={ showFragmentationPrompt={
readyForFragmentation && mission !== "ebike" readyForFragmentation && mission !== "ebike"
} }
{...(mission === "ebike" && step === "repairing"
? { interactLabel: "Changez le refroidisseur" }
: {})}
onInteract={ onInteract={
readyForFragmentation && mission !== "ebike" mission === "ebike" && step === "repairing"
? handleEbikeCoolingInstall
: readyForFragmentation && mission !== "ebike"
? () => setMissionStep(mission, "fragmented") ? () => setMissionStep(mission, "fragmented")
: undefined : undefined
} }
@@ -25,6 +25,7 @@ interface RepairMissionCaseProps {
open?: boolean; open?: boolean;
zoomed?: boolean; zoomed?: boolean;
showFragmentationPrompt?: boolean; showFragmentationPrompt?: boolean;
interactLabel?: string;
onInteract?: (() => void) | undefined; onInteract?: (() => void) | undefined;
} }
@@ -37,6 +38,7 @@ export function RepairMissionCase({
open = false, open = false,
zoomed = false, zoomed = false,
showFragmentationPrompt = false, showFragmentationPrompt = false,
interactLabel,
onInteract, onInteract,
}: RepairMissionCaseProps): React.JSX.Element { }: RepairMissionCaseProps): React.JSX.Element {
const casePosition = zoomed const casePosition = zoomed
@@ -51,7 +53,7 @@ export function RepairMissionCase({
<TriggerObject <TriggerObject
position={casePosition} position={casePosition}
colliders="ball" colliders="ball"
label={`Ouvrir ${config.label}`} label={interactLabel ?? `Ouvrir ${config.label}`}
radius={REPAIR_INTERACTION_RADIUS} radius={REPAIR_INTERACTION_RADIUS}
onTrigger={onInteract} onTrigger={onInteract}
> >
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody } from "@react-three/rapier"; import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier"; import type { RapierRigidBody } from "@react-three/rapier";
@@ -35,6 +35,7 @@ interface GrabbableObjectProps {
label?: string; label?: string;
handControlled?: boolean; handControlled?: boolean;
disabled?: boolean; disabled?: boolean;
lockUntilGrab?: boolean;
onGrabChange?: (held: boolean) => void; onGrabChange?: (held: boolean) => void;
onPositionChange?: (position: THREE.Vector3) => void; onPositionChange?: (position: THREE.Vector3) => void;
onSnap?: (position: THREE.Vector3) => void; onSnap?: (position: THREE.Vector3) => void;
@@ -134,6 +135,7 @@ export function GrabbableObject({
label = GRAB_DEFAULT_LABEL, label = GRAB_DEFAULT_LABEL,
handControlled = false, handControlled = false,
disabled = false, disabled = false,
lockUntilGrab = false,
onGrabChange, onGrabChange,
onPositionChange, onPositionChange,
onSnap, onSnap,
@@ -148,6 +150,7 @@ export function GrabbableObject({
const rbRef = useRef<RapierRigidBody>(null); const rbRef = useRef<RapierRigidBody>(null);
const isHolding = useRef(false); const isHolding = useRef(false);
const isHandHolding = useRef(false); const isHandHolding = useRef(false);
const [hasBeenGrabbed, setHasBeenGrabbed] = useState(false);
const snapTween = useRef<gsap.core.Tween | null>(null); const snapTween = useRef<gsap.core.Tween | null>(null);
useEffect(() => { useEffect(() => {
@@ -288,6 +291,7 @@ export function GrabbableObject({
const hadHit = Boolean(hit); const hadHit = Boolean(hit);
if (hadHit) { if (hadHit) {
setHasBeenGrabbed(true);
isHandHolding.current = true; isHandHolding.current = true;
InteractionManager.getInstance().setHandHolding(true); InteractionManager.getInstance().setHandHolding(true);
onGrabChange?.(true); onGrabChange?.(true);
@@ -330,7 +334,7 @@ export function GrabbableObject({
<group ref={spaceRef}> <group ref={spaceRef}>
<RigidBody <RigidBody
ref={rbRef} ref={rbRef}
type="dynamic" type={lockUntilGrab && !hasBeenGrabbed ? "fixed" : "dynamic"}
colliders={colliders} colliders={colliders}
position={position} position={position}
> >
@@ -344,6 +348,7 @@ export function GrabbableObject({
position={position} position={position}
bodyRef={rbRef} bodyRef={rbRef}
onPress={() => { onPress={() => {
setHasBeenGrabbed(true);
isHolding.current = true; isHolding.current = true;
onGrabChange?.(true); onGrabChange?.(true);
}} }}
+2
View File
@@ -41,6 +41,8 @@ export const PYLON_NARRATIVE_INTERACT_RADIUS = 3.5;
export const PYLON_STRAIGHTEN_ANIMATION_DURATION_MS = 2200; export const PYLON_STRAIGHTEN_ANIMATION_DURATION_MS = 2200;
export const PYLON_APPROACH_DELAY_MS = 7500;
export const PYLON_NARRATIVE_DIALOGUES = { export const PYLON_NARRATIVE_DIALOGUES = {
electricOutage: "narrateur_coupureelec", electricOutage: "narrateur_coupureelec",
searchCentral: "narrateur_fouillelecentre", searchCentral: "narrateur_fouillelecentre",
+8 -2
View File
@@ -11,6 +11,7 @@ const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
export const MISSION_STEPS = [ export const MISSION_STEPS = [
"locked", "locked",
"electricienne_history", "electricienne_history",
"tampon",
"approaching", "approaching",
"arrived", "arrived",
"npc-return", "npc-return",
@@ -26,6 +27,7 @@ export const MISSION_STEPS = [
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS); const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
const PYLON_ONLY_MISSION_STEPS = new Set<MissionStep>([ const PYLON_ONLY_MISSION_STEPS = new Set<MissionStep>([
"tampon",
"approaching", "approaching",
"arrived", "arrived",
"npc-return", "npc-return",
@@ -61,9 +63,11 @@ export function getNextMissionStep(
): MissionStep { ): MissionStep {
switch (step) { switch (step) {
case "locked": case "locked":
return mission === "pylon" ? "approaching" : "waiting"; return mission === "pylon" ? "tampon" : "waiting";
case "electricienne_history": case "electricienne_history":
return "done"; return "done";
case "tampon":
return "approaching";
case "approaching": case "approaching":
return "arrived"; return "arrived";
case "arrived": case "arrived":
@@ -98,8 +102,10 @@ export function getPreviousMissionStep(
return "locked"; return "locked";
case "electricienne_history": case "electricienne_history":
return "locked"; return "locked";
case "approaching": case "tampon":
return "locked"; return "locked";
case "approaching":
return "tampon";
case "arrived": case "arrived":
return "approaching"; return "approaching";
case "npc-return": case "npc-return":
+1 -1
View File
@@ -146,7 +146,7 @@ function completeEbikeState(state: GameState): GameStateUpdate {
}, },
pylon: { pylon: {
...state.pylon, ...state.pylon,
currentStep: "approaching", currentStep: "tampon",
}, },
}; };
} }
+2
View File
@@ -99,6 +99,7 @@ export interface RepairMissionConfig {
export type MissionStep = export type MissionStep =
| "locked" | "locked"
| "tampon"
| "approaching" | "approaching"
| "arrived" | "arrived"
| "npc-return" | "npc-return"
@@ -113,6 +114,7 @@ export type MissionStep =
| "electricienne_history"; | "electricienne_history";
export const PYLON_NARRATIVE_STEPS = [ export const PYLON_NARRATIVE_STEPS = [
"tampon",
"approaching", "approaching",
"arrived", "arrived",
"npc-return", "npc-return",