feat(ui): show narrator video on talkie
🔍 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
🔍 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:
Binary file not shown.
@@ -4,20 +4,81 @@ 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_REVEAL_STEPS = new Set([
|
const TALKIE_VIDEO_PATH = "/assets/world/UI/talkie-video.mp4";
|
||||||
"reveal",
|
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
|
||||||
"await-ebike-mount",
|
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
|
||||||
"ebike-intro-ride",
|
TALKIE_FIRST_VISIBLE_STEP,
|
||||||
"ebike-breakdown",
|
);
|
||||||
"completed",
|
|
||||||
]);
|
|
||||||
|
|
||||||
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 { 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) => {
|
||||||
@@ -27,38 +88,119 @@ function TalkieModel(): 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();
|
||||||
groupRef.current.rotation.z = Math.sin(t * 22) * 0.025;
|
const floatY = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
|
||||||
groupRef.current.position.y = Math.sin(t * 6) * 0.012;
|
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 (
|
return (
|
||||||
<group ref={groupRef}>
|
<group
|
||||||
|
ref={groupRef}
|
||||||
|
position={[0, TALKIE_REST_Y, 0]}
|
||||||
|
rotation={TALKIE_BASE_ROTATION}
|
||||||
|
>
|
||||||
<primitive
|
<primitive
|
||||||
object={model}
|
object={model}
|
||||||
position={[0, -0.18, 0]}
|
position={[0, -3.25, 0]}
|
||||||
rotation={[0.18, Math.PI, -0.08]}
|
rotation={[0, -1, 0]}
|
||||||
scale={1.45}
|
scale={1.5}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TalkieSignalLines(): React.JSX.Element {
|
interface TalkieSignalLinesProps {
|
||||||
|
side: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TalkieSignalLines({
|
||||||
|
side,
|
||||||
|
}: TalkieSignalLinesProps): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className="talkie-dialogue-overlay__signals"
|
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
|
||||||
viewBox="0 0 120 160"
|
viewBox="0 0 90 120"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="M34 20 C52 44 16 66 34 92 C48 112 22 128 30 146" />
|
<path d="M18 48 C30 58 30 72 18 82" />
|
||||||
<path d="M68 12 C92 44 50 70 70 104 C84 130 48 142 52 154" />
|
<path d="M34 34 C56 52 56 78 34 96" />
|
||||||
<path d="M100 8 C124 42 82 76 100 112 C112 136 74 150 78 158" />
|
<path d="M52 20 C84 46 84 84 52 110" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,21 +209,23 @@ 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 isAfterReveal =
|
const introStepIndex = GAME_STEPS.indexOf(introStep);
|
||||||
mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep);
|
const hasTalkieBeenRevealed =
|
||||||
|
mainState !== "intro" || introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX;
|
||||||
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
|
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
|
||||||
|
|
||||||
if (!isAfterReveal || !isNarratorDialogue) return null;
|
if (!hasTalkieBeenRevealed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="talkie-dialogue-overlay talkie-dialogue-overlay--raised"
|
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active talkie-dialogue-overlay--raised" : ""}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<TalkieSignalLines />
|
{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: 62 }}
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
gl={{ alpha: true, antialias: true }}
|
gl={{ alpha: true, antialias: true }}
|
||||||
orthographic
|
orthographic
|
||||||
@@ -89,7 +233,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 />
|
<TalkieModel active={isNarratorDialogue} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+24
-16
@@ -1240,24 +1240,24 @@ 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, 310px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1.05;
|
||||||
|
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(-10px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.talkie-dialogue-overlay__model-frame {
|
.talkie-dialogue-overlay__model-frame {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: -18% -12% -6% -12%;
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,22 +1268,30 @@ canvas {
|
|||||||
|
|
||||||
.talkie-dialogue-overlay__signals {
|
.talkie-dialogue-overlay__signals {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -26%;
|
bottom: 38%;
|
||||||
bottom: 34%;
|
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 58%;
|
width: 34%;
|
||||||
height: 78%;
|
height: 50%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
opacity: 0.8;
|
opacity: 0.72;
|
||||||
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(235, 244, 255, 0.9);
|
stroke: rgba(162, 210, 255, 0.92);
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-width: 5;
|
stroke-width: 4;
|
||||||
filter: drop-shadow(0 0 7px rgba(125, 211, 252, 0.72));
|
filter: drop-shadow(0 0 5px rgba(125, 211, 252, 0.58));
|
||||||
}
|
}
|
||||||
|
|
||||||
.talkie-dialogue-overlay__signals path:nth-child(2) {
|
.talkie-dialogue-overlay__signals path:nth-child(2) {
|
||||||
@@ -1293,7 +1301,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.55;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes talkie-radio-shake {
|
@keyframes talkie-radio-shake {
|
||||||
|
|||||||
Reference in New Issue
Block a user