revert(ui): remove narrator video on talkie

This commit is contained in:
Tom Boullay
2026-06-01 14:14:03 +02:00
parent 7a378afad3
commit 1ad0c4de37
3 changed files with 48 additions and 201 deletions
Binary file not shown.
+27 -171
View File
@@ -4,81 +4,20 @@ import { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import { GAME_STEPS } from "@/data/game/gameStateConfig";
import type { Vector3Tuple } from "@/types/three/three";
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf"; const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
const TALKIE_VIDEO_PATH = "/assets/world/UI/talkie-video.mp4"; const TALKIE_REVEAL_STEPS = new Set([
const TALKIE_FIRST_VISIBLE_STEP = "reveal"; "reveal",
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf( "await-ebike-mount",
TALKIE_FIRST_VISIBLE_STEP, "ebike-intro-ride",
); "ebike-breakdown",
"completed",
]);
const TALKIE_REST_Y = -1.55; function TalkieModel(): React.JSX.Element {
const TALKIE_ACTIVE_Y = -0.92;
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;
const TALKIE_SCREEN_TEXTURE_SIZE = 512;
interface TalkieModelProps {
active: boolean;
}
interface TalkieVideoResources {
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D | null;
material: THREE.MeshBasicMaterial;
texture: THREE.CanvasTexture;
video: HTMLVideoElement;
}
function createTalkieVideoResources(): TalkieVideoResources {
const video = document.createElement("video");
video.src = TALKIE_VIDEO_PATH;
video.crossOrigin = "anonymous";
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = "auto";
const canvas = document.createElement("canvas");
canvas.width = TALKIE_SCREEN_TEXTURE_SIZE;
canvas.height = TALKIE_SCREEN_TEXTURE_SIZE;
const context = canvas.getContext("2d");
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.flipY = false;
texture.needsUpdate = true;
const material = new THREE.MeshBasicMaterial({
map: texture,
toneMapped: false,
});
return { canvas, context, material, texture, video };
}
function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
const { scene } = useGLTF(TALKIE_MODEL_PATH); const { scene } = useGLTF(TALKIE_MODEL_PATH);
const model = useMemo(() => scene.clone(true), [scene]); const model = useMemo(() => scene.clone(true), [scene]);
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const screenRef = useRef<THREE.Mesh | null>(null);
const originalScreenMaterialRef = useRef<THREE.Material | null>(null);
const videoResourcesRef = useRef<TalkieVideoResources | null>(null);
useEffect(() => {
const videoResources = createTalkieVideoResources();
videoResourcesRef.current = videoResources;
return () => {
videoResources.video.pause();
videoResources.video.removeAttribute("src");
videoResources.video.load();
videoResources.texture.dispose();
videoResources.material.dispose();
videoResourcesRef.current = null;
};
}, []);
useEffect(() => { useEffect(() => {
model.traverse((child) => { model.traverse((child) => {
@@ -88,119 +27,38 @@ function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
child.frustumCulled = false; child.frustumCulled = false;
} }
}); });
const screen = model.getObjectByName("écran");
if (screen instanceof THREE.Mesh) {
screenRef.current = screen;
originalScreenMaterialRef.current = Array.isArray(screen.material)
? (screen.material[0] ?? null)
: screen.material;
}
}, [model]); }, [model]);
useEffect(() => {
const screen = screenRef.current;
const originalMaterial = originalScreenMaterialRef.current;
const videoResources = videoResourcesRef.current;
if (!videoResources) return;
if (screen) {
screen.material = active
? videoResources.material
: (originalMaterial ?? videoResources.material);
}
if (active) {
void videoResources.video.play();
return;
}
videoResources.video.pause();
}, [active]);
useFrame(({ clock }) => { useFrame(({ clock }) => {
if (!groupRef.current) return; if (!groupRef.current) return;
const t = clock.getElapsedTime(); const t = clock.getElapsedTime();
const floatY = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE; groupRef.current.rotation.z = Math.sin(t * 22) * 0.025;
const targetY = (active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y) + floatY; groupRef.current.position.y = Math.sin(t * 6) * 0.012;
groupRef.current.position.y = THREE.MathUtils.lerp(
groupRef.current.position.y,
targetY,
0.14,
);
groupRef.current.rotation.x =
TALKIE_BASE_ROTATION[0] +
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
groupRef.current.rotation.y =
TALKIE_BASE_ROTATION[1] +
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
groupRef.current.rotation.z =
TALKIE_BASE_ROTATION[2] +
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
const videoResources = videoResourcesRef.current;
if (active && videoResources?.context) {
const { canvas, context, texture, video } = videoResources;
context.fillStyle = "#02040a";
context.fillRect(0, 0, canvas.width, canvas.height);
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
const videoAspect = video.videoWidth / video.videoHeight;
const canvasAspect = canvas.width / canvas.height;
const drawWidth =
videoAspect > canvasAspect
? canvas.width
: canvas.height * videoAspect;
const drawHeight =
videoAspect > canvasAspect
? canvas.width / videoAspect
: canvas.height;
const drawX = (canvas.width - drawWidth) / 2;
const drawY = (canvas.height - drawHeight) / 2;
context.drawImage(video, drawX, drawY, drawWidth, drawHeight);
}
texture.needsUpdate = true;
}
}); });
return ( return (
<group <group ref={groupRef}>
ref={groupRef}
position={[0, TALKIE_REST_Y, 0]}
rotation={TALKIE_BASE_ROTATION}
>
<primitive <primitive
object={model} object={model}
position={[0, -3.25, 0]} position={[0, -0.18, 0]}
rotation={[0, -1, 0]} rotation={[0.18, Math.PI, -0.08]}
scale={1.5} scale={1.45}
/> />
</group> </group>
); );
} }
interface TalkieSignalLinesProps { function TalkieSignalLines(): React.JSX.Element {
side: "left" | "right";
}
function TalkieSignalLines({
side,
}: TalkieSignalLinesProps): React.JSX.Element {
return ( return (
<svg <svg
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`} className="talkie-dialogue-overlay__signals"
viewBox="0 0 90 120" viewBox="0 0 120 160"
aria-hidden="true" aria-hidden="true"
> >
<path d="M18 48 C30 58 30 72 18 82" /> <path d="M34 20 C52 44 16 66 34 92 C48 112 22 128 30 146" />
<path d="M34 34 C56 52 56 78 34 96" /> <path d="M68 12 C92 44 50 70 70 104 C84 130 48 142 52 154" />
<path d="M52 20 C84 46 84 84 52 110" /> <path d="M100 8 C124 42 82 76 100 112 C112 136 74 150 78 158" />
</svg> </svg>
); );
} }
@@ -209,23 +67,21 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null {
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle); const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const mainState = useGameStore((state) => state.mainState); const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep); const introStep = useGameStore((state) => state.intro.currentStep);
const introStepIndex = GAME_STEPS.indexOf(introStep); const isAfterReveal =
const hasTalkieBeenRevealed = mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep);
mainState !== "intro" || introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX;
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur"; const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
if (!hasTalkieBeenRevealed) return null; if (!isAfterReveal || !isNarratorDialogue) return null;
return ( return (
<aside <aside
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active talkie-dialogue-overlay--raised" : ""}`} className="talkie-dialogue-overlay talkie-dialogue-overlay--raised"
aria-hidden="true" aria-hidden="true"
> >
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null} <TalkieSignalLines />
{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: 62 }} camera={{ position: [0, 0, 4.2], zoom: 78 }}
dpr={[1, 1.5]} dpr={[1, 1.5]}
gl={{ alpha: true, antialias: true }} gl={{ alpha: true, antialias: true }}
orthographic orthographic
@@ -233,7 +89,7 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null {
<ambientLight intensity={2.5} /> <ambientLight intensity={2.5} />
<directionalLight position={[2, 3, 4]} intensity={2.8} /> <directionalLight position={[2, 3, 4]} intensity={2.8} />
<Suspense fallback={null}> <Suspense fallback={null}>
<TalkieModel active={isNarratorDialogue} /> <TalkieModel />
</Suspense> </Suspense>
</Canvas> </Canvas>
</div> </div>
+21 -27
View File
@@ -942,9 +942,11 @@ canvas {
.scene-loading-overlay__logo { .scene-loading-overlay__logo {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: clamp(207px, 32.2vw, 368px); width: clamp(180px, 28vw, 320px);
max-height: min(43.7vh, 368px); max-height: min(38vh, 320px);
object-fit: contain; border-radius: 16px;
object-fit: cover;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
} }
.scene-loading-overlay__footer { .scene-loading-overlay__footer {
@@ -1238,24 +1240,24 @@ canvas {
/* Dialogue talkie */ /* Dialogue talkie */
.talkie-dialogue-overlay { .talkie-dialogue-overlay {
position: fixed; position: fixed;
left: 0; left: clamp(12px, 2.2vw, 28px);
bottom: 0; bottom: clamp(24px, 7vh, 76px);
z-index: 16; z-index: 16;
width: clamp(190px, 18vw, 310px); width: clamp(120px, 13vw, 190px);
aspect-ratio: 1.05; aspect-ratio: 1;
overflow: visible;
pointer-events: none; pointer-events: none;
transform: translateY(0); transform: translateY(0);
transition: transform 180ms ease; transition: transform 180ms ease;
} }
.talkie-dialogue-overlay--raised { .talkie-dialogue-overlay--raised {
transform: translateY(-8px); transform: translateY(-10px);
} }
.talkie-dialogue-overlay__model-frame { .talkie-dialogue-overlay__model-frame {
position: absolute; position: absolute;
inset: -18% -12% -6% -12%; inset: 0;
animation: talkie-radio-shake 1s ease-in-out infinite;
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55)); filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
} }
@@ -1266,30 +1268,22 @@ canvas {
.talkie-dialogue-overlay__signals { .talkie-dialogue-overlay__signals {
position: absolute; position: absolute;
bottom: 38%; right: -26%;
bottom: 34%;
z-index: 2; z-index: 2;
width: 34%; width: 58%;
height: 50%; height: 78%;
overflow: visible; overflow: visible;
opacity: 0.72; 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 {
left: 7%;
scale: -1 1;
}
.talkie-dialogue-overlay__signals--right {
right: 7%;
}
.talkie-dialogue-overlay__signals path { .talkie-dialogue-overlay__signals path {
fill: none; fill: none;
stroke: rgba(162, 210, 255, 0.92); stroke: rgba(235, 244, 255, 0.9);
stroke-linecap: round; stroke-linecap: round;
stroke-width: 4; stroke-width: 5;
filter: drop-shadow(0 0 5px rgba(125, 211, 252, 0.58)); filter: drop-shadow(0 0 7px rgba(125, 211, 252, 0.72));
} }
.talkie-dialogue-overlay__signals path:nth-child(2) { .talkie-dialogue-overlay__signals path:nth-child(2) {
@@ -1299,7 +1293,7 @@ canvas {
.talkie-dialogue-overlay__signals path:nth-child(3) { .talkie-dialogue-overlay__signals path:nth-child(3) {
animation-delay: 180ms; animation-delay: 180ms;
opacity: 0.45; opacity: 0.55;
} }
@keyframes talkie-radio-shake { @keyframes talkie-radio-shake {