From 53add29a486d9eb4328d970b603e88026bd30e8a Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 9 May 2026 23:30:14 +0100 Subject: [PATCH 01/47] add: type audio playback cat --- src/components/three/gameplay/RepairCaseObject.tsx | 4 +++- src/components/three/interaction/TriggerObject.tsx | 4 +++- src/managers/AudioManager.ts | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/three/gameplay/RepairCaseObject.tsx b/src/components/three/gameplay/RepairCaseObject.tsx index 2307794..97987f2 100644 --- a/src/components/three/gameplay/RepairCaseObject.tsx +++ b/src/components/three/gameplay/RepairCaseObject.tsx @@ -70,7 +70,9 @@ export function RepairCaseObject({ label={open ? "Mallette inspectée" : "Inspecter la mallette"} onTrigger={() => { if (open) return; - AudioManager.getInstance().playSound(REPAIR_CASE_OPEN_SOUND_PATH); + AudioManager.getInstance().playSound(REPAIR_CASE_OPEN_SOUND_PATH, 1, { + category: "sfx", + }); onInspect(); }} > diff --git a/src/components/three/interaction/TriggerObject.tsx b/src/components/three/interaction/TriggerObject.tsx index 77f4cc6..599cc2f 100644 --- a/src/components/three/interaction/TriggerObject.tsx +++ b/src/components/three/interaction/TriggerObject.tsx @@ -69,7 +69,9 @@ export function TriggerObject({ position={position} onPress={() => { if (soundPath) { - AudioManager.getInstance().playSound(soundPath, soundVolume); + AudioManager.getInstance().playSound(soundPath, soundVolume, { + category: "sfx", + }); } onTrigger?.(); diff --git a/src/managers/AudioManager.ts b/src/managers/AudioManager.ts index 6c2a534..e0237b4 100644 --- a/src/managers/AudioManager.ts +++ b/src/managers/AudioManager.ts @@ -1,6 +1,10 @@ import { logger } from "@/utils/core/logger"; +export type AudioCategory = "music" | "sfx" | "dialogue"; +export type OneShotAudioCategory = Exclude; + interface PlaySoundOptions { + category?: OneShotAudioCategory; playbackRate?: number; } @@ -12,6 +16,7 @@ export class AudioManager { private _musicUnlockHandler: (() => void) | null = null; private static readonly MAX_POOL_SIZE_PER_SOUND = 6; + private static readonly DEFAULT_SOUND_CATEGORY: OneShotAudioCategory = "sfx"; private static readonly IGNORED_PLAYBACK_ERRORS = new Set([ "AbortError", "NotAllowedError", @@ -29,6 +34,7 @@ export class AudioManager { playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void { const audio = this._acquireAudio(path); + const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY; audio.volume = Math.max(0, Math.min(1, volume)); audio.playbackRate = options.playbackRate ?? 1; audio.currentTime = 0; @@ -43,6 +49,7 @@ export class AudioManager { logger.error("AudioManager", "Failed to play sound", { path, + category, error: AudioManager._toLogValue(error), }); }); From ce67d07107134b7a5a9f79ad46706a08fccea1ef Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 9 May 2026 23:37:07 +0100 Subject: [PATCH 02/47] add: global cat volumes --- src/managers/AudioManager.ts | 38 +++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/managers/AudioManager.ts b/src/managers/AudioManager.ts index e0237b4..4e5d4bb 100644 --- a/src/managers/AudioManager.ts +++ b/src/managers/AudioManager.ts @@ -3,6 +3,12 @@ import { logger } from "@/utils/core/logger"; export type AudioCategory = "music" | "sfx" | "dialogue"; export type OneShotAudioCategory = Exclude; +const DEFAULT_CATEGORY_VOLUMES: Record = { + music: 1, + sfx: 1, + dialogue: 1, +}; + interface PlaySoundOptions { category?: OneShotAudioCategory; playbackRate?: number; @@ -11,8 +17,12 @@ interface PlaySoundOptions { export class AudioManager { private static _instance: AudioManager | null = null; private readonly _audioPools = new Map(); + private readonly _categoryVolumes: Record = { + ...DEFAULT_CATEGORY_VOLUMES, + }; private _music: HTMLAudioElement | null = null; private _musicPath: string | null = null; + private _musicVolume = 1; private _musicUnlockHandler: (() => void) | null = null; private static readonly MAX_POOL_SIZE_PER_SOUND = 6; @@ -32,10 +42,22 @@ export class AudioManager { private constructor() {} + setCategoryVolume(category: AudioCategory, volume: number): void { + this._categoryVolumes[category] = AudioManager._clampVolume(volume); + + if (category === "music" && this._music) { + this._music.volume = this._getEffectiveVolume("music", this._musicVolume); + } + } + + getCategoryVolume(category: AudioCategory): number { + return this._categoryVolumes[category]; + } + playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void { const audio = this._acquireAudio(path); const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY; - audio.volume = Math.max(0, Math.min(1, volume)); + audio.volume = this._getEffectiveVolume(category, volume); audio.playbackRate = options.playbackRate ?? 1; audio.currentTime = 0; @@ -56,8 +78,10 @@ export class AudioManager { } playMusic(path: string, volume = 1): void { + this._musicVolume = AudioManager._clampVolume(volume); + if (this._musicPath === path && this._music) { - this._music.volume = Math.max(0, Math.min(1, volume)); + this._music.volume = this._getEffectiveVolume("music", this._musicVolume); if (!this._music.paused) return; } else { this.stopMusic(); @@ -66,7 +90,7 @@ export class AudioManager { this._musicPath = path; } - this._music.volume = Math.max(0, Math.min(1, volume)); + this._music.volume = this._getEffectiveVolume("music", this._musicVolume); void this._music.play().catch((error: unknown) => { if ( @@ -151,6 +175,14 @@ export class AudioManager { this._musicUnlockHandler = null; } + private _getEffectiveVolume(category: AudioCategory, volume: number): number { + return AudioManager._clampVolume(volume) * this._categoryVolumes[category]; + } + + private static _clampVolume(volume: number): number { + return Math.max(0, Math.min(1, volume)); + } + private static _toLogValue(error: unknown): Error | DOMException | string { if (error instanceof Error || error instanceof DOMException) { return error; From 974f1e33fb55ecfbf2d50e5b4dc76f246bab4935 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 9 May 2026 23:45:05 +0100 Subject: [PATCH 03/47] add: settings menu + menu store --- src/components/ui/GameSettingsMenu.tsx | 203 ++++++++++++++++++++++++ src/components/ui/GameUI.tsx | 2 + src/index.css | 147 +++++++++++++++++ src/managers/stores/useSettingsStore.ts | 87 ++++++++++ src/world/player/PlayerController.tsx | 14 ++ 5 files changed, 453 insertions(+) create mode 100644 src/components/ui/GameSettingsMenu.tsx create mode 100644 src/managers/stores/useSettingsStore.ts diff --git a/src/components/ui/GameSettingsMenu.tsx b/src/components/ui/GameSettingsMenu.tsx new file mode 100644 index 0000000..e737bb6 --- /dev/null +++ b/src/components/ui/GameSettingsMenu.tsx @@ -0,0 +1,203 @@ +import { useEffect } from "react"; +import { X } from "lucide-react"; +import { useSettingsStore } from "@/managers/stores/useSettingsStore"; +import type { + RepairRuntime, + SubtitleLanguage, +} from "@/managers/stores/useSettingsStore"; + +function formatPercent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function clearCookies(): void { + document.cookie.split(";").forEach((cookie) => { + const cookieName = cookie.split("=")[0]?.trim(); + if (!cookieName) return; + + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + }); +} + +interface VolumeSliderProps { + id: string; + label: string; + value: number; + onChange: (value: number) => void; +} + +function VolumeSlider({ + id, + label, + value, + onChange, +}: VolumeSliderProps): React.JSX.Element { + return ( + + ); +} + +export function GameSettingsMenu(): React.JSX.Element | null { + const { + isSettingsMenuOpen, + musicVolume, + sfxVolume, + dialogueVolume, + subtitlesEnabled, + subtitleLanguage, + repairRuntime, + setMusicVolume, + setSfxVolume, + setDialogueVolume, + setSettingsMenuOpen, + setSubtitlesEnabled, + setSubtitleLanguage, + setRepairRuntime, + } = useSettingsStore(); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (!isSettingsMenuOpen) document.exitPointerLock(); + setSettingsMenuOpen(!isSettingsMenuOpen); + return; + } + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }; + }, [isSettingsMenuOpen, setSettingsMenuOpen]); + + if (!isSettingsMenuOpen) return null; + + const handleQuit = (): void => { + clearCookies(); + window.location.assign("/"); + }; + + return ( +
+
+
+
+ Pause +

Options

+
+ +
+ +
+

Audio

+ + + +
+ +
+

Sous-titres

+ + +
+ {(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => ( + + ))} +
+
+ +
+

Repair game

+
+ {(["js", "python"] satisfies RepairRuntime[]).map((runtime) => ( + + ))} +
+
+ + +
+
+ ); +} diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx index 6b3482a..0525e48 100644 --- a/src/components/ui/GameUI.tsx +++ b/src/components/ui/GameUI.tsx @@ -1,5 +1,6 @@ import { Crosshair } from "@/components/ui/Crosshair"; import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout"; +import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu"; import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; @@ -10,6 +11,7 @@ export function GameUI(): React.JSX.Element { + ); } diff --git a/src/index.css b/src/index.css index 6ea0915..5efb0c9 100644 --- a/src/index.css +++ b/src/index.css @@ -397,6 +397,153 @@ canvas { letter-spacing: 0.03em; } +/* In-game settings menu */ +.game-settings-menu { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: 20px; + background: rgba(0, 0, 0, 0.6); + color: #ffffff; + pointer-events: auto; + backdrop-filter: blur(10px); +} + +.game-settings-menu__panel { + width: min(460px, 100%); + max-height: calc(100vh - 40px); + overflow-y: auto; + padding: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 24px; + background: rgba(8, 8, 8, 0.94); + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55); +} + +.game-settings-menu__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 4px 4px 16px; +} + +.game-settings-menu__header span { + color: #8f8f8f; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.game-settings-menu__header h2 { + margin: 0.25rem 0 0; + font-size: 1.8rem; + letter-spacing: -0.06em; +} + +.game-settings-menu__close { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + background: #111111; + color: #ffffff; + cursor: pointer; +} + +.game-settings-menu__section { + display: grid; + gap: 12px; + padding: 16px 4px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.game-settings-menu__section h3 { + margin: 0; + color: #d7d7d7; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.game-settings-menu__slider { + display: grid; + gap: 8px; +} + +.game-settings-menu__slider span, +.game-settings-menu__checkbox { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #f2f2f2; + font-size: 0.9rem; + font-weight: 650; +} + +.game-settings-menu__slider strong { + color: #8f8f8f; + font-size: 0.78rem; +} + +.game-settings-menu__slider input[type="range"] { + width: 100%; + accent-color: #ffffff; +} + +.game-settings-menu__checkbox { + justify-content: flex-start; + cursor: pointer; +} + +.game-settings-menu__checkbox input { + width: 18px; + height: 18px; + accent-color: #ffffff; +} + +.game-settings-menu__choice-group { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.game-settings-menu__choice-group--stacked { + grid-template-columns: 1fr; +} + +.game-settings-menu__choice-group button, +.game-settings-menu__quit { + width: 100%; + padding: 11px 12px; + border: 1px solid #242424; + border-radius: 14px; + background: #101010; + color: #f2f2f2; + cursor: pointer; + font-size: 0.88rem; + font-weight: 680; +} + +.game-settings-menu__choice-group button.active { + border-color: #ffffff; + background: #ffffff; + color: #050505; +} + +.game-settings-menu__quit { + margin-top: 8px; + border-color: rgba(248, 113, 113, 0.35); + color: #fecaca; +} + /* Debug overlay panels */ .debug-overlay-layout { position: fixed; diff --git a/src/managers/stores/useSettingsStore.ts b/src/managers/stores/useSettingsStore.ts new file mode 100644 index 0000000..414be31 --- /dev/null +++ b/src/managers/stores/useSettingsStore.ts @@ -0,0 +1,87 @@ +import { create } from "zustand"; +import { AudioManager } from "@/managers/AudioManager"; +import type { AudioCategory } from "@/managers/AudioManager"; + +export type SubtitleLanguage = "fr" | "en"; +export type RepairRuntime = "js" | "python"; + +interface SettingsState { + isSettingsMenuOpen: boolean; + musicVolume: number; + sfxVolume: number; + dialogueVolume: number; + subtitlesEnabled: boolean; + subtitleLanguage: SubtitleLanguage; + repairRuntime: RepairRuntime; +} + +interface SettingsActions { + setSettingsMenuOpen: (open: boolean) => void; + setMusicVolume: (volume: number) => void; + setSfxVolume: (volume: number) => void; + setDialogueVolume: (volume: number) => void; + setSubtitlesEnabled: (enabled: boolean) => void; + setSubtitleLanguage: (language: SubtitleLanguage) => void; + setRepairRuntime: (runtime: RepairRuntime) => void; + resetSettings: () => void; +} + +type SettingsStore = SettingsState & SettingsActions; + +const DEFAULT_SETTINGS: SettingsState = { + isSettingsMenuOpen: false, + musicVolume: 1, + sfxVolume: 1, + dialogueVolume: 1, + subtitlesEnabled: true, + subtitleLanguage: "fr", + repairRuntime: "js", +}; + +function clampVolume(volume: number): number { + return Math.max(0, Math.min(1, volume)); +} + +function setAudioCategoryVolume( + category: AudioCategory, + volume: number, +): number { + const nextVolume = clampVolume(volume); + AudioManager.getInstance().setCategoryVolume(category, nextVolume); + return nextVolume; +} + +function applyDefaultAudioSettings(): void { + AudioManager.getInstance().setCategoryVolume( + "music", + DEFAULT_SETTINGS.musicVolume, + ); + AudioManager.getInstance().setCategoryVolume( + "sfx", + DEFAULT_SETTINGS.sfxVolume, + ); + AudioManager.getInstance().setCategoryVolume( + "dialogue", + DEFAULT_SETTINGS.dialogueVolume, + ); +} + +applyDefaultAudioSettings(); + +export const useSettingsStore = create()((set) => ({ + ...DEFAULT_SETTINGS, + setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }), + setMusicVolume: (volume) => + set({ musicVolume: setAudioCategoryVolume("music", volume) }), + setSfxVolume: (volume) => + set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }), + setDialogueVolume: (volume) => + set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }), + setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }), + setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }), + setRepairRuntime: (repairRuntime) => set({ repairRuntime }), + resetSettings: () => { + applyDefaultAudioSettings(); + set(DEFAULT_SETTINGS); + }, +})); diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index 6467d84..d7c3e32 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -24,6 +24,7 @@ import { PLAYER_XZ_DAMPING_FACTOR, } from "@/data/player/playerConfig"; import { InteractionManager } from "@/managers/InteractionManager"; +import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import type { Vector3Tuple } from "@/types/three/three"; type Keys = { @@ -108,6 +109,8 @@ export function PlayerController({ const interaction = InteractionManager.getInstance(); const handleKeyDown = (event: KeyboardEvent): void => { + if (useSettingsStore.getState().isSettingsMenuOpen) return; + if (setMovementKey(keys.current, event.key, true)) { event.preventDefault(); return; @@ -128,12 +131,15 @@ export function PlayerController({ }; const handleKeyUp = (event: KeyboardEvent): void => { + if (useSettingsStore.getState().isSettingsMenuOpen) return; + if (setMovementKey(keys.current, event.key, false)) { event.preventDefault(); } }; const handleMouseDown = (event: MouseEvent): void => { + if (useSettingsStore.getState().isSettingsMenuOpen) return; if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return; if (interaction.getState().focused?.kind === "grab") { interaction.pressInteract(); @@ -141,6 +147,7 @@ export function PlayerController({ }; const handleMouseUp = (event: MouseEvent): void => { + if (useSettingsStore.getState().isSettingsMenuOpen) return; if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return; if (interaction.getState().holding) { interaction.releaseInteract(); @@ -162,6 +169,13 @@ export function PlayerController({ }, []); useFrame((_, delta) => { + if (useSettingsStore.getState().isSettingsMenuOpen) { + keys.current = { ...DEFAULT_KEYS }; + velocity.current.set(0, 0, 0); + wantsJump.current = false; + return; + } + const dt = Math.min(delta, PLAYER_MAX_DELTA); camera.getWorldDirection(_forward); From 5b43f503ac7e0280f192684186ab06dff28fee72 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 9 May 2026 23:51:22 +0100 Subject: [PATCH 04/47] add: basic subtitle --- src/components/ui/GameUI.tsx | 2 ++ src/components/ui/Subtitles.tsx | 33 ++++++++++++++++++++++++++ src/index.css | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/components/ui/Subtitles.tsx diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx index 0525e48..a4b0228 100644 --- a/src/components/ui/GameUI.tsx +++ b/src/components/ui/GameUI.tsx @@ -3,6 +3,7 @@ import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout"; import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu"; import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; +import { Subtitles } from "@/components/ui/Subtitles"; export function GameUI(): React.JSX.Element { return ( @@ -11,6 +12,7 @@ export function GameUI(): React.JSX.Element { + ); diff --git a/src/components/ui/Subtitles.tsx b/src/components/ui/Subtitles.tsx new file mode 100644 index 0000000..db67d37 --- /dev/null +++ b/src/components/ui/Subtitles.tsx @@ -0,0 +1,33 @@ +import { useSettingsStore } from "@/managers/stores/useSettingsStore"; + +export type SubtitleSpeaker = "Narrateur" | "Fermier" | "Leonie"; + +interface SubtitlesProps { + speaker?: SubtitleSpeaker | null; + text?: string | null; +} + +export function Subtitles({ + speaker = null, + text = null, +}: SubtitlesProps): React.JSX.Element | null { + const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled); + const content = text?.trim(); + + if (!subtitlesEnabled || !content) return null; + + return ( +
+

+ {speaker ? ( + + {speaker}: + + ) : null} + {content} +

+
+ ); +} diff --git a/src/index.css b/src/index.css index 5efb0c9..8af92a5 100644 --- a/src/index.css +++ b/src/index.css @@ -397,6 +397,47 @@ canvas { letter-spacing: 0.03em; } +/* Subtitles */ +.subtitles { + position: fixed; + left: 50%; + bottom: 7vh; + z-index: 15; + width: min(780px, calc(100vw - 32px)); + transform: translateX(-50%); + pointer-events: none; +} + +.subtitles p { + margin: 0; + padding: 12px 16px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.82); + color: #ffffff; + font-size: clamp(1rem, 2vw, 1.25rem); + font-weight: 650; + line-height: 1.45; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); +} + +.subtitles__speaker { + margin-right: 0.35em; + font-weight: 800; +} + +.subtitles__speaker--narrateur { + color: #7dd3fc; +} + +.subtitles__speaker--fermier { + color: #86efac; +} + +.subtitles__speaker--leonie { + color: #f9a8d4; +} + /* In-game settings menu */ .game-settings-menu { position: fixed; From d9525b0aaf97c5a42050fd30842f1e47acd528e4 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 9 May 2026 23:53:19 +0100 Subject: [PATCH 05/47] add: parser srt files --- src/utils/subtitles/parseSrt.ts | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/utils/subtitles/parseSrt.ts diff --git a/src/utils/subtitles/parseSrt.ts b/src/utils/subtitles/parseSrt.ts new file mode 100644 index 0000000..ec58014 --- /dev/null +++ b/src/utils/subtitles/parseSrt.ts @@ -0,0 +1,62 @@ +export interface SubtitleCue { + index: number; + startTime: number; + endTime: number; + text: string; +} + +const SRT_TIME_SEPARATOR = " --> "; +const SRT_TIME_PATTERN = /^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/; + +export function parseSrt(srtContent: string): SubtitleCue[] { + return srtContent + .replace(/^\uFEFF/, "") + .replace(/\r/g, "") + .trim() + .split(/\n{2,}/) + .map(parseSrtBlock) + .filter((cue): cue is SubtitleCue => cue !== null); +} + +function parseSrtBlock(block: string): SubtitleCue | null { + const lines = block + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length < 3) return null; + + const index = Number(lines[0]); + if (!Number.isInteger(index)) return null; + + const [start, end] = lines[1]?.split(SRT_TIME_SEPARATOR) ?? []; + if (!start || !end) return null; + + const startTime = parseSrtTime(start); + const endTime = parseSrtTime(end); + if (startTime === null || endTime === null || endTime <= startTime) { + return null; + } + + return { + index, + startTime, + endTime, + text: lines.slice(2).join("\n"), + }; +} + +function parseSrtTime(value: string): number | null { + const match = value.match(SRT_TIME_PATTERN); + if (!match) return null; + + const [, hours, minutes, seconds, milliseconds] = match; + if (!hours || !minutes || !seconds || !milliseconds) return null; + + return ( + Number(hours) * 3600 + + Number(minutes) * 60 + + Number(seconds) + + Number(milliseconds) / 1000 + ); +} From f5bc7cb08e92b22796d302dd9be182d8043ca551 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 10 May 2026 00:00:36 +0100 Subject: [PATCH 06/47] add: dialoguejson --- public/sounds/dialogue/dialogues.json | 188 ++++++++++++++++++ src/types/dialogues/dialogues.ts | 24 +++ .../dialogues/dialogueManifestValidation.ts | 140 +++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 public/sounds/dialogue/dialogues.json create mode 100644 src/types/dialogues/dialogues.ts create mode 100644 src/utils/dialogues/dialogueManifestValidation.ts diff --git a/public/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json new file mode 100644 index 0000000..ada518e --- /dev/null +++ b/public/sounds/dialogue/dialogues.json @@ -0,0 +1,188 @@ +{ + "version": 1, + "voices": [ + { + "id": "narrateur", + "speaker": "Narrateur", + "subtitles": { + "fr": "/sounds/dialogue/subtitles/fr/narrateur.srt", + "en": "/sounds/dialogue/subtitles/en/narrateur.srt" + } + }, + { + "id": "fermier", + "speaker": "Fermier", + "subtitles": { + "fr": "/sounds/dialogue/subtitles/fr/fermier.srt", + "en": "/sounds/dialogue/subtitles/en/fermier.srt" + } + }, + { + "id": "leonie", + "speaker": "Leonie", + "subtitles": { + "fr": "/sounds/dialogue/subtitles/fr/leonie.srt", + "en": "/sounds/dialogue/subtitles/en/leonie.srt" + } + } + ], + "dialogues": [ + { + "id": "narrateur_bienvenueaaltera", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3", + "subtitleCueIndex": 1, + "timecode": 0 + }, + { + "id": "narrateur_intro_prenom", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_intro_prenom.mp3", + "subtitleCueIndex": 2 + }, + { + "id": "narrateur_intro_apresprenom", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_intro_apresprenom.mp3", + "subtitleCueIndex": 3 + }, + { + "id": "narrateur_ordreebike", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_ordreebike.mp3", + "subtitleCueIndex": 4 + }, + { + "id": "narrateur_ebikecasse", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_ebikecassé.mp3", + "subtitleCueIndex": 5 + }, + { + "id": "narrateur_galetscan", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_galetscan.mp3", + "subtitleCueIndex": 6 + }, + { + "id": "narrateur_ebikerepare", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_ebikeréparé.mp3", + "subtitleCueIndex": 7 + }, + { + "id": "narrateur_ordredemandedelaide", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_ordredemandedelaide.mp3", + "subtitleCueIndex": 8 + }, + { + "id": "narrateur_coupureelec", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_coupureélec.mp3", + "subtitleCueIndex": 9 + }, + { + "id": "narrateur_poteaueleccasse", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3", + "subtitleCueIndex": 10 + }, + { + "id": "narrateur_courantrepare", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_courantréparé.mp3", + "subtitleCueIndex": 11 + }, + { + "id": "narrateur_routeversferme", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_routeversferme.mp3", + "subtitleCueIndex": 12 + }, + { + "id": "narrateur_arriveferme", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_arrivéferme.mp3", + "subtitleCueIndex": 13 + }, + { + "id": "narrateur_fouillelecentre", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_fouillelecentre.mp3", + "subtitleCueIndex": 14 + }, + { + "id": "narrateur_interactiontuyauxlac", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_interactiontuyauxlac.mp3", + "subtitleCueIndex": 15 + }, + { + "id": "narrateur_interactionrefroidisseur", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_interactionrefroidisseur.mp3", + "subtitleCueIndex": 16 + }, + { + "id": "narrateur_refroidisseurcasse", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_refroidisseurcassé.mp3", + "subtitleCueIndex": 17 + }, + { + "id": "narrateur_createurdepluiecree", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_createurdepluiecréé.mp3", + "subtitleCueIndex": 18 + }, + { + "id": "narrateur_remerciement", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_remerciement.mp3", + "subtitleCueIndex": 19 + }, + { + "id": "narrateur_bonnechance", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_bonnechance.mp3", + "subtitleCueIndex": 20 + }, + { + "id": "narrateur_presentationatelier", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_présentationatelier.mp3", + "subtitleCueIndex": 21 + }, + { + "id": "narrateur_presentationoutils", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_présentationoutils.mp3", + "subtitleCueIndex": 22 + }, + { + "id": "narrateur_histoireleonie", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur_histoireleonie.mp3", + "subtitleCueIndex": 23 + }, + { + "id": "fermier_coupdemain", + "voice": "fermier", + "audio": "/sounds/dialogue/fermier_coupdemain.mp3", + "subtitleCueIndex": 1 + }, + { + "id": "fermier_coupdemain_2", + "voice": "fermier", + "audio": "/sounds/dialogue/fermier_coupdemain_2.mp3", + "subtitleCueIndex": 2 + }, + { + "id": "fermier_findemission", + "voice": "fermier", + "audio": "/sounds/dialogue/fermier_findemission.mp3", + "subtitleCueIndex": 3 + } + ] +} diff --git a/src/types/dialogues/dialogues.ts b/src/types/dialogues/dialogues.ts new file mode 100644 index 0000000..33a1b48 --- /dev/null +++ b/src/types/dialogues/dialogues.ts @@ -0,0 +1,24 @@ +import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore"; + +export type DialogueVoiceId = "narrateur" | "fermier" | "leonie"; +export type DialogueSpeaker = "Narrateur" | "Fermier" | "Leonie"; + +export interface DialogueVoice { + id: DialogueVoiceId; + speaker: DialogueSpeaker; + subtitles: Partial>; +} + +export interface DialogueDefinition { + id: string; + voice: DialogueVoiceId; + audio: string; + subtitleCueIndex: number; + timecode?: number; +} + +export interface DialogueManifest { + version: 1; + voices: DialogueVoice[]; + dialogues: DialogueDefinition[]; +} diff --git a/src/utils/dialogues/dialogueManifestValidation.ts b/src/utils/dialogues/dialogueManifestValidation.ts new file mode 100644 index 0000000..e23954a --- /dev/null +++ b/src/utils/dialogues/dialogueManifestValidation.ts @@ -0,0 +1,140 @@ +import type { + DialogueDefinition, + DialogueManifest, + DialogueSpeaker, + DialogueVoice, + DialogueVoiceId, +} from "@/types/dialogues/dialogues"; + +const VALID_VOICE_IDS = new Set([ + "narrateur", + "fermier", + "leonie", +]); +const VALID_SPEAKERS = new Set([ + "Narrateur", + "Fermier", + "Leonie", +]); + +export function parseDialogueManifest(data: unknown): DialogueManifest { + if (!isRecord(data)) { + throw new Error("Dialogue manifest must be an object"); + } + + if (data.version !== 1) { + throw new Error("Unsupported dialogue manifest version"); + } + + if (!Array.isArray(data.voices) || !Array.isArray(data.dialogues)) { + throw new Error("Dialogue manifest requires voices and dialogues arrays"); + } + + const voices = data.voices.map(parseDialogueVoice); + const voiceIds = new Set(voices.map((voice) => voice.id)); + const dialogues = data.dialogues.map((dialogue) => + parseDialogueDefinition(dialogue, voiceIds), + ); + + return { + version: 1, + voices, + dialogues, + }; +} + +function parseDialogueVoice(data: unknown): DialogueVoice { + if (!isRecord(data)) { + throw new Error("Dialogue voice must be an object"); + } + + if (!isDialogueVoiceId(data.id)) { + throw new Error("Dialogue voice has an invalid id"); + } + + if (!isDialogueSpeaker(data.speaker)) { + throw new Error(`Dialogue voice ${data.id} has an invalid speaker`); + } + + if (!isRecord(data.subtitles)) { + throw new Error(`Dialogue voice ${data.id} must define subtitles`); + } + + const subtitles: DialogueVoice["subtitles"] = {}; + const frSubtitle = getOptionalPath(data.subtitles.fr); + const enSubtitle = getOptionalPath(data.subtitles.en); + if (frSubtitle) subtitles.fr = frSubtitle; + if (enSubtitle) subtitles.en = enSubtitle; + + return { + id: data.id, + speaker: data.speaker, + subtitles, + }; +} + +function parseDialogueDefinition( + data: unknown, + voiceIds: Set, +): DialogueDefinition { + if (!isRecord(data)) { + throw new Error("Dialogue definition must be an object"); + } + + if (typeof data.id !== "string" || data.id.length === 0) { + throw new Error("Dialogue definition has an invalid id"); + } + + if (!isDialogueVoiceId(data.voice) || !voiceIds.has(data.voice)) { + throw new Error(`Dialogue ${data.id} references an unknown voice`); + } + + if (typeof data.audio !== "string" || data.audio.length === 0) { + throw new Error(`Dialogue ${data.id} has an invalid audio path`); + } + + const subtitleCueIndex = data.subtitleCueIndex; + if ( + typeof subtitleCueIndex !== "number" || + !Number.isInteger(subtitleCueIndex) || + subtitleCueIndex < 1 + ) { + throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`); + } + + const timecode = data.timecode; + if (timecode !== undefined && typeof timecode !== "number") { + throw new Error(`Dialogue ${data.id} has an invalid timecode`); + } + + const dialogue: DialogueDefinition = { + id: data.id, + voice: data.voice, + audio: data.audio, + subtitleCueIndex, + }; + + if (timecode !== undefined) dialogue.timecode = timecode; + + return dialogue; +} + +function getOptionalPath(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function isDialogueVoiceId(value: unknown): value is DialogueVoiceId { + return ( + typeof value === "string" && VALID_VOICE_IDS.has(value as DialogueVoiceId) + ); +} + +function isDialogueSpeaker(value: unknown): value is DialogueSpeaker { + return ( + typeof value === "string" && VALID_SPEAKERS.has(value as DialogueSpeaker) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} From 1c30b73253116764940efcf234d2cef30b81165d Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 10 May 2026 00:02:48 +0100 Subject: [PATCH 07/47] add: load dialohue manifest --- src/utils/dialogues/loadDialogueManifest.ts | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/utils/dialogues/loadDialogueManifest.ts diff --git a/src/utils/dialogues/loadDialogueManifest.ts b/src/utils/dialogues/loadDialogueManifest.ts new file mode 100644 index 0000000..cdfe23d --- /dev/null +++ b/src/utils/dialogues/loadDialogueManifest.ts @@ -0,0 +1,49 @@ +import type { + DialogueDefinition, + DialogueManifest, + DialogueVoice, +} from "@/types/dialogues/dialogues"; +import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore"; +import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation"; + +const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json"; +const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr"; + +export async function loadDialogueManifest(): Promise { + const response = await fetch(DIALOGUE_MANIFEST_PATH); + + if (!response.ok) { + return null; + } + + return parseDialogueManifest(await response.json()); +} + +export function resolveDialogueSubtitlePath( + manifest: DialogueManifest, + dialogue: DialogueDefinition, + language: SubtitleLanguage, +): string | null { + const voice = getDialogueVoice(manifest, dialogue.voice); + if (!voice) return null; + + return getVoiceSubtitlePath(voice, language); +} + +export function getDialogueVoice( + manifest: DialogueManifest, + voiceId: DialogueDefinition["voice"], +): DialogueVoice | null { + return manifest.voices.find((voice) => voice.id === voiceId) ?? null; +} + +function getVoiceSubtitlePath( + voice: DialogueVoice, + language: SubtitleLanguage, +): string | null { + return ( + voice.subtitles[language] ?? + voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE] ?? + null + ); +} From 8ef1da0e9a69ed0e383774f157df2ec94522ec73 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 10 May 2026 00:04:59 +0100 Subject: [PATCH 08/47] upatde: load dialogue en fonction du language --- src/utils/dialogues/loadDialogueManifest.ts | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/utils/dialogues/loadDialogueManifest.ts b/src/utils/dialogues/loadDialogueManifest.ts index cdfe23d..2387a12 100644 --- a/src/utils/dialogues/loadDialogueManifest.ts +++ b/src/utils/dialogues/loadDialogueManifest.ts @@ -5,10 +5,18 @@ import type { } from "@/types/dialogues/dialogues"; import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore"; import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation"; +import { parseSrt } from "@/utils/subtitles/parseSrt"; +import type { SubtitleCue } from "@/utils/subtitles/parseSrt"; const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json"; const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr"; +export interface DialogueSubtitleCue { + voice: DialogueVoice; + cue: SubtitleCue; + subtitlePath: string; +} + export async function loadDialogueManifest(): Promise { const response = await fetch(DIALOGUE_MANIFEST_PATH); @@ -37,6 +45,65 @@ export function getDialogueVoice( return manifest.voices.find((voice) => voice.id === voiceId) ?? null; } +export async function loadDialogueSubtitleCue( + manifest: DialogueManifest, + dialogue: DialogueDefinition, + language: SubtitleLanguage, +): Promise { + const voice = getDialogueVoice(manifest, dialogue.voice); + if (!voice) return null; + + const subtitles = await loadVoiceSubtitleCues(voice, language); + if (!subtitles) return null; + + const cue = subtitles.cues.find( + (item) => item.index === dialogue.subtitleCueIndex, + ); + + if (!cue) return null; + + return { + voice, + cue, + subtitlePath: subtitles.path, + }; +} + +export async function loadVoiceSubtitleCues( + voice: DialogueVoice, + language: SubtitleLanguage, +): Promise<{ path: string; cues: SubtitleCue[] } | null> { + const paths = getVoiceSubtitlePaths(voice, language); + + for (const path of paths) { + const srtContent = await loadSrtContent(path); + if (srtContent !== null) { + return { path, cues: parseSrt(srtContent) }; + } + } + + return null; +} + +async function loadSrtContent(path: string): Promise { + const response = await fetch(path); + + if (!response.ok) { + return null; + } + + return response.text(); +} + +function getVoiceSubtitlePaths( + voice: DialogueVoice, + language: SubtitleLanguage, +): string[] { + return [voice.subtitles[language], voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE]] + .filter((path): path is string => Boolean(path)) + .filter((path, index, paths) => paths.indexOf(path) === index); +} + function getVoiceSubtitlePath( voice: DialogueVoice, language: SubtitleLanguage, From 53fdf3cb1e1bc02d20c0ced2d4985a2878614a98 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 10 May 2026 00:07:56 +0100 Subject: [PATCH 09/47] update: play audio + srt sync --- src/components/ui/Subtitles.tsx | 14 +++--- src/managers/AudioManager.ts | 8 +++- src/managers/stores/useSubtitleStore.ts | 24 ++++++++++ src/utils/dialogues/playDialogue.ts | 60 +++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 src/managers/stores/useSubtitleStore.ts create mode 100644 src/utils/dialogues/playDialogue.ts diff --git a/src/components/ui/Subtitles.tsx b/src/components/ui/Subtitles.tsx index db67d37..8e338e3 100644 --- a/src/components/ui/Subtitles.tsx +++ b/src/components/ui/Subtitles.tsx @@ -1,6 +1,8 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; +import type { DialogueSpeaker } from "@/types/dialogues/dialogues"; -export type SubtitleSpeaker = "Narrateur" | "Fermier" | "Leonie"; +export type SubtitleSpeaker = DialogueSpeaker; interface SubtitlesProps { speaker?: SubtitleSpeaker | null; @@ -12,18 +14,20 @@ export function Subtitles({ text = null, }: SubtitlesProps): React.JSX.Element | null { const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled); - const content = text?.trim(); + const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle); + const subtitleSpeaker = speaker ?? activeSubtitle?.speaker ?? null; + const content = (text ?? activeSubtitle?.text)?.trim(); if (!subtitlesEnabled || !content) return null; return (

- {speaker ? ( + {subtitleSpeaker ? ( - {speaker}: + {subtitleSpeaker}: ) : null} {content} diff --git a/src/managers/AudioManager.ts b/src/managers/AudioManager.ts index 4e5d4bb..5ac796b 100644 --- a/src/managers/AudioManager.ts +++ b/src/managers/AudioManager.ts @@ -54,7 +54,11 @@ export class AudioManager { return this._categoryVolumes[category]; } - playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void { + playSound( + path: string, + volume = 1, + options: PlaySoundOptions = {}, + ): HTMLAudioElement { const audio = this._acquireAudio(path); const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY; audio.volume = this._getEffectiveVolume(category, volume); @@ -75,6 +79,8 @@ export class AudioManager { error: AudioManager._toLogValue(error), }); }); + + return audio; } playMusic(path: string, volume = 1): void { diff --git a/src/managers/stores/useSubtitleStore.ts b/src/managers/stores/useSubtitleStore.ts new file mode 100644 index 0000000..e7766ec --- /dev/null +++ b/src/managers/stores/useSubtitleStore.ts @@ -0,0 +1,24 @@ +import { create } from "zustand"; +import type { DialogueSpeaker } from "@/types/dialogues/dialogues"; + +interface ActiveSubtitle { + speaker: DialogueSpeaker; + text: string; +} + +interface SubtitleState { + activeSubtitle: ActiveSubtitle | null; +} + +interface SubtitleActions { + setActiveSubtitle: (subtitle: ActiveSubtitle | null) => void; + clearActiveSubtitle: () => void; +} + +type SubtitleStore = SubtitleState & SubtitleActions; + +export const useSubtitleStore = create()((set) => ({ + activeSubtitle: null, + setActiveSubtitle: (activeSubtitle) => set({ activeSubtitle }), + clearActiveSubtitle: () => set({ activeSubtitle: null }), +})); diff --git a/src/utils/dialogues/playDialogue.ts b/src/utils/dialogues/playDialogue.ts new file mode 100644 index 0000000..5c4d7da --- /dev/null +++ b/src/utils/dialogues/playDialogue.ts @@ -0,0 +1,60 @@ +import { AudioManager } from "@/managers/AudioManager"; +import { useSettingsStore } from "@/managers/stores/useSettingsStore"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; +import type { DialogueManifest } from "@/types/dialogues/dialogues"; +import { loadDialogueSubtitleCue } from "@/utils/dialogues/loadDialogueManifest"; + +export async function playDialogueById( + manifest: DialogueManifest, + dialogueId: string, +): Promise { + const dialogue = manifest.dialogues.find((item) => item.id === dialogueId); + if (!dialogue) return null; + + const subtitleLanguage = useSettingsStore.getState().subtitleLanguage; + const subtitle = await loadDialogueSubtitleCue( + manifest, + dialogue, + subtitleLanguage, + ); + const audio = AudioManager.getInstance().playSound(dialogue.audio, 1, { + category: "dialogue", + }); + + if (!subtitle) return audio; + + const clearSubtitle = (): void => { + useSubtitleStore.getState().clearActiveSubtitle(); + }; + + const cleanup = (): void => { + audio.removeEventListener("timeupdate", syncSubtitle); + audio.removeEventListener("ended", cleanup); + audio.removeEventListener("pause", cleanup); + clearSubtitle(); + }; + + const syncSubtitle = (): void => { + const currentTime = audio.currentTime; + const shouldShowSubtitle = + currentTime >= subtitle.cue.startTime && + currentTime <= subtitle.cue.endTime; + + if (shouldShowSubtitle) { + useSubtitleStore.getState().setActiveSubtitle({ + speaker: subtitle.voice.speaker, + text: subtitle.cue.text, + }); + return; + } + + clearSubtitle(); + }; + + audio.addEventListener("timeupdate", syncSubtitle); + audio.addEventListener("ended", cleanup); + audio.addEventListener("pause", cleanup); + syncSubtitle(); + + return audio; +} From 0fbf6bfa0e8ffd03ee219e1e21b386e672f28d0f Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 10 May 2026 00:10:16 +0100 Subject: [PATCH 10/47] add: trigger dialogue with timecode --- src/world/GameDialogues.tsx | 59 +++++++++++++++++++++++++++++++++++++ src/world/World.tsx | 2 ++ 2 files changed, 61 insertions(+) create mode 100644 src/world/GameDialogues.tsx diff --git a/src/world/GameDialogues.tsx b/src/world/GameDialogues.tsx new file mode 100644 index 0000000..845cd20 --- /dev/null +++ b/src/world/GameDialogues.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from "react"; +import { useFrame } from "@react-three/fiber"; +import type { DialogueManifest } from "@/types/dialogues/dialogues"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { playDialogueById } from "@/utils/dialogues/playDialogue"; +import { logger } from "@/utils/core/logger"; + +export function GameDialogues(): null { + const [manifest, setManifest] = useState(null); + const playedDialoguesRef = useRef(new Set()); + const activeAudiosRef = useRef(new Set()); + + useEffect(() => { + let mounted = true; + const activeAudios = activeAudiosRef.current; + + void loadDialogueManifest() + .then((loadedManifest) => { + if (mounted) setManifest(loadedManifest); + }) + .catch((error: unknown) => { + logger.error("GameDialogues", "Failed to load dialogue manifest", { + error: error instanceof Error ? error : String(error), + }); + }); + + return () => { + mounted = false; + activeAudios.forEach((audio) => audio.pause()); + activeAudios.clear(); + }; + }, []); + + useFrame(({ clock }) => { + if (!manifest) return; + + const elapsedTime = clock.getElapsedTime(); + + manifest.dialogues.forEach((dialogue) => { + if (dialogue.timecode === undefined) return; + if (dialogue.timecode > elapsedTime) return; + if (playedDialoguesRef.current.has(dialogue.id)) return; + + playedDialoguesRef.current.add(dialogue.id); + + void playDialogueById(manifest, dialogue.id).then((audio) => { + if (!audio) return; + activeAudiosRef.current.add(audio); + audio.addEventListener( + "ended", + () => activeAudiosRef.current.delete(audio), + { once: true }, + ); + }); + }); + }); + + return null; +} diff --git a/src/world/World.tsx b/src/world/World.tsx index 4737abb..eb22071 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -10,6 +10,7 @@ import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControl import { DebugHelpers } from "@/components/debug/scene/DebugHelpers"; import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove"; import { Environment } from "@/world/Environment"; +import { GameDialogues } from "@/world/GameDialogues"; import { GameMusic } from "@/world/GameMusic"; import { Lighting } from "@/world/Lighting"; import { GameMap } from "@/world/GameMap"; @@ -42,6 +43,7 @@ export function World(): React.JSX.Element { {sceneMode === "game" ? ( <> + From 0c8b9070bbdbd814c1a6d646bf940f6c38d01328 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 10 May 2026 00:13:42 +0100 Subject: [PATCH 11/47] add: add str editing panel --- src/components/editor/EditorControls.tsx | 3 + src/components/editor/EditorSrtPanel.tsx | 161 +++++++++++++++++++++++ src/index.css | 71 ++++++++++ 3 files changed, 235 insertions(+) create mode 100644 src/components/editor/EditorSrtPanel.tsx diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx index 143d666..e441db0 100644 --- a/src/components/editor/EditorControls.tsx +++ b/src/components/editor/EditorControls.tsx @@ -12,6 +12,7 @@ import { Save, Undo2, } from "lucide-react"; +import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel"; import type { MapNode, TransformMode } from "@/types/editor/editor"; interface EditorControlsProps { @@ -236,6 +237,8 @@ export function EditorControls({ : `Selected node ${selectedNodeIndex + 1} raw lines`}

+ + ); diff --git a/src/components/editor/EditorSrtPanel.tsx b/src/components/editor/EditorSrtPanel.tsx new file mode 100644 index 0000000..4ee739e --- /dev/null +++ b/src/components/editor/EditorSrtPanel.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from "react"; +import { Download, RefreshCw } from "lucide-react"; +import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore"; +import type { + DialogueSpeaker, + DialogueVoiceId, +} from "@/types/dialogues/dialogues"; + +interface SrtVoiceOption { + id: DialogueVoiceId; + label: DialogueSpeaker; +} + +const SRT_VOICES: SrtVoiceOption[] = [ + { id: "narrateur", label: "Narrateur" }, + { id: "fermier", label: "Fermier" }, + { id: "leonie", label: "Leonie" }, +]; +const DEFAULT_SRT_VOICE: SrtVoiceOption = { + id: "narrateur", + label: "Narrateur", +}; + +const SRT_LANGUAGES: SubtitleLanguage[] = ["fr", "en"]; + +function getSrtPath( + voice: DialogueVoiceId, + language: SubtitleLanguage, +): string { + return `/sounds/dialogue/subtitles/${language}/${voice}.srt`; +} + +function createEmptySrtTemplate(speaker: DialogueSpeaker): string { + return `1\n00:00:00,000 --> 00:00:02,000\n${speaker}: Nouveau sous-titre\n`; +} + +function downloadSrtFile( + voice: DialogueVoiceId, + language: SubtitleLanguage, + content: string, +): void { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${voice}.${language}.srt`; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +export function EditorSrtPanel(): React.JSX.Element { + const [voice, setVoice] = useState("narrateur"); + const [language, setLanguage] = useState("fr"); + const [content, setContent] = useState(""); + const [status, setStatus] = useState("Chargement du SRT..."); + const selectedVoice = + SRT_VOICES.find((item) => item.id === voice) ?? DEFAULT_SRT_VOICE; + + useEffect(() => { + let mounted = true; + const srtPath = getSrtPath(voice, language); + + void fetch(srtPath) + .then(async (response) => { + if (!mounted) return; + + if (!response.ok) { + setContent(createEmptySrtTemplate(selectedVoice.label)); + setStatus("Fichier absent, template local cree"); + return; + } + + setContent(await response.text()); + setStatus(`Charge depuis ${srtPath}`); + }) + .catch(() => { + if (!mounted) return; + setContent(createEmptySrtTemplate(selectedVoice.label)); + setStatus("Erreur de chargement, template local cree"); + }); + + return () => { + mounted = false; + }; + }, [language, selectedVoice.label, voice]); + + return ( +
+
+

SRT

+ {language.toUpperCase()} +
+ +
+ + + +
+ +