feat(ui): add narrator talkie overlay

This commit is contained in:
Tom Boullay
2026-06-01 01:32:46 +02:00
parent bc862960a7
commit dcf3a8564c
3 changed files with 207 additions and 0 deletions
+2
View File
@@ -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 {
<InteractPrompt />
<HandTrackingVisualizer />
<Subtitles />
<TalkieDialogueOverlay />
<GameSettingsMenu />
</>
);
+100
View File
@@ -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<THREE.Group>(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 (
<group ref={groupRef}>
<primitive
object={model}
position={[0, -0.18, 0]}
rotation={[0.18, Math.PI, -0.08]}
scale={1.45}
/>
</group>
);
}
function TalkieSignalLines(): React.JSX.Element {
return (
<svg
className="talkie-dialogue-overlay__signals"
viewBox="0 0 120 160"
aria-hidden="true"
>
<path d="M34 20 C52 44 16 66 34 92 C48 112 22 128 30 146" />
<path d="M68 12 C92 44 50 70 70 104 C84 130 48 142 52 154" />
<path d="M100 8 C124 42 82 76 100 112 C112 136 74 150 78 158" />
</svg>
);
}
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 (
<aside
className="talkie-dialogue-overlay talkie-dialogue-overlay--raised"
aria-hidden="true"
>
<TalkieSignalLines />
<div className="talkie-dialogue-overlay__model-frame">
<Canvas
camera={{ position: [0, 0, 4.2], zoom: 78 }}
dpr={[1, 1.5]}
gl={{ alpha: true, antialias: true }}
orthographic
>
<ambientLight intensity={2.5} />
<directionalLight position={[2, 3, 4]} intensity={2.8} />
<Suspense fallback={null}>
<TalkieModel />
</Suspense>
</Canvas>
</div>
</aside>
);
}
useGLTF.preload(TALKIE_MODEL_PATH);
+105
View File
@@ -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;