diff --git a/public/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json
index 2f30ef1..bd738f0 100644
--- a/public/sounds/dialogue/dialogues.json
+++ b/public/sounds/dialogue/dialogues.json
@@ -78,19 +78,19 @@
{
"id": "narrateur_coupureelec",
"voice": "narrateur",
- "audio": "/sounds/dialogue/narrateur_coupureélec.mp3",
+ "audio": "/sounds/dialogue/narrateur_coupure_elec.mp3",
"subtitleCueIndex": 9
},
{
"id": "narrateur_poteaueleccasse",
"voice": "narrateur",
- "audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3",
+ "audio": "/sounds/dialogue/narrateur_poteau_elec_casse.mp3",
"subtitleCueIndex": 10
},
{
"id": "narrateur_courantrepare",
"voice": "narrateur",
- "audio": "/sounds/dialogue/narrateur_courantréparé.mp3",
+ "audio": "/sounds/dialogue/narrateur_courant_repare.mp3",
"subtitleCueIndex": 11
},
{
@@ -165,6 +165,12 @@
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
"subtitleCueIndex": 23
},
+ {
+ "id": "narrateur_demande_aide",
+ "voice": "narrateur",
+ "audio": "/sounds/dialogue/narrateur_demande_aide.mp3",
+ "subtitleCueIndex": 24
+ },
{
"id": "fermier_coupdemain",
"voice": "fermier",
diff --git a/public/sounds/dialogue/narrateur_ordredemandedelaide.mp3 b/public/sounds/dialogue/narrateur_demande_aide.mp3
similarity index 100%
rename from public/sounds/dialogue/narrateur_ordredemandedelaide.mp3
rename to public/sounds/dialogue/narrateur_demande_aide.mp3
diff --git a/src/components/game/EbikeIntroSequence.tsx b/src/components/game/EbikeIntroSequence.tsx
index b5d0c90..93cc754 100644
--- a/src/components/game/EbikeIntroSequence.tsx
+++ b/src/components/game/EbikeIntroSequence.tsx
@@ -12,8 +12,10 @@ import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
export function EbikeIntroSequence(): React.JSX.Element | null {
+ const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const movementMode = useGameStore((state) => state.player.movementMode);
+ const pylonStep = useGameStore((state) => state.pylon.currentStep);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const completeIntro = useGameStore((state) => state.completeIntro);
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
@@ -100,6 +102,16 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
}
}, [introStep]);
+ if (mainState === "pylon") {
+ if (pylonStep === "approaching") {
+ return ;
+ }
+ if (pylonStep === "narrator-outro") {
+ return ;
+ }
+ return null;
+ }
+
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
return null;
}
diff --git a/src/components/gameplay/pylon/PylonDownedPylon.tsx b/src/components/gameplay/pylon/PylonDownedPylon.tsx
index 9cafb24..11458b4 100644
--- a/src/components/gameplay/pylon/PylonDownedPylon.tsx
+++ b/src/components/gameplay/pylon/PylonDownedPylon.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
@@ -14,6 +14,7 @@ import {
PYLON_UPRIGHT_ROTATION,
PYLON_WORLD_POSITION,
} from "@/data/gameplay/pylonConfig";
+import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
const PYLON_MODEL_PATH = "/models/pylone/model.gltf";
@@ -25,6 +26,11 @@ export function PylonDownedPylon(): React.JSX.Element | null {
const [isStraightening, setIsStraightening] = useState(false);
const groupRef = useRef(null);
const straightenStartRef = useRef(null);
+ const hasPlayedFirstAudioRef = useRef(false);
+
+ useEffect(() => {
+ if (step === "arrived") hasPlayedFirstAudioRef.current = false;
+ }, [step]);
const { scene } = useGLTF(PYLON_MODEL_PATH);
@@ -33,11 +39,7 @@ export function PylonDownedPylon(): React.JSX.Element | null {
if (!group) return;
if (!isStraightening || straightenStartRef.current === null) {
- const targetRotation =
- step === "narrator-outro"
- ? PYLON_UPRIGHT_ROTATION
- : PYLON_DOWNED_ROTATION;
- group.rotation.set(...targetRotation);
+ group.rotation.set(...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION));
return;
}
@@ -53,25 +55,22 @@ export function PylonDownedPylon(): React.JSX.Element | null {
);
});
- if (mainState !== "pylon") return null;
-
- if (
- step === "approaching" ||
+ const showUpright =
+ mainState !== "pylon" ||
step === "waiting" ||
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling" ||
- step === "done"
- ) {
- return null;
- }
+ step === "done" ||
+ step === "narrator-outro";
const isPylonInteractive = step === "arrived" || step === "npc-return";
const beginStraighten = (): void => {
setIsStraightening(true);
+ pylonStraighteningSignal.started = true;
straightenStartRef.current = performance.now();
setCanMove(false);
if (groupRef.current) {
@@ -79,8 +78,9 @@ export function PylonDownedPylon(): React.JSX.Element | null {
}
window.setTimeout(() => {
setIsStraightening(false);
+ pylonStraighteningSignal.started = false;
setCanMove(true);
- setMissionStep("pylon", "waiting");
+ setMissionStep("pylon", "inspected");
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
};
@@ -101,14 +101,35 @@ export function PylonDownedPylon(): React.JSX.Element | null {
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
onPress={() => {
if (step === "arrived") {
- void (async () => {
- const manifest = await loadDialogueManifest();
- if (!manifest) return;
- await playDialogueById(
- manifest,
- PYLON_NARRATIVE_DIALOGUES.brokenPylon,
- );
- })();
+ if (!hasPlayedFirstAudioRef.current) {
+ hasPlayedFirstAudioRef.current = true;
+ void (async () => {
+ const manifest = await loadDialogueManifest();
+ if (!manifest) return;
+ const audio = await playDialogueById(
+ manifest,
+ PYLON_NARRATIVE_DIALOGUES.brokenPylon,
+ );
+ if (!audio) return;
+ audio.addEventListener(
+ "ended",
+ () => {
+ void (async () => {
+ const m = await loadDialogueManifest();
+ if (!m) return;
+ await playDialogueById(m, PYLON_NARRATIVE_DIALOGUES.demandeAide);
+ })();
+ },
+ { once: true },
+ );
+ })();
+ } else {
+ void (async () => {
+ const manifest = await loadDialogueManifest();
+ if (!manifest) return;
+ await playDialogueById(manifest, PYLON_NARRATIVE_DIALOGUES.demandeAide);
+ })();
+ }
} else if (step === "npc-return" && !isStraightening) {
beginStraighten();
}
diff --git a/src/components/gameplay/pylon/PylonFarmerNPC.tsx b/src/components/gameplay/pylon/PylonFarmerNPC.tsx
index 9cf3ea9..5811469 100644
--- a/src/components/gameplay/pylon/PylonFarmerNPC.tsx
+++ b/src/components/gameplay/pylon/PylonFarmerNPC.tsx
@@ -7,51 +7,58 @@ import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import {
PYLON_FARMER_NPC_AFTER_POSITION,
+ PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight,
+ PYLON_FARMER_NPC_AFTER_ROTATION,
+ PYLON_FARMER_NPC_AFTER_SCALE,
PYLON_FARMER_NPC_POSITION,
+ PYLON_FARMER_NPC_WALK_SPEED,
PYLON_NARRATIVE_DIALOGUES,
PYLON_NARRATIVE_INTERACT_RADIUS,
} from "@/data/gameplay/pylonConfig";
+import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
+
+const _target = new THREE.Vector3();
export function PylonFarmerNPC(): React.JSX.Element | null {
const mainState = useGameStore((state) => state.mainState);
const step = useGameStore((state) => state.pylon.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep);
- const setCanMove = useGameStore((state) => state.setCanMove);
const groupRef = useRef(null);
+ const currentPosRef = useRef(
+ new THREE.Vector3(...PYLON_FARMER_NPC_POSITION),
+ );
+ // Reset position when entering arrived, set target when entering npc-return
useEffect(() => {
- if (mainState !== "pylon" || step !== "arrived") return;
+ if (step === "arrived") {
+ currentPosRef.current.set(...PYLON_FARMER_NPC_POSITION);
+ }
+ }, [step]);
- if (!groupRef.current) return;
- (groupRef.current.userData as Record).startTime =
- undefined;
- }, [mainState, step]);
-
- useFrame(() => {
+ useFrame((_, delta) => {
const group = groupRef.current;
if (!group) return;
- if (
- step === "npc-return" ||
- step === "waiting" ||
- step === "narrator-outro"
- ) {
- const startTime = (group.userData as Record)
- .startTime as number | undefined;
- if (startTime === undefined) {
- (group.userData as Record).startTime =
- performance.now();
- group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION);
- return;
- }
- group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION);
+ if (step === "npc-return") {
+ const targetPos = pylonStraighteningSignal.started
+ ? PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight
+ : PYLON_FARMER_NPC_AFTER_POSITION;
+ _target.set(...targetPos);
+ currentPosRef.current.lerp(_target, Math.min(PYLON_FARMER_NPC_WALK_SPEED * delta, 1));
+ group.position.copy(currentPosRef.current);
+ group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
+ group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
+ } else if (step === "inspected") {
+ group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
+ group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
+ group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
} else {
group.position.set(...PYLON_FARMER_NPC_POSITION);
}
});
if (mainState !== "pylon") return null;
- if (step !== "arrived") return null;
+ if (step !== "arrived" && step !== "npc-return" && step !== "inspected") return null;
return (
@@ -63,45 +70,42 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
- {
- setCanMove(false);
- void (async () => {
- const manifest = await loadDialogueManifest();
- if (!manifest) {
- setCanMove(true);
- setMissionStep("pylon", "npc-return");
- return;
- }
- const audio = await playDialogueById(
- manifest,
- PYLON_NARRATIVE_DIALOGUES.farmerHelp,
- );
- if (!audio) {
- setCanMove(true);
- setMissionStep("pylon", "npc-return");
- return;
- }
- audio.addEventListener(
- "ended",
- () => {
- setCanMove(true);
+
+ {step === "arrived" ? (
+ {
+ void (async () => {
+ const manifest = await loadDialogueManifest();
+ if (!manifest) {
setMissionStep("pylon", "npc-return");
- },
- { once: true },
- );
- })();
- }}
- >
-
-
-
-
-
+ return;
+ }
+ const audio = await playDialogueById(
+ manifest,
+ PYLON_NARRATIVE_DIALOGUES.farmerHelp,
+ );
+ if (!audio) {
+ setMissionStep("pylon", "npc-return");
+ return;
+ }
+ audio.addEventListener(
+ "ended",
+ () => setMissionStep("pylon", "npc-return"),
+ { once: true },
+ );
+ })();
+ }}
+ >
+
+
+
+
+
+ ) : null}
);
}
diff --git a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx
index c7258d9..218a47a 100644
--- a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx
+++ b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx
@@ -1,10 +1,9 @@
import { useGameStore } from "@/managers/stores/useGameStore";
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
import { ZoneDetection } from "@/components/zone/ZoneDetection";
-import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC";
import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro";
-import { 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";
export function PylonNarrativeFlow(): React.JSX.Element | null {
@@ -31,26 +30,28 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
if (mainState !== "pylon") return null;
+ if (step === "locked") {
+ return (
+ setMissionStep("pylon", "approaching")}
+ />
+ );
+ }
+
if (step === "approaching") {
return (
setMissionStep("pylon", "arrived")}
/>
);
}
- if (step === "arrived") {
- return (
- <>
-
-
- >
- );
- }
-
- if (step === "npc-return") {
- return ;
+ if (step === "arrived" || step === "npc-return" || step === "inspected") {
+ return ;
}
if (step === "narrator-outro") {
diff --git a/src/components/gameplay/pylon/pylonSignals.ts b/src/components/gameplay/pylon/pylonSignals.ts
new file mode 100644
index 0000000..eafa24d
--- /dev/null
+++ b/src/components/gameplay/pylon/pylonSignals.ts
@@ -0,0 +1,5 @@
+/**
+ * Shared runtime signal set by PylonDownedPylon when the straighten
+ * animation starts, so PylonFarmerNPC can switch its lerp target.
+ */
+export const pylonStraighteningSignal = { started: false };
diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx
index 49581ee..8c4e067 100644
--- a/src/components/zone/ZoneDetection.tsx
+++ b/src/components/zone/ZoneDetection.tsx
@@ -12,7 +12,7 @@ interface ZoneDetectionProps {
const _cameraPos = new THREE.Vector3();
-function ZoneDebugVisual({
+export function ZoneDebugVisual({
zone,
active,
}: {
diff --git a/src/data/gameplay/pylonConfig.ts b/src/data/gameplay/pylonConfig.ts
index 10e7204..c249947 100644
--- a/src/data/gameplay/pylonConfig.ts
+++ b/src/data/gameplay/pylonConfig.ts
@@ -2,7 +2,7 @@ import type { Vector3Tuple } from "@/types/three/three";
export const PYLON_WORLD_POSITION: Vector3Tuple = [43, 5, 45];
-export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -1.4];
+export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
@@ -13,11 +13,27 @@ export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
];
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
+ PYLON_WORLD_POSITION[0] + 3,
+ PYLON_WORLD_POSITION[1],
+ PYLON_WORLD_POSITION[2],
+];
+
+/** Position finale du PNJ quand le pylône se redresse */
+export const PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight: Vector3Tuple = [
PYLON_WORLD_POSITION[0] + 1,
PYLON_WORLD_POSITION[1],
- PYLON_WORLD_POSITION[2] - 2,
+ PYLON_WORLD_POSITION[2],
];
+/** Rotation (X Y Z radians) du PNJ une fois arrivé sous le pylône */
+export const PYLON_FARMER_NPC_AFTER_ROTATION: Vector3Tuple = [0, 0, 0];
+
+/** Scale uniforme du PNJ une fois arrivé sous le pylône */
+export const PYLON_FARMER_NPC_AFTER_SCALE = 1;
+
+/** Vitesse du lerp de déplacement du PNJ (unités/s) */
+export const PYLON_FARMER_NPC_WALK_SPEED = 2;
+
export const PYLON_NARRATIVE_INTERACT_RADIUS = 3.5;
export const PYLON_STRAIGHTEN_ANIMATION_DURATION_MS = 2200;
@@ -26,6 +42,7 @@ export const PYLON_NARRATIVE_DIALOGUES = {
electricOutage: "narrateur_coupureelec",
searchCentral: "narrateur_fouillelecentre",
brokenPylon: "narrateur_poteaueleccasse",
+ demandeAide: "narrateur_demande_aide",
farmerHelp: "fermier_coupdemain",
powerRestored: "narrateur_courantrepare",
} as const;
diff --git a/src/data/gameplay/repairMissionAnchors.ts b/src/data/gameplay/repairMissionAnchors.ts
index 373dec1..351fbf5 100644
--- a/src/data/gameplay/repairMissionAnchors.ts
+++ b/src/data/gameplay/repairMissionAnchors.ts
@@ -4,6 +4,7 @@ import type {
RepairMissionTriggerConfig,
} from "@/types/gameplay/repairMission";
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
+import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
Record
@@ -15,7 +16,7 @@ const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
const REPAIR_MISSION_POSITIONS = {
ebike: EBIKE_REPAIR_POSITION,
- pylon: [64, 0, -66],
+ pylon: PYLON_WORLD_POSITION,
farm: [-24, 0, 42],
} as const satisfies Record;
diff --git a/src/data/gameplay/repairMissions.ts b/src/data/gameplay/repairMissions.ts
index 3ba887f..c2f84b8 100644
--- a/src/data/gameplay/repairMissions.ts
+++ b/src/data/gameplay/repairMissions.ts
@@ -76,7 +76,7 @@ export const REPAIR_MISSIONS: Record = {
{
id: "pylon-damaged-panel",
label: "Damaged solar panel",
- nodeName: "panneau2",
+ nodeName: "pylone",
caseSlotName: "placeholder_2",
},
],
diff --git a/src/data/gameplay/zones.ts b/src/data/gameplay/zones.ts
index ca59adb..14675b5 100644
--- a/src/data/gameplay/zones.ts
+++ b/src/data/gameplay/zones.ts
@@ -4,11 +4,11 @@ import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
export const PYLON_APPROACH_ZONE: ZoneConfig = {
id: "pylon-approach",
position: [
- PYLON_WORLD_POSITION[0] - 20,
- PYLON_WORLD_POSITION[1],
- PYLON_WORLD_POSITION[2] - 5,
+ PYLON_WORLD_POSITION[0],
+ PYLON_WORLD_POSITION[1]- 5,
+ PYLON_WORLD_POSITION[2],
],
- radius: 12,
+ radius: 5,
height: 18,
oneShot: true,
};
@@ -16,11 +16,11 @@ export const PYLON_APPROACH_ZONE: ZoneConfig = {
export const PYLON_ARRIVED_ZONE: ZoneConfig = {
id: "pylon-arrived",
position: [
- PYLON_WORLD_POSITION[0] - 3,
- PYLON_WORLD_POSITION[1],
- PYLON_WORLD_POSITION[2] + 2,
+ PYLON_WORLD_POSITION[0] + 5,
+ PYLON_WORLD_POSITION[1] - 5,
+ PYLON_WORLD_POSITION[2] + 5,
],
- radius: 8,
+ radius: 5,
height: 15,
oneShot: true,
};
diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx
index 78162cc..3e755c1 100644
--- a/src/world/GameStageContent.tsx
+++ b/src/world/GameStageContent.tsx
@@ -1,7 +1,11 @@
import { Ebike } from "@/components/ebike/Ebike";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairGame } from "@/components/three/gameplay/RepairGame";
+import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
+import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
+import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
+import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
import {
REPAIR_MISSION_POSITION_ENTRIES,
REPAIR_MISSION_TRIGGERS,
@@ -89,6 +93,13 @@ export function GameStageContent(): React.JSX.Element {
<>
{mainState === "intro" ? : null}
+
+ {isDebugEnabled() ? (
+ <>
+
+
+ >
+ ) : null}
{mainState === "pylon" ? : null}
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors);