diff --git a/client/src/mobile/components/MDancingTrek.tsx b/client/src/mobile/components/MDancingTrek.tsx index 2c938620e..a023adb75 100644 --- a/client/src/mobile/components/MDancingTrek.tsx +++ b/client/src/mobile/components/MDancingTrek.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { useState } from 'react'; +import MascotClip, { findClip, mascot3dEnabled } from './trek3d/MascotClip'; /** * The TREK mascot — an empty-state character that IS the TREK mark: the logo is @@ -37,38 +38,58 @@ export type TrekScene = | 'costs' | 'search' | 'tasks' - | 'tours' + | 'tours'; -export type TrekMood = 'default' | 'happy' | 'sad' | 'sleepy' | 'confused' | 'error' +export type TrekMood = 'default' | 'happy' | 'sad' | 'sleepy' | 'confused' | 'error'; const MARK = - 'M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z' + 'M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z'; const SCENE_MOOD: Partial> = { notifications: 'sleepy', search: 'confused', +}; + +type MascotProps = { + size?: number; + scene?: TrekScene; + mood?: TrekMood; + className?: string; +}; + +/** + * Picks the look: the pre-rendered 3D clip when one exists for this scene and + * face (see `trek3d/MascotClip`), the SVG otherwise, under reduced motion, in + * tests, or when `localStorage['trek-mascot-3d']` is `'0'`. + */ +export default function MDancingTrek(props: MascotProps) { + const { size = 96, scene = 'idle', mood, className = '' } = props; + const sceneMood = SCENE_MOOD[scene] ?? 'default'; + const clip = + import.meta.env.MODE !== 'test' && mascot3dEnabled() ? findClip(scene, mood ?? sceneMood, sceneMood) : null; + if (clip) + return ( + } + /> + ); + return ; } -export default function MDancingTrek({ - size = 96, - scene = 'idle', - mood, - className = '', -}: { - size?: number - scene?: TrekScene - mood?: TrekMood - className?: string -}) { - const face = mood ?? SCENE_MOOD[scene] ?? 'default' +function MDancingTrekSvg({ size = 96, scene = 'idle', mood, className = '' }: MascotProps) { + const face = mood ?? SCENE_MOOD[scene] ?? 'default'; // Ground shadow sits under whatever the mascot stands on this scene. - const shadowCy = scene === 'transport' ? 86 : 73 + const shadowCy = scene === 'transport' ? 86 : 73; // Poke to react: remounting the svg replays the entrance pop-in as a tap bounce. - const [poke, setPoke] = useState(0) + const [poke, setPoke] = useState(0); return ( setPoke(p => p + 1)} + onClick={() => setPoke((p) => p + 1)} width={size} height={(size * 96) / 88} viewBox="0 0 88 96" @@ -96,7 +117,7 @@ export default function MDancingTrek({ - ) + ); } /* ── Eyes ──────────────────────────────────────────────────────────────────── @@ -111,7 +132,7 @@ function Eyes({ mood }: { mood: TrekMood }) { - ) + ); } if (mood === 'sad') { // The mirror of `happy`: the same arcs bent the other way. `sleepy` is a @@ -122,7 +143,7 @@ function Eyes({ mood }: { mood: TrekMood }) { - ) + ); } if (mood === 'sleepy') { return ( @@ -130,7 +151,7 @@ function Eyes({ mood }: { mood: TrekMood }) { - ) + ); } if (mood === 'error') { return ( @@ -138,7 +159,7 @@ function Eyes({ mood }: { mood: TrekMood }) { - ) + ); } if (mood === 'confused') { // one eye open, one a raised squint — reads puzzled @@ -146,9 +167,15 @@ function Eyes({ mood }: { mood: TrekMood }) { - + - ) + ); } // default — open eyes that blink and glance around return ( @@ -160,7 +187,7 @@ function Eyes({ mood }: { mood: TrekMood }) { - ) + ); } /* ── Scene props ───────────────────────────────────────────────────────────── @@ -174,12 +201,23 @@ function Eyes({ mood }: { mood: TrekMood }) { * mark that gap is the only thing telling the pack from the body behind it. */ function SceneOnBody({ scene }: { scene: TrekScene }) { - if (scene !== 'tours') return null - const cut = { stroke: 'var(--m-bg)', strokeWidth: 1.6, paintOrder: 'stroke' as const, strokeLinejoin: 'round' as const } + if (scene !== 'tours') return null; + const cut = { + stroke: 'var(--m-bg)', + strokeWidth: 1.6, + paintOrder: 'stroke' as const, + strokeLinejoin: 'round' as const, + }; return ( {/* a small smile under the eyes, glad to be out walking */} - + {/* the pack, slung over the left side */} @@ -188,9 +226,19 @@ function SceneOnBody({ scene }: { scene: TrekScene }) { {/* body of the pack */} - + {/* lid seam and buckle */} - + {/* front pocket */} @@ -198,7 +246,7 @@ function SceneOnBody({ scene }: { scene: TrekScene }) { - ) + ); } function SceneBack({ scene }: { scene: TrekScene }) { @@ -209,11 +257,22 @@ function SceneBack({ scene }: { scene: TrekScene }) { - + - + - ) + ); case 'transport': // skateboard under the body — deck + two wheels spinning in place return ( @@ -228,7 +287,7 @@ function SceneBack({ scene }: { scene: TrekScene }) { - ) + ); case 'guide': // tall staff with a big pennant fluttering near the top return ( @@ -239,7 +298,7 @@ function SceneBack({ scene }: { scene: TrekScene }) { - ) + ); case 'packing': // rolling suitcase parked beside the body — rocks on its wheels, // handle telescoping up and down @@ -258,7 +317,7 @@ function SceneBack({ scene }: { scene: TrekScene }) { - ) + ); case 'links': // two chain links clicking into each other beside the body. The lower one // carries a surface-coloured under-stroke so it reads as passing through @@ -273,7 +332,7 @@ function SceneBack({ scene }: { scene: TrekScene }) { - ) + ); case 'polls': // vote bars gently rising and settling like live tallies return ( @@ -282,24 +341,36 @@ function SceneBack({ scene }: { scene: TrekScene }) { - ) + ); case 'collections': // a map pin dropping in and bouncing beside the body return ( - + - ) + ); case 'atlas': // a globe beside the body, meridians drifting return ( - + - ) + ); case 'costs': // a stack of coins beside the body, top one bouncing on return ( @@ -312,7 +383,7 @@ function SceneBack({ scene }: { scene: TrekScene }) { - ) + ); case 'chat': // a speech bubble beside the body with three typing dots pulsing in turn return ( @@ -323,9 +394,9 @@ function SceneBack({ scene }: { scene: TrekScene }) { - ) + ); default: - return null + return null; } } @@ -340,12 +411,18 @@ function SceneFront({ scene }: { scene: TrekScene }) { - + - ) + ); // Files reuse the booking ticket per design — a card getting written on. case 'bookings': case 'files': @@ -360,16 +437,33 @@ function SceneFront({ scene }: { scene: TrekScene }) { - ) + ); case 'notes': // a paper note (surface colour) with ink lines being written and a // scribbling pencil — pencil is ink (light in dark theme, dark in light) return ( - + - + @@ -381,22 +475,40 @@ function SceneFront({ scene }: { scene: TrekScene }) { - ) + ); case 'journey': // an open book with a fluttering page and a ribbon bookmark return ( - - + + - + - ) + ); case 'dashboard': // a little paper plane looping around, ready for a first trip return ( @@ -404,16 +516,40 @@ function SceneFront({ scene }: { scene: TrekScene }) { - ) + ); case 'notifications': // all caught up — a stack of Zzz drifting up while the mascot dozes return ( - - - + + + - ) + ); case 'search': // a magnifying glass scanning back and forth return ( @@ -422,7 +558,7 @@ function SceneFront({ scene }: { scene: TrekScene }) { - ) + ); case 'tasks': // a clipboard checklist with a checkmark ticking on return ( @@ -436,11 +572,19 @@ function SceneFront({ scene }: { scene: TrekScene }) { - + - ) + ); default: - return null + return null; } } diff --git a/client/src/mobile/components/trek3d/MascotClip.tsx b/client/src/mobile/components/trek3d/MascotClip.tsx new file mode 100644 index 000000000..e08bbd2ea --- /dev/null +++ b/client/src/mobile/components/trek3d/MascotClip.tsx @@ -0,0 +1,286 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import meta from './clips.meta.json'; + +/** + * The 3D TREK mascot as pre-rendered clips. Each clip is a plain H.264 MP4 with + * "stacked alpha": the colour frame on top, its transparency below as a grey + * mask. A tiny WebGL shader joins the halves into a transparent image, so the + * same file plays everywhere (iOS included) with hardware decoding and no + * runtime 3D. Eyes are holes in the mask, like the SVG's `--m-bg` cut-outs. + * + * One seamless loop per scene and theme plus a one-shot click clip; the click + * cross-fades into the clip and back inside the shader. The