From dcf3a8564cc28e8d493b8b3a3d8e429237660476 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 01:32:46 +0200 Subject: [PATCH] feat(ui): add narrator talkie overlay --- src/components/ui/GameUI.tsx | 2 + src/components/ui/TalkieDialogueOverlay.tsx | 100 +++++++++++++++++++ src/index.css | 105 ++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 src/components/ui/TalkieDialogueOverlay.tsx diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx index 687d71e..526c3b6 100644 --- a/src/components/ui/GameUI.tsx +++ b/src/components/ui/GameUI.tsx @@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator"; import { Subtitles } from "@/components/ui/Subtitles"; +import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay"; export function GameUI(): React.JSX.Element { return ( @@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element { + ); diff --git a/src/components/ui/TalkieDialogueOverlay.tsx b/src/components/ui/TalkieDialogueOverlay.tsx new file mode 100644 index 0000000..d28c2cc --- /dev/null +++ b/src/components/ui/TalkieDialogueOverlay.tsx @@ -0,0 +1,100 @@ +import { Suspense, useEffect, useMemo, useRef } from "react"; +import { Canvas, useFrame } from "@react-three/fiber"; +import { useGLTF } from "@react-three/drei"; +import * as THREE from "three"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; + +const TALKIE_MODEL_PATH = "/models/talkie/model.gltf"; +const TALKIE_REVEAL_STEPS = new Set([ + "reveal", + "await-ebike-mount", + "ebike-intro-ride", + "ebike-breakdown", + "completed", +]); + +function TalkieModel(): React.JSX.Element { + const { scene } = useGLTF(TALKIE_MODEL_PATH); + const model = useMemo(() => scene.clone(true), [scene]); + const groupRef = useRef(null); + + useEffect(() => { + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = false; + child.receiveShadow = false; + child.frustumCulled = false; + } + }); + }, [model]); + + useFrame(({ clock }) => { + if (!groupRef.current) return; + + const t = clock.getElapsedTime(); + groupRef.current.rotation.z = Math.sin(t * 22) * 0.025; + groupRef.current.position.y = Math.sin(t * 6) * 0.012; + }); + + return ( + + + + ); +} + +function TalkieSignalLines(): React.JSX.Element { + return ( + + ); +} + +export function TalkieDialogueOverlay(): React.JSX.Element | null { + const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle); + const mainState = useGameStore((state) => state.mainState); + const introStep = useGameStore((state) => state.intro.currentStep); + const isAfterReveal = + mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep); + const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur"; + + if (!isAfterReveal || !isNarratorDialogue) return null; + + return ( + + ); +} + +useGLTF.preload(TALKIE_MODEL_PATH); diff --git a/src/index.css b/src/index.css index 0aed3d3..a716273 100644 --- a/src/index.css +++ b/src/index.css @@ -1237,6 +1237,111 @@ canvas { color: #f9a8d4; } +/* Dialogue talkie */ +.talkie-dialogue-overlay { + position: fixed; + left: clamp(12px, 2.2vw, 28px); + bottom: clamp(24px, 7vh, 76px); + z-index: 16; + width: clamp(120px, 13vw, 190px); + aspect-ratio: 1; + pointer-events: none; + transform: translateY(0); + transition: transform 180ms ease; +} + +.talkie-dialogue-overlay--raised { + transform: translateY(-10px); +} + +.talkie-dialogue-overlay__model-frame { + position: absolute; + inset: 0; + animation: talkie-radio-shake 1s ease-in-out infinite; + filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55)); +} + +.talkie-dialogue-overlay__model-frame canvas { + width: 100% !important; + height: 100% !important; +} + +.talkie-dialogue-overlay__signals { + position: absolute; + right: -26%; + bottom: 34%; + z-index: 2; + width: 58%; + height: 78%; + overflow: visible; + opacity: 0.8; + animation: talkie-signal-pulse 1s ease-in-out infinite; +} + +.talkie-dialogue-overlay__signals path { + fill: none; + stroke: rgba(235, 244, 255, 0.9); + stroke-linecap: round; + stroke-width: 5; + filter: drop-shadow(0 0 7px rgba(125, 211, 252, 0.72)); +} + +.talkie-dialogue-overlay__signals path:nth-child(2) { + animation-delay: 90ms; + opacity: 0.75; +} + +.talkie-dialogue-overlay__signals path:nth-child(3) { + animation-delay: 180ms; + opacity: 0.55; +} + +@keyframes talkie-radio-shake { + 0%, + 11%, + 23%, + 100% { + transform: translate3d(0, 0, 0) rotate(0deg); + } + + 3%, + 15%, + 27% { + transform: translate3d(-2px, 1px, 0) rotate(-1.7deg); + } + + 6%, + 18%, + 30% { + transform: translate3d(2px, -1px, 0) rotate(1.7deg); + } + + 9%, + 21%, + 33% { + transform: translate3d(-1px, 0, 0) rotate(-0.8deg); + } +} + +@keyframes talkie-signal-pulse { + 0%, + 100% { + opacity: 0.28; + transform: translate3d(-4px, 4px, 0) scale(0.92); + } + + 18%, + 38% { + opacity: 0.95; + transform: translate3d(0, 0, 0) scale(1); + } + + 60% { + opacity: 0.45; + transform: translate3d(4px, -6px, 0) scale(1.05); + } +} + /* In-game settings menu */ .game-settings-menu { position: fixed;