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);