refactor(ui): split talkie dialogue overlay
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-06-01 21:43:58 +02:00
parent 3b07f40f2d
commit a1798aecb3
5 changed files with 158 additions and 116 deletions
+14 -100
View File
@@ -1,108 +1,24 @@
import { Suspense, useEffect, useMemo, useRef } from "react"; import { Suspense } from "react";
import { Canvas, useFrame } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei"; import { TalkieModel } from "@/components/ui/talkie/TalkieModel";
import * as THREE from "three"; import { TalkieSignalLines } from "@/components/ui/talkie/TalkieSignalLines";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useTalkieDialogueOverlayState } from "@/hooks/ui/useTalkieDialogueOverlayState";
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",
]);
const TALKIE_REST_Y = -0.55;
const TALKIE_ACTIVE_Y = -0.18;
const TALKIE_FLOAT_Y_AMPLITUDE = 0.025;
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(1.6);
interface TalkieModelProps {
active: boolean;
}
function TalkieModel({ active }: TalkieModelProps): 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();
const floatY = Math.sin(t * 1.4) * TALKIE_FLOAT_Y_AMPLITUDE;
const targetY = (active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y) + floatY;
groupRef.current.position.y = THREE.MathUtils.lerp(
groupRef.current.position.y,
targetY,
0.14,
);
if (active) {
groupRef.current.rotation.z = Math.sin(t * 22) * 0.025;
} else {
groupRef.current.rotation.z =
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
}
});
return (
<group ref={groupRef} position={[0, TALKIE_REST_Y, 0]}>
<primitive
object={model}
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 { export function TalkieDialogueOverlay(): React.JSX.Element | null {
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle); const { isNarratorDialogue, isVisible } = useTalkieDialogueOverlayState();
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) return null; if (!isVisible) return null;
const overlayClassName = isNarratorDialogue
? "talkie-dialogue-overlay talkie-dialogue-overlay--active talkie-dialogue-overlay--raised"
: "talkie-dialogue-overlay";
return ( return (
<aside className={overlayClassName} aria-hidden="true"> <aside
{isNarratorDialogue ? <TalkieSignalLines /> : null} className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active" : ""}`}
aria-hidden="true"
>
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
<div className="talkie-dialogue-overlay__model-frame"> <div className="talkie-dialogue-overlay__model-frame">
<Canvas <Canvas
camera={{ position: [0, 0, 4.2], zoom: 78 }} camera={{ position: [0, 0, 4.2], zoom: 56 }}
dpr={[1, 1.5]} dpr={[1, 1.5]}
gl={{ alpha: true, antialias: true }} gl={{ alpha: true, antialias: true }}
orthographic orthographic
@@ -117,5 +33,3 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null {
</aside> </aside>
); );
} }
useGLTF.preload(TALKIE_MODEL_PATH);
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import gsap from "gsap";
import type { Vector3Tuple } from "@/types/three/three";
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
const TALKIE_REST_Y = -1.55;
const TALKIE_ACTIVE_Y = -0.38;
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2);
const TALKIE_FLOAT_Y_AMPLITUDE = 0.055;
interface TalkieModelProps {
active: boolean;
}
export function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
const { scene } = useGLTF(TALKIE_MODEL_PATH);
const model = useMemo(() => scene.clone(true), [scene]);
const groupRef = useRef<THREE.Group>(null);
const floatRef = useRef<THREE.Group>(null);
useEffect(() => {
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = false;
child.receiveShadow = false;
child.frustumCulled = false;
}
});
}, [model]);
useEffect(() => {
const group = groupRef.current;
if (!group) return;
gsap.killTweensOf(group.position);
gsap.to(group.position, {
y: active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y,
duration: active ? 0.72 : 0.5,
ease: active ? "power3.out" : "power2.out",
});
return () => {
gsap.killTweensOf(group.position);
};
}, [active]);
useFrame(({ clock }) => {
if (!floatRef.current) return;
const t = clock.getElapsedTime();
floatRef.current.position.y = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
floatRef.current.rotation.x =
TALKIE_BASE_ROTATION[0] +
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
floatRef.current.rotation.y =
TALKIE_BASE_ROTATION[1] +
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
floatRef.current.rotation.z =
TALKIE_BASE_ROTATION[2] +
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
});
return (
<group ref={groupRef} position={[0, TALKIE_REST_Y, 0]}>
<group ref={floatRef} rotation={TALKIE_BASE_ROTATION}>
<primitive
object={model}
position={[0, -2.45, 0]}
rotation={[0, -1, 0]}
scale={1.2}
/>
</group>
</group>
);
}
useGLTF.preload(TALKIE_MODEL_PATH);
@@ -0,0 +1,19 @@
interface TalkieSignalLinesProps {
side: "left" | "right";
}
export function TalkieSignalLines({
side,
}: TalkieSignalLinesProps): React.JSX.Element {
return (
<svg
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
viewBox="0 0 90 120"
aria-hidden="true"
>
<path d="M18 48 C30 58 30 72 18 82" />
<path d="M34 34 C56 52 56 78 34 96" />
<path d="M52 20 C84 46 84 84 52 110" />
</svg>
);
}
@@ -0,0 +1,27 @@
import { GAME_STEPS } from "@/data/game/gameStateConfig";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
TALKIE_FIRST_VISIBLE_STEP,
);
interface TalkieDialogueOverlayState {
isNarratorDialogue: boolean;
isVisible: boolean;
}
export function useTalkieDialogueOverlayState(): TalkieDialogueOverlayState {
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const introStepIndex = GAME_STEPS.indexOf(introStep);
return {
isNarratorDialogue: activeSubtitle?.speaker === "Narrateur",
isVisible:
mainState !== "intro" ||
introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX,
};
}
+16 -16
View File
@@ -1240,18 +1240,12 @@ canvas {
/* Dialogue talkie */ /* Dialogue talkie */
.talkie-dialogue-overlay { .talkie-dialogue-overlay {
position: fixed; position: fixed;
left: clamp(12px, 2.2vw, 28px); left: 0;
bottom: clamp(24px, 7vh, 76px); bottom: 0;
z-index: 16; z-index: 16;
width: clamp(120px, 13vw, 190px); width: clamp(190px, 18vw, 300px);
aspect-ratio: 1; aspect-ratio: 1;
pointer-events: none; pointer-events: none;
transform: translateY(0);
transition: transform 180ms ease;
}
.talkie-dialogue-overlay--raised {
transform: translateY(-10px);
} }
.talkie-dialogue-overlay__model-frame { .talkie-dialogue-overlay__model-frame {
@@ -1271,16 +1265,25 @@ canvas {
.talkie-dialogue-overlay__signals { .talkie-dialogue-overlay__signals {
position: absolute; position: absolute;
right: -26%; top: 52%;
bottom: 34%;
z-index: 2; z-index: 2;
width: 58%; width: 38%;
height: 78%; height: 52%;
overflow: visible; overflow: visible;
opacity: 0.8; opacity: 0.8;
animation: talkie-signal-pulse 1s ease-in-out infinite; animation: talkie-signal-pulse 1s ease-in-out infinite;
} }
.talkie-dialogue-overlay__signals--left {
right: 62%;
transform: translateY(-50%) scaleX(-1);
}
.talkie-dialogue-overlay__signals--right {
left: 62%;
transform: translateY(-50%);
}
.talkie-dialogue-overlay__signals path { .talkie-dialogue-overlay__signals path {
fill: none; fill: none;
stroke: rgba(235, 244, 255, 0.9); stroke: rgba(235, 244, 255, 0.9);
@@ -1330,18 +1333,15 @@ canvas {
0%, 0%,
100% { 100% {
opacity: 0.28; opacity: 0.28;
transform: translate3d(-4px, 4px, 0) scale(0.92);
} }
18%, 18%,
38% { 38% {
opacity: 0.95; opacity: 0.95;
transform: translate3d(0, 0, 0) scale(1);
} }
60% { 60% {
opacity: 0.45; opacity: 0.45;
transform: translate3d(4px, -6px, 0) scale(1.05);
} }
} }