From b1037d5107357fe8361fcfc741103f5571726bd6 Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:01:48 +0200 Subject: [PATCH] split narrator srt --- public/sounds/dialogue/dialogues.json | 2 +- .../dialogue/subtitles/en/narrateur.srt | 20 ++- .../dialogue/subtitles/fr/narrateur.srt | 20 ++- .../gameplay/farm/FarmNarrativeFlow.tsx | 125 ++++++++++++++++++ .../gameplay/pylon/PylonLightingEffect.tsx | 9 +- .../gameplay/pylon/PylonNarrativeFlow.tsx | 96 +++++++++++++- src/components/three/gameplay/RepairGame.tsx | 2 +- src/managers/stores/useGameStore.ts | 5 +- src/types/gameplay/repairMission.ts | 13 +- src/world/GameStageContent.tsx | 12 +- 10 files changed, 289 insertions(+), 15 deletions(-) create mode 100644 src/components/gameplay/farm/FarmNarrativeFlow.tsx diff --git a/public/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json index f53a902..ffef6f8 100644 --- a/public/sounds/dialogue/dialogues.json +++ b/public/sounds/dialogue/dialogues.json @@ -163,7 +163,7 @@ "id": "narrateur_histoireelectricienne", "voice": "narrateur", "audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3", - "subtitleCueIndex": 23 + "subtitleCueIndices": [23, 25, 26, 27, 28] }, { "id": "narrateur_demande_aide", diff --git a/public/sounds/dialogue/subtitles/en/narrateur.srt b/public/sounds/dialogue/subtitles/en/narrateur.srt index b6f3ffd..2e18c0d 100644 --- a/public/sounds/dialogue/subtitles/en/narrateur.srt +++ b/public/sounds/dialogue/subtitles/en/narrateur.srt @@ -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. diff --git a/public/sounds/dialogue/subtitles/fr/narrateur.srt b/public/sounds/dialogue/subtitles/fr/narrateur.srt index cbb89c2..293065c 100644 --- a/public/sounds/dialogue/subtitles/fr/narrateur.srt +++ b/public/sounds/dialogue/subtitles/fr/narrateur.srt @@ -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. diff --git a/src/components/gameplay/farm/FarmNarrativeFlow.tsx b/src/components/gameplay/farm/FarmNarrativeFlow.tsx new file mode 100644 index 0000000..172defc --- /dev/null +++ b/src/components/gameplay/farm/FarmNarrativeFlow.tsx @@ -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; +} diff --git a/src/components/gameplay/pylon/PylonLightingEffect.tsx b/src/components/gameplay/pylon/PylonLightingEffect.tsx index c71648f..3255eb7 100644 --- a/src/components/gameplay/pylon/PylonLightingEffect.tsx +++ b/src/components/gameplay/pylon/PylonLightingEffect.tsx @@ -19,8 +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) - const isActive = mainState === "pylon" && step !== "locked" && step !== "narrator-outro"; + // 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)); diff --git a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx index cf5bd63..02e2944 100644 --- a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx +++ b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx @@ -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,109 @@ 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((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; diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index c6bc9df..6690602 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -170,7 +170,7 @@ export function RepairGame({ onComplete={() => setMissionStep(mission, "done")} /> ) : null} - {step === "done" ? ( + {step === "done" && mission !== "pylon" ? ( completeMission(mission)} diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 0310f5b..d898828 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -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", }, }; } diff --git a/src/types/gameplay/repairMission.ts b/src/types/gameplay/repairMission.ts index 6a5e6d7..dda6081 100644 --- a/src/types/gameplay/repairMission.ts +++ b/src/types/gameplay/repairMission.ts @@ -93,7 +93,8 @@ export type MissionStep = | "repairing" | "reassembling" | "done" - | "narrator-outro"; + | "narrator-outro" + | "electricienne_history"; export const PYLON_NARRATIVE_STEPS = [ "approaching", @@ -102,6 +103,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", @@ -116,6 +123,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); } diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx index e602ae6..b8e5720 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -1,6 +1,7 @@ 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"; @@ -17,7 +18,10 @@ import { } from "@/data/gameplay/gameStageAnchors"; import { useGameStore } from "@/managers/stores/useGameStore"; 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"; @@ -93,8 +97,12 @@ export function GameStageContent(): React.JSX.Element { 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 ( <> @@ -109,10 +117,12 @@ export function GameStageContent(): React.JSX.Element { ) : null} {mainState === "pylon" ? : null} + {mainState === "farm" ? : 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 ( );