Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1037d5107 | |||
| 18fb5e39e9 | |||
| 0a3966a339 | |||
| eb5d4076d1 | |||
| 5177f43d96 | |||
| ff1ec56729 | |||
| cd0afcda8c | |||
| 813c10f3f7 |
+3
-3
@@ -39340,8 +39340,7 @@
|
||||
"rotation": [0, 0.0027, 0.0819],
|
||||
"scale": [1, 1, 1]
|
||||
}
|
||||
],
|
||||
"id": "repair:pylon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pylone",
|
||||
@@ -39373,7 +39372,8 @@
|
||||
"rotation": [0, 0.0027, 0.0819],
|
||||
"scale": [1, 1, 1]
|
||||
}
|
||||
]
|
||||
],
|
||||
"id": "repair:pylon"
|
||||
},
|
||||
{
|
||||
"name": "pylone",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
},
|
||||
{
|
||||
@@ -163,7 +163,13 @@
|
||||
"id": "narrateur_histoireelectricienne",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
|
||||
"subtitleCueIndex": 23
|
||||
"subtitleCueIndices": [23, 25, 26, 27, 28]
|
||||
},
|
||||
{
|
||||
"id": "narrateur_demande_aide",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_demande_aide.mp3",
|
||||
"subtitleCueIndex": 24
|
||||
},
|
||||
{
|
||||
"id": "fermier_coupdemain",
|
||||
@@ -182,6 +188,24 @@
|
||||
"voice": "fermier",
|
||||
"audio": "/sounds/dialogue/fermier_findemission.mp3",
|
||||
"subtitleCueIndex": 3
|
||||
},
|
||||
{
|
||||
"id": "electricienne_welcome",
|
||||
"voice": "electricienne",
|
||||
"audio": "/sounds/dialogue/electricienne_welcome.mp3",
|
||||
"subtitleCueIndex": 1
|
||||
},
|
||||
{
|
||||
"id": "electricienne_apresMontage",
|
||||
"voice": "electricienne",
|
||||
"audio": "/sounds/dialogue/electricienne_aprèsmontage.mp3",
|
||||
"subtitleCueIndex": 2
|
||||
},
|
||||
{
|
||||
"id": "electricienne_aurevoir",
|
||||
"voice": "electricienne",
|
||||
"audio": "/sounds/dialogue/electricienne_aurevoir.mp3",
|
||||
"subtitleCueIndex": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -87,5 +87,21 @@ Welcome to your workshop!! So? Pretty impressive, right? Okay, quick tour of wha
|
||||
Here, this is a dashboard. You can imagine that if your fridge or oven breaks down, you won't be able to put it in the pipe haha! So here, it tells you when residents have a bulky item that broke down, or when there's a problem in the city. Uh oh... I've got an emergency, I'll have to leave you soon! So here, take your tools to repair most things: a mini 3D printer powered by electronic waste, Push-Parts gloves to disassemble objects, and a Relaunch pack!
|
||||
|
||||
23
|
||||
00:00:00,000 --> 00:00:54,000
|
||||
The electrician helped you at the Power Plant? Aaaaah, that's what I love here: everyone helps each other, nobody judges anyone, it's like a real little family. You should know the electrician has quite a special story. She was born in the north of the continent, in the city of Kalska. She grew up happily with her mother Edith, her father Jordan, and her two little brothers, Malo and Justin. A few years ago, as you know, the northern countries were, quite unexpectedly, the first ones forced to migrate. So they began their journey, country by country, city by city, village by village. On a day of walking like so many others after several months, a climate storm caught them off guard. Having split up to find food in the village, her father and one of her two brothers sadly disappeared. It's tragic. But one day, they happened upon this place during their journey. We welcomed them with open arms, and they were slowly able to rebuild their lives among us. Today, they are an integral part of the community.
|
||||
00:00:00,000 --> 00:00:07,500
|
||||
The electrician helped you at the Power Plant? Aaaaah, that's what I love here: everyone helps each other, nobody judges anyone, it's like a real little family.
|
||||
|
||||
25
|
||||
00:00:07,500 --> 00:00:19,100
|
||||
You should know the electrician has quite a special story. She was born in the north of the continent, in the city of Kalska. She grew up happily with her mother Edith, her father Jordan, and her two little brothers, Malo and Justin.
|
||||
|
||||
26
|
||||
00:00:19,100 --> 00:00:30,600
|
||||
A few years ago, as you know, the northern countries were, quite unexpectedly, the first ones forced to migrate. So they began their journey, country by country, city by city, village by village.
|
||||
|
||||
27
|
||||
00:00:30,600 --> 00:00:42,800
|
||||
On a day of walking like so many others after several months, a climate storm caught them off guard. Having split up to find food in the village, her father and one of her two brothers sadly disappeared. It's tragic.
|
||||
|
||||
28
|
||||
00:00:42,800 --> 00:00:54,000
|
||||
But one day, they happened upon this place during their journey. We welcomed them with open arms, and they were slowly able to rebuild their lives among us. Today, they are an integral part of the community.
|
||||
|
||||
@@ -87,5 +87,21 @@ Bienvenue dans ton atelier !! Alors ? Ça claque hein ? Bon je te présente en r
|
||||
Ici, c'est un tableau de bord. T'imagines bien que si ton frigo ou ton four tombe en panne, tu ne vas pas pouvoir le mettre dans le tuyau haha ! Donc ici, ça te signale quand des résidents ont un objet volumineux tombé en panne, ou quand il y a un problème dans la ville. Oh oh... j'ai une urgence, il va bientôt falloir que je te laisse ! Donc tiens, tes outils pour pouvoir réparer la plupart des choses : une mini imprimante 3D à base de déchets électroniques, des gants Pousse Pièces pour désassembler les objets, ainsi qu'un pack de Relance !
|
||||
|
||||
23
|
||||
00:00:00,000 --> 00:00:54,000
|
||||
L'électricienne t'a aidé à la Centrale ? Aaaaah c'est ça que j'adore ici, tout le monde s'entraide, personne se juge, une vraie petite famille. Sache que l'électricienne a une histoire assez particulière. Elle est née au nord du continent, dans la ville de Kalska. Elle a grandit heureuse, avec sa mère Edith, son père Jordan et ses deux petits frères Malo et Justin. Il y a quelques années de ça, comme tu le sais, c'est les pays du Nord, qui par grande surprise, ont été obligés de migrer en premier. Ils ont alors entamé leur périple, pays par pays, ville par ville, village par village. Un jour de marche comme les autres depuis plusieurs mois, une tempête climatique les a pris de court. S'étant séparés pour trouver des vivres dans le village, le père et un des deux frères sont malheureusement partis. C'est tragique. Mais un beau jour, ils sont tombés ici, par hasard dans leur périple. On les a accueillis les bras ouverts et ils ont pu se reconstruire doucement parmi nous et font partie intégrante de la communauté aujourd'hui.
|
||||
00:00:00,000 --> 00:00:07,500
|
||||
L'électricienne t'a aidé à la Centrale ? Aaaaah c'est ça que j'adore ici, tout le monde s'entraide, personne se juge, une vraie petite famille.
|
||||
|
||||
25
|
||||
00:00:07,500 --> 00:00:19,100
|
||||
Sache que l'électricienne a une histoire assez particulière. Elle est née au nord du continent, dans la ville de Kalska. Elle a grandit heureuse, avec sa mère Edith, son père Jordan et ses deux petits frères Malo et Justin.
|
||||
|
||||
26
|
||||
00:00:19,100 --> 00:00:30,600
|
||||
Il y a quelques années de ça, comme tu le sais, c'est les pays du Nord, qui par grande surprise, ont été obligés de migrer en premier. Ils ont alors entamé leur périple, pays par pays, ville par ville, village par village.
|
||||
|
||||
27
|
||||
00:00:30,600 --> 00:00:42,800
|
||||
Un jour de marche comme les autres depuis plusieurs mois, une tempête climatique les a pris de court. S'étant séparés pour trouver des vivres dans le village, le père et un des deux frères sont malheureusement partis. C'est tragique.
|
||||
|
||||
28
|
||||
00:00:42,800 --> 00:00:54,000
|
||||
Mais un beau jour, ils sont tombés ici, par hasard dans leur périple. On les a accueillis les bras ouverts et ils ont pu se reconstruire doucement parmi nous et font partie intégrante de la communauté aujourd'hui.
|
||||
|
||||
@@ -14,8 +14,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);
|
||||
@@ -134,6 +136,26 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}
|
||||
}, [introStep]);
|
||||
|
||||
if (mainState === "pylon") {
|
||||
if (pylonStep === "approaching") {
|
||||
return <MissionNotification mission="pylon" visible />;
|
||||
}
|
||||
if (pylonStep === "narrator-outro") {
|
||||
return <MissionNotification mission="farm" visible />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mainState == "pylon") {
|
||||
if (pylonStep === "approaching") {
|
||||
return <MissionNotification mission="pylon" visible />;
|
||||
}
|
||||
if (pylonStep === "narrator-outro") {
|
||||
return <MissionNotification mission="farm" visible />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
introStep !== "reveal" &&
|
||||
introStep !== "await-ebike-mount" &&
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
|
||||
|
||||
const HISTOIRE_AUDIO_PATH = "/sounds/dialogue/narrateur_histoireelectricienne.mp3";
|
||||
|
||||
/**
|
||||
* Text blocks for the electricienne history narration (max 5 lines each).
|
||||
* Displayed sequentially — timings are calculated dynamically from the actual
|
||||
* audio duration so they are always correct regardless of the mp3 length.
|
||||
*/
|
||||
const HISTOIRE_BLOCKS = [
|
||||
"L'électricienne t'a aidé à la Centrale ? Aaaaah c'est ça que j'adore ici, tout le monde s'entraide, personne se juge, une vraie petite famille.",
|
||||
"Sache que l'électricienne a une histoire assez particulière. Elle est née au nord du continent, dans la ville de Kalska. Elle a grandit heureuse, avec sa mère Edith, son père Jordan et ses deux petits frères Malo et Justin.",
|
||||
"Il y a quelques années de ça, comme tu le sais, c'est les pays du Nord, qui par grande surprise, ont été obligés de migrer en premier. Ils ont alors entamé leur périple, pays par pays, ville par ville, village par village.",
|
||||
"Un jour de marche comme les autres depuis plusieurs mois, une tempête climatique les a pris de court. S'étant séparés pour trouver des vivres dans le village, le père et un des deux frères sont malheureusement partis. C'est tragique.",
|
||||
"Mais un beau jour, ils sont tombés ici, par hasard dans leur périple. On les a accueillis les bras ouverts et ils ont pu se reconstruire doucement parmi nous et font partie intégrante de la communauté aujourd'hui.",
|
||||
] as const;
|
||||
|
||||
const TOTAL_CHARS = HISTOIRE_BLOCKS.reduce((sum, b) => sum + b.length, 0);
|
||||
|
||||
/** Compute start/end times for each block based on actual audio duration. */
|
||||
function buildBlockTimings(
|
||||
duration: number,
|
||||
): Array<{ start: number; end: number }> {
|
||||
let t = 0;
|
||||
return HISTOIRE_BLOCKS.map((block) => {
|
||||
const blockDuration = (block.length / TOTAL_CHARS) * duration;
|
||||
const start = t;
|
||||
t += blockDuration;
|
||||
return { start, end: t };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the histoire audio and keep `useSubtitleStore` in sync with
|
||||
* dynamically-computed block boundaries.
|
||||
* Movement is intentionally NOT blocked so the player can explore while
|
||||
* listening to the narration.
|
||||
*/
|
||||
function useHistoireSubtitlePlayback(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const audio = AudioManager.getInstance().playSound(HISTOIRE_AUDIO_PATH, 1, {
|
||||
category: "dialogue",
|
||||
});
|
||||
|
||||
if (!audio) return undefined;
|
||||
|
||||
const { setActiveSubtitle, clearActiveSubtitle } =
|
||||
useSubtitleStore.getState();
|
||||
|
||||
/** Wire up block-level subtitle sync once we know the audio duration. */
|
||||
function startSync(): void {
|
||||
const duration = audio.duration;
|
||||
if (!duration || isNaN(duration) || isCancelled) return;
|
||||
|
||||
const timings = buildBlockTimings(duration);
|
||||
|
||||
function onTimeUpdate(): void {
|
||||
const t = audio.currentTime;
|
||||
const idx = timings.findIndex(
|
||||
({ start, end }) => t >= start && t < end,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
setActiveSubtitle({
|
||||
speaker: "Narrateur",
|
||||
text: HISTOIRE_BLOCKS[idx],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
audio.addEventListener("timeupdate", onTimeUpdate);
|
||||
audio.addEventListener("ended", clearActiveSubtitle, { once: true });
|
||||
}
|
||||
|
||||
// If duration is already known (cached audio), start immediately.
|
||||
if (audio.duration && !isNaN(audio.duration)) {
|
||||
startSync();
|
||||
} else {
|
||||
audio.addEventListener("loadedmetadata", startSync, { once: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
audio.pause();
|
||||
useSubtitleStore.getState().clearActiveSubtitle();
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the farm mission narrative intro:
|
||||
* locked → (auto) → electricienne_history → plays audio with block subtitles
|
||||
*/
|
||||
export function FarmNarrativeFlow(): null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.farm.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
|
||||
// locked is purely a gate — transition immediately to electricienne_history.
|
||||
useEffect(() => {
|
||||
if (mainState !== "farm" || step !== "locked") return;
|
||||
setMissionStep("farm", "electricienne_history");
|
||||
}, [mainState, step, setMissionStep]);
|
||||
|
||||
// Ensure movement is always allowed during the electricienne_history narration,
|
||||
// regardless of what the previous step may have blocked.
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
useEffect(() => {
|
||||
if (mainState !== "farm" || step !== "electricienne_history") return;
|
||||
setCanMove(true);
|
||||
}, [mainState, step, setCanMove]);
|
||||
|
||||
useHistoireSubtitlePlayback(
|
||||
mainState === "farm" && step === "electricienne_history",
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import {
|
||||
PYLON_DOWNED_ROTATION,
|
||||
PYLON_NARRATIVE_INTERACT_RADIUS,
|
||||
PYLON_NARRATIVE_DIALOGUES,
|
||||
PYLON_STRAIGHTEN_ANIMATION_DURATION_MS,
|
||||
PYLON_UPRIGHT_ROTATION,
|
||||
PYLON_WORLD_POSITION,
|
||||
} from "@/data/gameplay/pylonConfig";
|
||||
import { isRepairGameStep } from "@/types/gameplay/repairMission";
|
||||
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
||||
|
||||
const PYLON_MODEL_PATH = "/models/pylone/model.glb";
|
||||
|
||||
export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
// Use the repair:pylon anchor from the store so the downed pylon is always
|
||||
// co-located with the instanced mesh it replaces. Falls back to the
|
||||
// hard-coded constant while the map is loading or unavailable.
|
||||
const pylonAnchor = useRepairMissionAnchorStore(
|
||||
(state) => state.anchors.pylon,
|
||||
);
|
||||
// Snap to terrain so the downed/upright model sits flush on the ground,
|
||||
// matching the Y adjustment that InstancedMapAsset applies to the same node.
|
||||
const position = useTerrainSnappedPosition(pylonAnchor ?? PYLON_WORLD_POSITION);
|
||||
const [isStraightening, setIsStraightening] = useState(false);
|
||||
// Keeps the pylon upright after the animation completes while
|
||||
// PylonFarmerNPC plays the post-raise audio sequence.
|
||||
const [isRaised, setIsRaised] = useState(false);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const straightenStartRef = useRef<number | null>(null);
|
||||
const hasPlayedFirstAudioRef = useRef(false);
|
||||
|
||||
// Hidden outside the pylon mission and once the pylon has been raised
|
||||
// (repair-game steps take over from there).
|
||||
const shouldRender = mainState === "pylon" && !isRepairGameStep(step);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "arrived") {
|
||||
hasPlayedFirstAudioRef.current = false;
|
||||
setIsRaised(false);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const { scene } = useGLTF(PYLON_MODEL_PATH);
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
if (!isStraightening || straightenStartRef.current === null) {
|
||||
group.rotation.set(...(isRaised ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION));
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - straightenStartRef.current;
|
||||
const t = Math.min(elapsed / PYLON_STRAIGHTEN_ANIMATION_DURATION_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const startEuler = new THREE.Euler(...PYLON_DOWNED_ROTATION);
|
||||
|
||||
group.rotation.set(
|
||||
THREE.MathUtils.lerp(startEuler.x, 0, eased),
|
||||
startEuler.y,
|
||||
THREE.MathUtils.lerp(startEuler.z, 0, eased),
|
||||
);
|
||||
});
|
||||
|
||||
const isPylonInteractive = step === "arrived" || step === "npc-return";
|
||||
|
||||
const beginStraighten = (): void => {
|
||||
setIsStraightening(true);
|
||||
pylonStraighteningSignal.started = true;
|
||||
pylonStraighteningSignal.completed = false;
|
||||
straightenStartRef.current = performance.now();
|
||||
setCanMove(false);
|
||||
if (groupRef.current) {
|
||||
groupRef.current.rotation.set(...PYLON_DOWNED_ROTATION);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
setIsStraightening(false);
|
||||
pylonStraighteningSignal.started = false;
|
||||
// Keep pylon upright while PylonFarmerNPC plays the audio sequence.
|
||||
// PylonFarmerNPC will call setMissionStep("pylon", "inspected") once done.
|
||||
setIsRaised(true);
|
||||
setCanMove(true);
|
||||
pylonStraighteningSignal.completed = true;
|
||||
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
|
||||
};
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={PYLON_DOWNED_ROTATION}
|
||||
>
|
||||
<primitive object={scene.clone(true)} />
|
||||
{isPylonInteractive ? (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={
|
||||
step === "arrived" ? "Inspecter le pylône" : "Redresser le pylône"
|
||||
}
|
||||
position={position}
|
||||
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
||||
onPress={() => {
|
||||
if (step === "arrived") {
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 8, 8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(PYLON_MODEL_PATH);
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useAnimations } from "@react-three/drei";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { SkeletonUtils } from "three-stdlib";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import {
|
||||
PYLON_FARMER_NPC_AFTER_POSITION,
|
||||
PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight,
|
||||
PYLON_FARMER_NPC_AFTER_SCALE,
|
||||
PYLON_FARMER_NPC_POSITION,
|
||||
PYLON_FARMER_NPC_WALK_LOOK_AT,
|
||||
PYLON_FARMER_NPC_WALK_SPEED,
|
||||
PYLON_NARRATIVE_DIALOGUES,
|
||||
PYLON_NARRATIVE_INTERACT_RADIUS,
|
||||
PYLON_WORLD_POSITION,
|
||||
} from "@/data/gameplay/pylonConfig";
|
||||
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
||||
|
||||
const ELECTRICIENNE_MODEL_PATH = "/models/electricienne-animated/model.gltf";
|
||||
const ANIM_FADE = 0.3;
|
||||
const ARRIVE_THRESHOLD = 0.12;
|
||||
|
||||
type NPCAnimation = "idle" | "walk" | "push";
|
||||
|
||||
const _target = new THREE.Vector3();
|
||||
|
||||
/**
|
||||
* Compute the Y rotation (radians) for a model whose default forward
|
||||
* direction is +Z, so that it faces from `from` toward `to`.
|
||||
*/
|
||||
function faceToward(from: THREE.Vector3, to: readonly [number, number, number]): number {
|
||||
const dx = to[0] - from.x;
|
||||
const dz = to[2] - from.z;
|
||||
return Math.atan2(dx, dz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer shell — only checks visibility conditions.
|
||||
* Rendering is delegated to PylonFarmerNPCContent so that the heavy hooks
|
||||
* (useFrame, useAnimations) are only active while the NPC is actually shown.
|
||||
*/
|
||||
export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
// Visible during narrative + at repair completion (hides during repair steps)
|
||||
if (
|
||||
step !== "arrived" &&
|
||||
step !== "npc-return" &&
|
||||
step !== "inspected" &&
|
||||
step !== "done"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <PylonFarmerNPCContent />;
|
||||
}
|
||||
|
||||
// ─── Inner component — heavy hooks only run when NPC is mounted ──────────────
|
||||
function PylonFarmerNPCContent(): React.JSX.Element {
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const currentPosRef = useRef(new THREE.Vector3(...PYLON_FARMER_NPC_POSITION));
|
||||
|
||||
// Animation state guard — null forces playAnim to always trigger
|
||||
const currentAnimRef = useRef<NPCAnimation | null>(null);
|
||||
|
||||
// Signal edge tracking
|
||||
const wasStraighteningRef = useRef(false);
|
||||
const wasCompletedRef = useRef(false);
|
||||
|
||||
// Saved Y rotation used whenever the NPC is stationary
|
||||
const savedRotationYRef = useRef<number>(0);
|
||||
|
||||
const { scene, animations } = useLoggedGLTF(ELECTRICIENNE_MODEL_PATH, {
|
||||
scope: "PylonFarmerNPC",
|
||||
});
|
||||
const model = useMemo(() => SkeletonUtils.clone(scene), [scene]);
|
||||
|
||||
// actions is in deps of playAnim: when useAnimations populates it (async useState
|
||||
// inside drei), playAnim recreates → useEffect([step, playAnim]) re-fires → animation plays.
|
||||
const { actions } = useAnimations(animations, model);
|
||||
|
||||
// ─── playAnim ─────────────────────────────────────────────────────────────
|
||||
const playAnim = useCallback(
|
||||
(name: NPCAnimation, fade = ANIM_FADE): void => {
|
||||
if (currentAnimRef.current === name) return;
|
||||
currentAnimRef.current = name;
|
||||
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(fade));
|
||||
|
||||
const action = actions[name];
|
||||
if (!action) return;
|
||||
|
||||
if (name === "push") {
|
||||
action.setLoop(THREE.LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
}
|
||||
action.reset().fadeIn(fade).play();
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
// ─── Async audio after pylon is raised ────────────────────────────────────
|
||||
const playPostRaiseAudioAndAdvance = useCallback(async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (manifest) {
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.electricienneApresMontage,
|
||||
);
|
||||
if (audio) {
|
||||
await new Promise<void>((resolve) => {
|
||||
audio.addEventListener("ended", () => resolve(), { once: true });
|
||||
audio.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
pylonStraighteningSignal.completed = false;
|
||||
setMissionStep("pylon", "inspected");
|
||||
}, [setMissionStep]);
|
||||
|
||||
// ─── Step-driven animation ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
currentAnimRef.current = null;
|
||||
if (step === "arrived") {
|
||||
currentPosRef.current.set(...PYLON_FARMER_NPC_POSITION);
|
||||
wasStraighteningRef.current = false;
|
||||
wasCompletedRef.current = false;
|
||||
savedRotationYRef.current = 0;
|
||||
playAnim("idle");
|
||||
} else if (step === "npc-return") {
|
||||
playAnim("walk");
|
||||
} else if (step === "inspected") {
|
||||
playAnim("idle");
|
||||
} else if (step === "done") {
|
||||
// NPC reappears at repair completion — position at the post-raise spot,
|
||||
// facing the pylon, playing idle.
|
||||
currentPosRef.current.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
|
||||
savedRotationYRef.current = faceToward(
|
||||
currentPosRef.current,
|
||||
PYLON_WORLD_POSITION,
|
||||
);
|
||||
playAnim("idle");
|
||||
}
|
||||
}, [step, playAnim]);
|
||||
|
||||
// ─── Per-frame: movement + rotation + signal detection ───────────────────
|
||||
useFrame((_, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
const isStraightening = pylonStraighteningSignal.started;
|
||||
const isCompleted = pylonStraighteningSignal.completed;
|
||||
|
||||
// Rising edge: pylon straightening starts → push
|
||||
if (isStraightening && !wasStraighteningRef.current) {
|
||||
wasStraighteningRef.current = true;
|
||||
currentAnimRef.current = null;
|
||||
playAnim("push");
|
||||
}
|
||||
|
||||
// Rising edge: straightening completed → idle + face player + audio
|
||||
if (isCompleted && !wasCompletedRef.current) {
|
||||
wasCompletedRef.current = true;
|
||||
currentAnimRef.current = null;
|
||||
playAnim("idle");
|
||||
savedRotationYRef.current = faceToward(currentPosRef.current, [
|
||||
camera.position.x,
|
||||
camera.position.y,
|
||||
camera.position.z,
|
||||
]);
|
||||
void playPostRaiseAudioAndAdvance();
|
||||
}
|
||||
|
||||
// ── Position ──────────────────────────────────────────────────────────
|
||||
if (step === "npc-return" && !isCompleted) {
|
||||
const targetPos = isStraightening
|
||||
? PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight
|
||||
: PYLON_FARMER_NPC_AFTER_POSITION;
|
||||
_target.set(...targetPos);
|
||||
|
||||
const dist = currentPosRef.current.distanceTo(_target);
|
||||
if (dist > ARRIVE_THRESHOLD) {
|
||||
const t = Math.min((PYLON_FARMER_NPC_WALK_SPEED * delta) / dist, 1);
|
||||
currentPosRef.current.lerp(_target, t);
|
||||
} else if (!isStraightening && currentAnimRef.current === "walk") {
|
||||
playAnim("idle");
|
||||
savedRotationYRef.current = faceToward(currentPosRef.current, PYLON_WORLD_POSITION);
|
||||
}
|
||||
group.position.copy(currentPosRef.current);
|
||||
} else if (step === "inspected" || step === "done") {
|
||||
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
|
||||
} else if (isCompleted) {
|
||||
group.position.copy(currentPosRef.current);
|
||||
} else {
|
||||
group.position.set(...PYLON_FARMER_NPC_POSITION);
|
||||
}
|
||||
|
||||
// ── Rotation ──────────────────────────────────────────────────────────
|
||||
if (step === "npc-return" && !isCompleted && currentAnimRef.current === "walk") {
|
||||
const walkRotY = faceToward(currentPosRef.current, PYLON_FARMER_NPC_WALK_LOOK_AT);
|
||||
group.rotation.set(0, walkRotY, 0);
|
||||
} else {
|
||||
group.rotation.set(0, savedRotationYRef.current, 0);
|
||||
}
|
||||
|
||||
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
|
||||
<primitive object={model} />
|
||||
{step === "arrived" ? (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label="Parler à l'électricienne"
|
||||
position={PYLON_FARMER_NPC_POSITION}
|
||||
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
||||
onPress={() => {
|
||||
// Turn to face the player the moment they engage the NPC
|
||||
savedRotationYRef.current = faceToward(currentPosRef.current, [
|
||||
camera.position.x,
|
||||
camera.position.y,
|
||||
camera.position.z,
|
||||
]);
|
||||
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) {
|
||||
setMissionStep("pylon", "npc-return");
|
||||
return;
|
||||
}
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.electricienneWelcome,
|
||||
);
|
||||
if (!audio) {
|
||||
setMissionStep("pylon", "npc-return");
|
||||
return;
|
||||
}
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => setMissionStep("pylon", "npc-return"),
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 8, 8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(ELECTRICIENNE_MODEL_PATH);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||
import { LIGHTING_DEFAULTS } from "@/data/world/lightingConfig";
|
||||
|
||||
// ─── Pylon atmosphere colours ─────────────────────────────────────────────────
|
||||
// Applied from "approaching" until the pylon mission ends.
|
||||
const PYLON_AMBIENT_COLOR = "#7b87c8"; // blue-violet
|
||||
const PYLON_SUN_COLOR = "#a882d4"; // lavender-purple
|
||||
|
||||
// Lerp speed (1 = full transition in ~1 s at 60 fps)
|
||||
const TRANSITION_SPEED = 0.8;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PylonLightingEffect(): null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
|
||||
// True from "approaching" until done — lighting starts reverting as soon as
|
||||
// the repair is complete (powerup sfx plays at "done", outro dialogue at "narrator-outro").
|
||||
const isActive =
|
||||
mainState === "pylon" &&
|
||||
step !== "locked" &&
|
||||
step !== "done" &&
|
||||
step !== "narrator-outro";
|
||||
|
||||
// Working THREE.Color instances — lerped every frame
|
||||
const ambientRef = useRef(new THREE.Color(LIGHTING_STATE.ambientColor));
|
||||
const sunRef = useRef(new THREE.Color(LIGHTING_STATE.sunColor));
|
||||
|
||||
// Target colours — updated reactively when isActive changes
|
||||
const targetAmbientRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.ambientColor));
|
||||
const targetSunRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.sunColor));
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
targetAmbientRef.current.set(PYLON_AMBIENT_COLOR);
|
||||
targetSunRef.current.set(PYLON_SUN_COLOR);
|
||||
} else {
|
||||
targetAmbientRef.current.set(LIGHTING_DEFAULTS.ambientColor);
|
||||
targetSunRef.current.set(LIGHTING_DEFAULTS.sunColor);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const t = Math.min(TRANSITION_SPEED * delta, 1);
|
||||
|
||||
ambientRef.current.lerp(targetAmbientRef.current, t);
|
||||
sunRef.current.lerp(targetSunRef.current, t);
|
||||
|
||||
LIGHTING_STATE.ambientColor = `#${ambientRef.current.getHexString()}`;
|
||||
LIGHTING_STATE.sunColor = `#${sunRef.current.getHexString()}`;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
|
||||
import { ZoneDetection } from "@/components/zone/ZoneDetection";
|
||||
import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC";
|
||||
import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro";
|
||||
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
||||
import { PYLON_NARRATIVE_DIALOGUES } from "@/data/gameplay/pylonConfig";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
const PYLON_POWERDOWN_SFX = "/sounds/effect/generateur-powerdown.mp3";
|
||||
const PYLON_POWERUP_SFX = "/sounds/effect/generateur-powerup.mp3";
|
||||
|
||||
export function PylonNarrativeFlow(): 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);
|
||||
|
||||
// ── approaching : powerdown sfx → then electricOutage dialogue ────────────
|
||||
useEffect(() => {
|
||||
if (mainState !== "pylon" || step !== "approaching") return undefined;
|
||||
|
||||
let isCancelled = false;
|
||||
setCanMove(false);
|
||||
|
||||
void (async () => {
|
||||
// 1. Play the generator powerdown sound effect
|
||||
const sfx = AudioManager.getInstance().playSound(
|
||||
PYLON_POWERDOWN_SFX,
|
||||
1,
|
||||
{ category: "sfx" },
|
||||
);
|
||||
|
||||
// 2. Wait for it to finish (or skip if it can't load)
|
||||
if (sfx) {
|
||||
await new Promise<void>((resolve) => {
|
||||
sfx.addEventListener("ended", () => resolve(), { once: true });
|
||||
sfx.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (isCancelled) return;
|
||||
|
||||
// 3. Play the narrative dialogue
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (isCancelled || !manifest) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.electricOutage,
|
||||
);
|
||||
|
||||
if (isCancelled || !audio) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
setCanMove(true);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
setCanMove(true);
|
||||
};
|
||||
}, [mainState, step, setCanMove]);
|
||||
|
||||
// ── arrived : searchCentral dialogue (unchanged) ──────────────────────────
|
||||
useDialoguePlayback({
|
||||
enabled: mainState === "pylon" && step === "arrived",
|
||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral,
|
||||
});
|
||||
|
||||
// ── done : powerup sfx + lighting revert → auto-transition to narrator-outro
|
||||
useEffect(() => {
|
||||
if (mainState !== "pylon" || step !== "done") return undefined;
|
||||
|
||||
const sfx = AudioManager.getInstance().playSound(PYLON_POWERUP_SFX, 1, {
|
||||
category: "sfx",
|
||||
});
|
||||
|
||||
if (sfx) {
|
||||
sfx.addEventListener(
|
||||
"ended",
|
||||
() => setMissionStep("pylon", "narrator-outro"),
|
||||
{ once: true },
|
||||
);
|
||||
sfx.addEventListener(
|
||||
"error",
|
||||
() => setMissionStep("pylon", "narrator-outro"),
|
||||
{ once: true },
|
||||
);
|
||||
} else {
|
||||
// Fallback if the audio can't load
|
||||
setMissionStep("pylon", "narrator-outro");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [mainState, step, setMissionStep]);
|
||||
|
||||
// narrator-outro audio sequence + completeMission are handled in PylonNarratorOutro
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
|
||||
if (step === "locked") {
|
||||
return (
|
||||
<ZoneDetection
|
||||
key="pylon-approach"
|
||||
zone={PYLON_APPROACH_ZONE}
|
||||
onEnter={() => setMissionStep("pylon", "approaching")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "approaching") {
|
||||
return (
|
||||
<ZoneDetection
|
||||
key="pylon-arrived"
|
||||
zone={PYLON_ARRIVED_ZONE}
|
||||
onEnter={() => setMissionStep("pylon", "arrived")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
step === "arrived" ||
|
||||
step === "npc-return" ||
|
||||
step === "inspected" ||
|
||||
step === "done"
|
||||
) {
|
||||
return <PylonFarmerNPC />;
|
||||
}
|
||||
|
||||
if (step === "narrator-outro") {
|
||||
return <PylonNarratorOutro />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import { PYLON_NARRATIVE_DIALOGUES } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
/**
|
||||
* Plays the narrator-outro audio sequence:
|
||||
* 1. electricienne_aurevoir ("À la prochaine !")
|
||||
* 2. narrateur_courantrepare ("powerRestored")
|
||||
* then completes the pylon mission.
|
||||
*/
|
||||
export function PylonNarratorOutro(): null {
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCanMove(false);
|
||||
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (cancelled || !manifest) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Électricienne : "À la prochaine !"
|
||||
const audio1 = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.electricienneAurevoir,
|
||||
);
|
||||
if (audio1 && !cancelled) {
|
||||
await new Promise<void>((resolve) => {
|
||||
audio1.addEventListener("ended", () => resolve(), { once: true });
|
||||
audio1.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Narrateur : "Le courant est réparé"
|
||||
const audio2 = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.powerRestored,
|
||||
);
|
||||
if (audio2 && !cancelled) {
|
||||
audio2.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
setCanMove(true);
|
||||
completeMission("pylon");
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else {
|
||||
setCanMove(true);
|
||||
completeMission("pylon");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setCanMove(true);
|
||||
};
|
||||
}, [completeMission, setCanMove]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Shared runtime signal set by PylonDownedPylon when the straighten
|
||||
* animation starts, so PylonFarmerNPC can switch its lerp target.
|
||||
*
|
||||
* `completed` is set after the straighten animation finishes so
|
||||
* PylonFarmerNPC can play the post-raise audio sequence before
|
||||
* transitioning to the repair game.
|
||||
*/
|
||||
export const pylonStraighteningSignal = { started: false, completed: false };
|
||||
@@ -170,7 +170,7 @@ export function RepairGame({
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" ? (
|
||||
{step === "done" && mission !== "pylon" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
import type { ZoneConfig } from "@/types/gameplay/zone";
|
||||
|
||||
interface ZoneDetectionProps {
|
||||
zone: ZoneConfig;
|
||||
onEnter: () => void;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
|
||||
export function ZoneDebugVisual({
|
||||
zone,
|
||||
active,
|
||||
}: {
|
||||
zone: ZoneConfig;
|
||||
active: boolean;
|
||||
}): React.JSX.Element | null {
|
||||
if (!isDebugEnabled()) return null;
|
||||
return (
|
||||
<group position={zone.position}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[zone.radius - 0.2, zone.radius, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={active ? "#22c55e" : "#fbbf24"}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh>
|
||||
<cylinderGeometry
|
||||
args={[zone.radius, zone.radius, zone.height, 16, 1, true]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={active ? "#22c55e" : "#fbbf24"}
|
||||
transparent
|
||||
opacity={0.08}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZoneDetection({
|
||||
zone,
|
||||
onEnter,
|
||||
height,
|
||||
}: ZoneDetectionProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const hasTriggeredRef = useRef(false);
|
||||
const onEnterRef = useRef(onEnter);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onEnterRef.current = onEnter;
|
||||
}, [onEnter]);
|
||||
|
||||
useFrame(() => {
|
||||
if (hasTriggeredRef.current) return;
|
||||
|
||||
camera.getWorldPosition(_cameraPos);
|
||||
const dx = _cameraPos.x - zone.position[0];
|
||||
const dz = _cameraPos.z - zone.position[2];
|
||||
const horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
if (horizontalDist > zone.radius) return;
|
||||
|
||||
const zoneHeight = height ?? zone.height;
|
||||
if (_cameraPos.y < zone.position[1] - zoneHeight / 2) return;
|
||||
if (_cameraPos.y > zone.position[1] + zoneHeight / 2) return;
|
||||
|
||||
hasTriggeredRef.current = true;
|
||||
setIsActive(true);
|
||||
onEnterRef.current();
|
||||
});
|
||||
|
||||
return <ZoneDebugVisual zone={zone} active={isActive} />;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const PYLON_WORLD_POSITION: Vector3Tuple = [-31.5, 3.5, 36.04];
|
||||
|
||||
export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
|
||||
|
||||
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
|
||||
-16.13,
|
||||
3.2,
|
||||
52.46
|
||||
];
|
||||
|
||||
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] + 3,
|
||||
PYLON_WORLD_POSITION[1] + 0.2,
|
||||
PYLON_WORLD_POSITION[2],
|
||||
];
|
||||
|
||||
/** Point vers lequel l'électricienne regarde pendant sa marche vers le pylône (ajustable) */
|
||||
export const PYLON_FARMER_NPC_WALK_LOOK_AT: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] + 3,
|
||||
PYLON_WORLD_POSITION[1] + 0.2,
|
||||
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],
|
||||
];
|
||||
|
||||
/** 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.55;
|
||||
|
||||
/** 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;
|
||||
|
||||
export const PYLON_NARRATIVE_DIALOGUES = {
|
||||
electricOutage: "narrateur_coupureelec",
|
||||
searchCentral: "narrateur_fouillelecentre",
|
||||
brokenPylon: "narrateur_poteaueleccasse",
|
||||
demandeAide: "narrateur_demande_aide",
|
||||
farmerHelp: "fermier_coupdemain",
|
||||
electricienneWelcome: "electricienne_welcome",
|
||||
electricienneApresMontage: "electricienne_apresMontage",
|
||||
electricienneAurevoir: "electricienne_aurevoir",
|
||||
powerRestored: "narrateur_courantrepare",
|
||||
} as const;
|
||||
@@ -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<RepairMissionId, string>
|
||||
@@ -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<RepairMissionId, Vector3Tuple>;
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
||||
|
||||
export const MISSION_STEPS = [
|
||||
"locked",
|
||||
"approaching",
|
||||
"arrived",
|
||||
"npc-return",
|
||||
"waiting",
|
||||
"inspected",
|
||||
"fragmented",
|
||||
@@ -17,6 +20,7 @@ export const MISSION_STEPS = [
|
||||
"repairing",
|
||||
"reassembling",
|
||||
"done",
|
||||
"narrator-outro",
|
||||
] as const satisfies readonly MissionStep[];
|
||||
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
|
||||
|
||||
@@ -28,9 +32,18 @@ export function isMissionStep(value: string): value is MissionStep {
|
||||
return MISSION_STEP_VALUES.has(value);
|
||||
}
|
||||
|
||||
export function getNextMissionStep(step: MissionStep): MissionStep {
|
||||
export function getNextMissionStep(
|
||||
step: MissionStep,
|
||||
mission?: RepairMissionId,
|
||||
): MissionStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
return mission === "pylon" ? "approaching" : "waiting";
|
||||
case "approaching":
|
||||
return "arrived";
|
||||
case "arrived":
|
||||
return "npc-return";
|
||||
case "npc-return":
|
||||
return "waiting";
|
||||
case "waiting":
|
||||
return "inspected";
|
||||
@@ -43,16 +56,29 @@ export function getNextMissionStep(step: MissionStep): MissionStep {
|
||||
case "repairing":
|
||||
return "reassembling";
|
||||
case "reassembling":
|
||||
case "done":
|
||||
return "done";
|
||||
case "done":
|
||||
return mission === "pylon" ? "narrator-outro" : "done";
|
||||
case "narrator-outro":
|
||||
return "narrator-outro";
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||
export function getPreviousMissionStep(
|
||||
step: MissionStep,
|
||||
mission?: RepairMissionId,
|
||||
): MissionStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
case "waiting":
|
||||
return "locked";
|
||||
case "approaching":
|
||||
return "locked";
|
||||
case "arrived":
|
||||
return "approaching";
|
||||
case "npc-return":
|
||||
return "arrived";
|
||||
case "waiting":
|
||||
return mission === "pylon" ? "npc-return" : "locked";
|
||||
case "inspected":
|
||||
return "waiting";
|
||||
case "fragmented":
|
||||
@@ -65,5 +91,7 @@ export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||
return "repairing";
|
||||
case "done":
|
||||
return "reassembling";
|
||||
case "narrator-outro":
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,20 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
"pylon-cable-left-replacement",
|
||||
],
|
||||
scanPartSeconds: 1.4,
|
||||
brokenParts: [],
|
||||
brokenParts: [
|
||||
{
|
||||
id: "pylon-grid-relay",
|
||||
label: "Grid relay",
|
||||
nodeName: "lampe",
|
||||
caseSlotName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "pylon-damaged-panel",
|
||||
label: "Damaged solar panel",
|
||||
nodeName: "pylone",
|
||||
caseSlotName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "pylon-cable-right-replacement",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ZoneConfig } from "@/types/gameplay/zone";
|
||||
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
// Zones qui active la coupure de courant
|
||||
export const PYLON_APPROACH_ZONE: ZoneConfig = {
|
||||
id: "pylon-approach",
|
||||
position: [
|
||||
5,
|
||||
4,
|
||||
-21.5
|
||||
],
|
||||
radius: 10,
|
||||
height: 18,
|
||||
oneShot: true,
|
||||
};
|
||||
|
||||
// Zone qui active la cinématique d'arrivée du pylône
|
||||
export const PYLON_ARRIVED_ZONE: ZoneConfig = {
|
||||
id: "pylon-arrived",
|
||||
position: [
|
||||
PYLON_WORLD_POSITION[0],
|
||||
PYLON_WORLD_POSITION[1],
|
||||
PYLON_WORLD_POSITION[2],
|
||||
],
|
||||
radius: 30,
|
||||
height: 15,
|
||||
oneShot: true,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import type GUI from "lil-gui";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
|
||||
export function usePlayerPositionDebug(): void {
|
||||
const pos = useRef({ x: 0, y: 0, z: 0 });
|
||||
const controllers = useRef<{ updateDisplay: () => void }[]>([]);
|
||||
|
||||
useDebugFolder("Game", (folder: GUI) => {
|
||||
const sub = folder.addFolder("Player Position");
|
||||
sub.open();
|
||||
|
||||
controllers.current = [
|
||||
sub.add(pos.current, "x").name("X").decimals(2).disable(),
|
||||
sub.add(pos.current, "y").name("Y").decimals(2).disable(),
|
||||
sub.add(pos.current, "z").name("Z").decimals(2).disable(),
|
||||
];
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
const p = window.playerPos;
|
||||
if (!p) return;
|
||||
pos.current.x = p[0];
|
||||
pos.current.y = p[1];
|
||||
pos.current.z = p[2];
|
||||
for (const c of controllers.current) c.updateDisplay();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
interface UseDialoguePlaybackOptions {
|
||||
enabled: boolean;
|
||||
dialogueId: string | null;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function useDialoguePlayback({
|
||||
enabled,
|
||||
dialogueId,
|
||||
onComplete,
|
||||
}: UseDialoguePlaybackOptions): void {
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !dialogueId) return undefined;
|
||||
|
||||
let isCancelled = false;
|
||||
setCanMove(false);
|
||||
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (isCancelled || !manifest) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = await playDialogueById(manifest, dialogueId);
|
||||
if (isCancelled || !audio) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
setCanMove(true);
|
||||
onComplete?.();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
setCanMove(true);
|
||||
};
|
||||
}, [enabled, dialogueId, onComplete, setCanMove]);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ function completeEbikeState(state: GameState): GameStateUpdate {
|
||||
},
|
||||
pylon: {
|
||||
...state.pylon,
|
||||
currentStep: "waiting",
|
||||
currentStep: "approaching",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -161,7 +161,10 @@ function completePylonState(state: GameState): GameStateUpdate {
|
||||
},
|
||||
farm: {
|
||||
...state.farm,
|
||||
currentStep: "waiting",
|
||||
// Farm starts at "locked" so FarmNarrativeFlow can auto-transition
|
||||
// to "electricienne_history" and play the intro audio before the
|
||||
// repair game begins.
|
||||
currentStep: "locked",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -212,7 +215,7 @@ function advanceRepairMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
const nextStep = getNextMissionStep(state[mission].currentStep);
|
||||
const nextStep = getNextMissionStep(state[mission].currentStep, mission);
|
||||
if (nextStep === "done") {
|
||||
return completeMissionState(state, mission);
|
||||
}
|
||||
@@ -227,7 +230,7 @@ function rewindRepairMissionState(
|
||||
return setMissionStepState(
|
||||
state,
|
||||
mission,
|
||||
getPreviousMissionStep(state[mission].currentStep),
|
||||
getPreviousMissionStep(state[mission].currentStep, mission),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,10 +83,50 @@ export interface RepairMissionConfig {
|
||||
|
||||
export type MissionStep =
|
||||
| "locked"
|
||||
| "approaching"
|
||||
| "arrived"
|
||||
| "npc-return"
|
||||
| "waiting"
|
||||
| "inspected"
|
||||
| "fragmented"
|
||||
| "scanning"
|
||||
| "repairing"
|
||||
| "reassembling"
|
||||
| "done";
|
||||
| "done"
|
||||
| "narrator-outro"
|
||||
| "electricienne_history";
|
||||
|
||||
export const PYLON_NARRATIVE_STEPS = [
|
||||
"approaching",
|
||||
"arrived",
|
||||
"npc-return",
|
||||
"narrator-outro",
|
||||
] as const;
|
||||
|
||||
/** Farm-specific steps that bypass the repair-game flow. */
|
||||
export const FARM_NARRATIVE_STEPS = [
|
||||
"locked",
|
||||
"electricienne_history",
|
||||
] as const;
|
||||
|
||||
export const REPAIR_GAME_STEPS = [
|
||||
"waiting",
|
||||
"inspected",
|
||||
"fragmented",
|
||||
"scanning",
|
||||
"repairing",
|
||||
"reassembling",
|
||||
"done",
|
||||
] as const;
|
||||
|
||||
export function isPylonNarrativeStep(step: MissionStep): boolean {
|
||||
return (PYLON_NARRATIVE_STEPS as readonly MissionStep[]).includes(step);
|
||||
}
|
||||
|
||||
export function isFarmNarrativeStep(step: MissionStep): boolean {
|
||||
return (FARM_NARRATIVE_STEPS as readonly MissionStep[]).includes(step);
|
||||
}
|
||||
|
||||
export function isRepairGameStep(step: MissionStep): boolean {
|
||||
return (REPAIR_GAME_STEPS as readonly MissionStep[]).includes(step);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface ZoneConfig {
|
||||
id: string;
|
||||
position: Vector3Tuple;
|
||||
radius: number;
|
||||
height: number;
|
||||
oneShot: boolean;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export interface MapNode {
|
||||
}
|
||||
|
||||
export interface MapNodeInstanceTransform {
|
||||
/** Node id from map.json — preserved so specific instances can be excluded at runtime. */
|
||||
id?: string;
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
|
||||
@@ -4,6 +4,7 @@ export function mapNodeToInstanceTransform(
|
||||
node: MapNode,
|
||||
): MapNodeInstanceTransform {
|
||||
return {
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Ebike } from "@/components/ebike/Ebike";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { FarmNarrativeFlow } from "@/components/gameplay/farm/FarmNarrativeFlow";
|
||||
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
||||
import { PylonLightingEffect } from "@/components/gameplay/pylon/PylonLightingEffect";
|
||||
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,
|
||||
@@ -11,6 +18,10 @@ import {
|
||||
} from "@/data/gameplay/gameStageAnchors";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||
import {
|
||||
isFarmNarrativeStep,
|
||||
isPylonNarrativeStep,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||
@@ -83,15 +94,35 @@ function RepairMissionTrigger({
|
||||
|
||||
export function GameStageContent(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
|
||||
|
||||
const farmStep = useGameStore((state) => state.farm.currentStep);
|
||||
|
||||
const pylonInNarrative =
|
||||
mainState === "pylon" && isPylonNarrativeStep(pylonStep);
|
||||
const farmInNarrative =
|
||||
mainState === "farm" && isFarmNarrativeStep(farmStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
<PylonLightingEffect />
|
||||
<PylonDownedPylon />
|
||||
{isDebugEnabled() ? (
|
||||
<>
|
||||
<ZoneDebugVisual zone={PYLON_APPROACH_ZONE} active={false} />
|
||||
<ZoneDebugVisual zone={PYLON_ARRIVED_ZONE} active={false} />
|
||||
</>
|
||||
) : null}
|
||||
{mainState === "pylon" ? <PylonNarrativeFlow /> : null}
|
||||
{mainState === "farm" ? <FarmNarrativeFlow /> : null}
|
||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||
const position = getRepairMissionPosition(mission, anchors);
|
||||
if (!position) return null;
|
||||
if (mission === "pylon" && pylonInNarrative) return null;
|
||||
if (mission === "farm" && farmInNarrative) return null;
|
||||
return (
|
||||
<RepairGame key={mission} mission={mission} position={position} />
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
||||
import { usePlayerPositionDebug } from "@/hooks/debug/usePlayerPositionDebug";
|
||||
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
@@ -49,6 +50,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
useEnvironmentDebug();
|
||||
useMapPerformanceDebug();
|
||||
useCharacterDebug();
|
||||
usePlayerPositionDebug();
|
||||
useDebugVisualsDebug();
|
||||
|
||||
const cameraMode = useCameraMode();
|
||||
|
||||
@@ -264,7 +264,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
</mesh>
|
||||
*/}
|
||||
{/* GPS Map screen plane */}
|
||||
<group position={[0, 0, 0.06]}>
|
||||
<group position={[0, -8, 0.06]}>
|
||||
<EbikeGPSMap
|
||||
width={4}
|
||||
height={4}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isMapModelVisible,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset";
|
||||
import {
|
||||
MAP_INSTANCING_ASSETS,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
type MapInstancingAssetConfig,
|
||||
type MapInstancingAssetType,
|
||||
} from "@/data/world/mapInstancingConfig";
|
||||
import { REPAIR_MISSION_ANCHOR_IDS } from "@/data/gameplay/repairMissionAnchors";
|
||||
import { isRepairGameStep } from "@/types/gameplay/repairMission";
|
||||
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
|
||||
import type { MapAssetInstance } from "@/types/map/mapScene";
|
||||
import type { GraphicsPreset } from "@/data/world/graphicsConfig";
|
||||
@@ -146,6 +149,8 @@ export function MapInstancingSystem({
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
const { data, isLoading } = useMapInstancingData();
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const streamingEnabled =
|
||||
streaming &&
|
||||
CHUNK_CONFIG.enabled &&
|
||||
@@ -153,6 +158,15 @@ export function MapInstancingSystem({
|
||||
sceneMode === "game" &&
|
||||
cameraMode === "player";
|
||||
|
||||
// During the pylon narrative phase (before the pylon is raised), hide the
|
||||
// repair:pylon instanced mesh so the PylonDownedPylon component takes its place.
|
||||
// Once the pylon is raised (repair-game steps), restore it so the normal model
|
||||
// appears upright in the world while the repair mini-game runs.
|
||||
const hidePylonAnchorId =
|
||||
mainState === "pylon" && !isRepairGameStep(pylonStep)
|
||||
? REPAIR_MISSION_ANCHOR_IDS.pylon
|
||||
: undefined;
|
||||
|
||||
const chunks = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
@@ -168,12 +182,18 @@ export function MapInstancingSystem({
|
||||
return [];
|
||||
}
|
||||
|
||||
const instances = data.get(type);
|
||||
let instances = data.get(type);
|
||||
if (!instances || instances.length === 0) return [];
|
||||
|
||||
// Filter out the repair-mission pylon instance during the narrative phase
|
||||
if (hidePylonAnchorId && config.mapName === "pylone") {
|
||||
instances = instances.filter((inst) => inst.id !== hidePylonAnchorId);
|
||||
if (instances.length === 0) return [];
|
||||
}
|
||||
|
||||
return createMapAssetChunks(type, config, instances);
|
||||
});
|
||||
}, [data, groups, models, onlyMapName]);
|
||||
}, [data, groups, models, onlyMapName, hidePylonAnchorId]);
|
||||
|
||||
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled, {
|
||||
loadRadius: graphicsPresetConfig.chunkLoadRadius,
|
||||
|
||||
Reference in New Issue
Block a user