Compare commits
5 Commits
08c10acd48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 296c0b233a | |||
| d8da88246d | |||
| 063ee20202 | |||
| 5968f0f67c | |||
| a0482aa04b |
Binary file not shown.
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +581,15 @@ 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"
|
||||||
? () => setMissionStep(mission, "fragmented")
|
? handleEbikeCoolingInstall
|
||||||
: undefined
|
: readyForFragmentation && mission !== "ebike"
|
||||||
|
? () => setMissionStep(mission, "fragmented")
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ function completeEbikeState(state: GameState): GameStateUpdate {
|
|||||||
},
|
},
|
||||||
pylon: {
|
pylon: {
|
||||||
...state.pylon,
|
...state.pylon,
|
||||||
currentStep: "approaching",
|
currentStep: "tampon",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user