Merge branch 'develop' of https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef } 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";
|
||||
const OUTRO_DELAY_MS = 5_000; // delay after audio ends before transitioning to outro
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `onAudioEnded` fires once when the audio element emits "ended".
|
||||
*/
|
||||
function useHistoireSubtitlePlayback(
|
||||
enabled: boolean,
|
||||
onAudioEnded?: () => void,
|
||||
): void {
|
||||
// Keep callback in a ref so the effect doesn't need it as a dependency.
|
||||
const onAudioEndedRef = useRef(onAudioEnded);
|
||||
useEffect(() => {
|
||||
onAudioEndedRef.current = onAudioEnded;
|
||||
});
|
||||
|
||||
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],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onEnded(): void {
|
||||
clearActiveSubtitle();
|
||||
onAudioEndedRef.current?.();
|
||||
}
|
||||
|
||||
audio.addEventListener("timeupdate", onTimeUpdate);
|
||||
audio.addEventListener("ended", onEnded, { 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
|
||||
* → 5 s after audio ends → completeMission("farm") → outro
|
||||
*/
|
||||
export function FarmNarrativeFlow(): null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.farm.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
|
||||
// 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]);
|
||||
|
||||
// After the audio finishes, wait 5 s then transition to outro.
|
||||
// The timeout ID is kept in a ref so we can cancel on unmount.
|
||||
const outroTimeoutRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (outroTimeoutRef.current !== null) {
|
||||
window.clearTimeout(outroTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAudioEnded = (): void => {
|
||||
if (outroTimeoutRef.current !== null) {
|
||||
window.clearTimeout(outroTimeoutRef.current);
|
||||
}
|
||||
outroTimeoutRef.current = window.setTimeout(() => {
|
||||
outroTimeoutRef.current = null;
|
||||
completeMission("farm");
|
||||
}, OUTRO_DELAY_MS);
|
||||
};
|
||||
|
||||
useHistoireSubtitlePlayback(
|
||||
mainState === "farm" && step === "electricienne_history",
|
||||
handleAudioEnded,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ 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 {
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
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";
|
||||
@@ -22,6 +25,15 @@ 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.
|
||||
@@ -30,19 +42,9 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
const straightenStartRef = useRef<number | null>(null);
|
||||
const hasPlayedFirstAudioRef = useRef(false);
|
||||
|
||||
const showUpright =
|
||||
isRaised ||
|
||||
mainState !== "pylon" ||
|
||||
step === "waiting" ||
|
||||
step === "inspected" ||
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "repairing" ||
|
||||
step === "reassembling" ||
|
||||
step === "done" ||
|
||||
step === "narrator-outro";
|
||||
|
||||
const isPylonInteractive = step === "arrived" || step === "npc-return";
|
||||
// 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") {
|
||||
@@ -61,9 +63,7 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
if (!group) return;
|
||||
|
||||
if (!isStraightening || straightenStartRef.current === null) {
|
||||
group.rotation.set(
|
||||
...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION),
|
||||
);
|
||||
group.rotation.set(...(isRaised ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
);
|
||||
});
|
||||
|
||||
const isPylonInteractive = step === "arrived" || step === "npc-return";
|
||||
|
||||
const beginStraighten = (): void => {
|
||||
setIsStraightening(true);
|
||||
pylonStraighteningSignal.started = true;
|
||||
@@ -99,10 +101,12 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
|
||||
};
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={PYLON_WORLD_POSITION}
|
||||
position={position}
|
||||
rotation={PYLON_DOWNED_ROTATION}
|
||||
>
|
||||
<primitive object={scene.clone(true)} />
|
||||
@@ -112,7 +116,7 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
label={
|
||||
step === "arrived" ? "Inspecter le pylône" : "Redresser le pylône"
|
||||
}
|
||||
position={PYLON_WORLD_POSITION}
|
||||
position={position}
|
||||
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
||||
onPress={() => {
|
||||
if (step === "arrived") {
|
||||
|
||||
@@ -43,9 +43,32 @@ function faceToward(
|
||||
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);
|
||||
|
||||
@@ -102,7 +125,6 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||
const playPostRaiseAudioAndAdvance = useCallback(async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (manifest) {
|
||||
// "N'hésite pas, si tu as besoin d'autre chose !"
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.electricienneApresMontage,
|
||||
@@ -134,6 +156,15 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||
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]);
|
||||
|
||||
@@ -184,7 +215,7 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||
);
|
||||
}
|
||||
group.position.copy(currentPosRef.current);
|
||||
} else if (step === "inspected") {
|
||||
} else if (step === "inspected" || step === "done") {
|
||||
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
|
||||
} else if (isCompleted) {
|
||||
group.position.copy(currentPosRef.current);
|
||||
@@ -211,10 +242,6 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||
});
|
||||
/* eslint-enable react-hooks/immutability */
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
if (step !== "arrived" && step !== "npc-return" && step !== "inspected")
|
||||
return null;
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
|
||||
<primitive object={model} />
|
||||
@@ -225,6 +252,13 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||
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) {
|
||||
|
||||
@@ -19,9 +19,13 @@ export function PylonLightingEffect(): null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
|
||||
// True from "approaching" until narrator-outro (lighting resets before the outro audio)
|
||||
// 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 !== "narrator-outro";
|
||||
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));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
|
||||
import { ZoneDetection } from "@/components/zone/ZoneDetection";
|
||||
@@ -5,22 +6,122 @@ 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);
|
||||
|
||||
useDialoguePlayback({
|
||||
enabled: mainState === "pylon" && step === "approaching",
|
||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.electricOutage,
|
||||
});
|
||||
// ── 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,
|
||||
});
|
||||
|
||||
// ── inspected (demo skip) : jump straight to done after 5 s ─────────────
|
||||
useEffect(() => {
|
||||
if (mainState !== "pylon" || step !== "inspected") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setMissionStep("pylon", "done");
|
||||
}, 5_000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, step, setMissionStep]);
|
||||
|
||||
// ── 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;
|
||||
@@ -45,7 +146,12 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "arrived" || step === "npc-return" || step === "inspected") {
|
||||
if (
|
||||
step === "arrived" ||
|
||||
step === "npc-return" ||
|
||||
step === "inspected" ||
|
||||
step === "done"
|
||||
) {
|
||||
return <PylonFarmerNPC />;
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ export function RepairGame({
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" ? (
|
||||
{step === "done" && mission !== "pylon" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
|
||||
import { HandTrackingFallback } from "@/components/ui/HandTrackingFallback";
|
||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { OutroVideoOverlay } from "@/components/ui/OutroVideoOverlay";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
|
||||
import { HandTrackingTutorial } from "@/components/ui/tutorial/HandTrackingTutorial";
|
||||
@@ -22,6 +23,7 @@ export function GameUI(): React.JSX.Element {
|
||||
<Subtitles />
|
||||
<TalkieDialogueOverlay />
|
||||
<GameSettingsMenu />
|
||||
<OutroVideoOverlay />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const OUTRO_VIDEO_SRC = "/cinematics/outro.mp4";
|
||||
|
||||
/**
|
||||
* Full-screen video overlay that plays once after the outro drone-shot
|
||||
* cinematic ends. Triggered by the "outro-cinematic-complete" window event
|
||||
* dispatched from GameCinematics.tsx.
|
||||
*/
|
||||
export function OutroVideoOverlay(): React.JSX.Element | null {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleCinematicComplete(): void {
|
||||
setVisible(true);
|
||||
}
|
||||
|
||||
window.addEventListener("outro-cinematic-complete", handleCinematicComplete);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"outro-cinematic-complete",
|
||||
handleCinematicComplete,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
void videoRef.current?.play();
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 10000,
|
||||
background: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={OUTRO_VIDEO_SRC}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ export type MissionStep =
|
||||
| "repairing"
|
||||
| "reassembling"
|
||||
| "done"
|
||||
| "narrator-outro";
|
||||
| "narrator-outro"
|
||||
| "electricienne_history";
|
||||
|
||||
export const PYLON_NARRATIVE_STEPS = [
|
||||
"approaching",
|
||||
@@ -109,6 +110,12 @@ export const PYLON_NARRATIVE_STEPS = [
|
||||
"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",
|
||||
@@ -123,6 +130,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -118,7 +118,15 @@ function playCinematic(
|
||||
onUpdate: () => camera.lookAt(target),
|
||||
onComplete: () => {
|
||||
timelineRef.current = null;
|
||||
useGameStore.getState().setCinematicPlaying(false);
|
||||
// During the outro the camera is intentionally left at its final
|
||||
// position — don't release cinematic lock so the player camera system
|
||||
// can't snap it back to the player's eye position.
|
||||
const { mainState } = useGameStore.getState();
|
||||
if (mainState === "outro") {
|
||||
window.dispatchEvent(new CustomEvent("outro-cinematic-complete"));
|
||||
} else {
|
||||
useGameStore.getState().setCinematicPlaying(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ebike } from "@/components/ebike/Ebike";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble";
|
||||
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";
|
||||
@@ -19,7 +20,10 @@ import {
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
|
||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||
import { isPylonNarrativeStep } from "@/types/gameplay/repairMission";
|
||||
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";
|
||||
@@ -90,8 +94,12 @@ export function GameStageContent(): React.JSX.Element {
|
||||
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
|
||||
const repairFocusActive = useRepairFocusStore((state) => state.active);
|
||||
|
||||
const farmStep = useGameStore((state) => state.farm.currentStep);
|
||||
|
||||
const pylonInNarrative =
|
||||
mainState === "pylon" && isPylonNarrativeStep(pylonStep);
|
||||
const farmInNarrative =
|
||||
mainState === "farm" && isFarmNarrativeStep(farmStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -106,10 +114,12 @@ export function GameStageContent(): React.JSX.Element {
|
||||
</>
|
||||
) : 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} />
|
||||
);
|
||||
|
||||
@@ -271,7 +271,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