From bdc704fe8e7829fca64acd6404efcc0c7e7eb1f0 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 10:52:28 +0200 Subject: [PATCH] feat(ui): show narrator video on talkie --- public/assets/world/UI/talkie-video.mp4 | 3 + src/components/ui/TalkieDialogueOverlay.tsx | 198 +++++++++++++++++--- src/index.css | 40 ++-- 3 files changed, 198 insertions(+), 43 deletions(-) create mode 100644 public/assets/world/UI/talkie-video.mp4 diff --git a/public/assets/world/UI/talkie-video.mp4 b/public/assets/world/UI/talkie-video.mp4 new file mode 100644 index 0000000..541ed63 --- /dev/null +++ b/public/assets/world/UI/talkie-video.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15ee3608d9d07029de48373fbafa0cb36effd25a68d2ae9dade8aaf03512d164 +size 288088 diff --git a/src/components/ui/TalkieDialogueOverlay.tsx b/src/components/ui/TalkieDialogueOverlay.tsx index d28c2cc..7d75a4a 100644 --- a/src/components/ui/TalkieDialogueOverlay.tsx +++ b/src/components/ui/TalkieDialogueOverlay.tsx @@ -4,20 +4,81 @@ import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; import { useGameStore } from "@/managers/stores/useGameStore"; 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_REVEAL_STEPS = new Set([ - "reveal", - "await-ebike-mount", - "ebike-intro-ride", - "ebike-breakdown", - "completed", -]); +const TALKIE_VIDEO_PATH = "/assets/world/UI/talkie-video.mp4"; +const TALKIE_FIRST_VISIBLE_STEP = "reveal"; +const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf( + TALKIE_FIRST_VISIBLE_STEP, +); -function TalkieModel(): React.JSX.Element { +const TALKIE_REST_Y = -1.55; +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 model = useMemo(() => scene.clone(true), [scene]); const groupRef = useRef(null); + const screenRef = useRef(null); + const originalScreenMaterialRef = useRef(null); + const videoResourcesRef = useRef(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(() => { model.traverse((child) => { @@ -27,38 +88,119 @@ function TalkieModel(): React.JSX.Element { 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]); + 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 }) => { 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; + const floatY = Math.sin(t * 1.2) * 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, + ); + + 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 ( - + ); } -function TalkieSignalLines(): React.JSX.Element { +interface TalkieSignalLinesProps { + side: "left" | "right"; +} + +function TalkieSignalLines({ + side, +}: TalkieSignalLinesProps): React.JSX.Element { return ( ); } @@ -67,21 +209,23 @@ 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 introStepIndex = GAME_STEPS.indexOf(introStep); + const hasTalkieBeenRevealed = + mainState !== "intro" || introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX; const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur"; - if (!isAfterReveal || !isNarratorDialogue) return null; + if (!hasTalkieBeenRevealed) return null; return (