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;