feat(ui): add narrator talkie overlay
This commit is contained in:
@@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
|||||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||||
import { Subtitles } from "@/components/ui/Subtitles";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
|
||||||
|
|
||||||
export function GameUI(): React.JSX.Element {
|
export function GameUI(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
|
|||||||
<InteractPrompt />
|
<InteractPrompt />
|
||||||
<HandTrackingVisualizer />
|
<HandTrackingVisualizer />
|
||||||
<Subtitles />
|
<Subtitles />
|
||||||
|
<TalkieDialogueOverlay />
|
||||||
<GameSettingsMenu />
|
<GameSettingsMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -1237,6 +1237,111 @@ canvas {
|
|||||||
color: #f9a8d4;
|
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 */
|
/* In-game settings menu */
|
||||||
.game-settings-menu {
|
.game-settings-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user