From ee1b6a837f91eb93cccdd285938f041e4788a61b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 11 Oct 2026 15:06:29 +0200 Subject: [PATCH] feat(mascot): 3D mascot as transparent 60 fps clips with a click animation per scene Every empty-state scene of the TREK mascot now plays a pre-rendered 3D clip instead of the SVG: a seamless loop per scene and theme plus a one-shot animation on click that cross-fades in and out. The clips are plain H.264 MP4 with stacked alpha (colour on top, the mask below) that a small WebGL shader joins into a transparent image, so the same file plays in every browser including iOS, decoded in hardware. The videos stay 2 px stand-ins and are drawn onto a canvas, which keeps browser video controls off the mascot. Each clip is cropped to the area its scene uses and placed back inside the 88:96 box, so every scene keeps the same scale. MDancingTrek picks the clip when one exists for the scene and face and falls back to the SVG under reduced motion, without WebGL, when autoplay is refused, in tests, or with localStorage trek-mascot-3d set to 0. --- client/src/mobile/components/MDancingTrek.tsx | 278 +++++++++++++---- .../mobile/components/trek3d/MascotClip.tsx | 286 ++++++++++++++++++ .../mobile/components/trek3d/clips.meta.json | 122 ++++++++ .../trek3d/clips/atlas-dark-loop.mp4 | Bin 0 -> 400728 bytes .../trek3d/clips/atlas-dark-poke.mp4 | Bin 0 -> 278566 bytes .../trek3d/clips/atlas-light-loop.mp4 | Bin 0 -> 313892 bytes .../trek3d/clips/atlas-light-poke.mp4 | Bin 0 -> 224280 bytes .../trek3d/clips/bookings-dark-loop.mp4 | Bin 0 -> 495616 bytes .../trek3d/clips/bookings-dark-poke.mp4 | Bin 0 -> 193176 bytes .../trek3d/clips/bookings-light-loop.mp4 | Bin 0 -> 384060 bytes .../trek3d/clips/bookings-light-poke.mp4 | Bin 0 -> 158101 bytes .../trek3d/clips/chat-dark-loop.mp4 | Bin 0 -> 342515 bytes .../trek3d/clips/chat-dark-poke.mp4 | Bin 0 -> 135837 bytes .../trek3d/clips/chat-light-loop.mp4 | Bin 0 -> 265320 bytes .../trek3d/clips/chat-light-poke.mp4 | Bin 0 -> 110296 bytes .../clips/collections--happy-dark-loop.mp4 | Bin 0 -> 230112 bytes .../clips/collections--happy-light-loop.mp4 | Bin 0 -> 190345 bytes .../trek3d/clips/collections-dark-loop.mp4 | Bin 0 -> 234155 bytes .../trek3d/clips/collections-dark-poke.mp4 | Bin 0 -> 204241 bytes .../trek3d/clips/collections-light-loop.mp4 | Bin 0 -> 194240 bytes .../trek3d/clips/collections-light-poke.mp4 | Bin 0 -> 176186 bytes .../trek3d/clips/costs-dark-loop.mp4 | Bin 0 -> 290888 bytes .../trek3d/clips/costs-dark-poke.mp4 | Bin 0 -> 227309 bytes .../trek3d/clips/costs-light-loop.mp4 | Bin 0 -> 232034 bytes .../trek3d/clips/costs-light-poke.mp4 | Bin 0 -> 186616 bytes .../clips/dashboard--happy-dark-loop.mp4 | Bin 0 -> 279017 bytes .../clips/dashboard--happy-light-loop.mp4 | Bin 0 -> 222093 bytes .../trek3d/clips/dashboard-dark-loop.mp4 | Bin 0 -> 295500 bytes .../trek3d/clips/dashboard-dark-poke.mp4 | Bin 0 -> 115954 bytes .../trek3d/clips/dashboard-light-loop.mp4 | Bin 0 -> 229182 bytes .../trek3d/clips/dashboard-light-poke.mp4 | Bin 0 -> 93030 bytes .../trek3d/clips/files-dark-loop.mp4 | Bin 0 -> 429246 bytes .../trek3d/clips/files-dark-poke.mp4 | Bin 0 -> 113971 bytes .../trek3d/clips/files-light-loop.mp4 | Bin 0 -> 324636 bytes .../trek3d/clips/files-light-poke.mp4 | Bin 0 -> 94361 bytes .../trek3d/clips/guide-dark-loop.mp4 | Bin 0 -> 275381 bytes .../trek3d/clips/guide-dark-poke.mp4 | Bin 0 -> 164077 bytes .../trek3d/clips/guide-light-loop.mp4 | Bin 0 -> 216462 bytes .../trek3d/clips/guide-light-poke.mp4 | Bin 0 -> 133518 bytes .../trek3d/clips/idle--confused-dark-loop.mp4 | Bin 0 -> 182712 bytes .../clips/idle--confused-light-loop.mp4 | Bin 0 -> 143533 bytes .../trek3d/clips/idle--error-dark-loop.mp4 | Bin 0 -> 185891 bytes .../trek3d/clips/idle--error-light-loop.mp4 | Bin 0 -> 144625 bytes .../trek3d/clips/idle--happy-dark-loop.mp4 | Bin 0 -> 184585 bytes .../trek3d/clips/idle--happy-light-loop.mp4 | Bin 0 -> 142381 bytes .../trek3d/clips/idle--sad-dark-loop.mp4 | Bin 0 -> 180324 bytes .../trek3d/clips/idle--sad-light-loop.mp4 | Bin 0 -> 142055 bytes .../trek3d/clips/idle--sleepy-dark-loop.mp4 | Bin 0 -> 183441 bytes .../trek3d/clips/idle--sleepy-light-loop.mp4 | Bin 0 -> 142233 bytes .../trek3d/clips/idle-dark-loop.mp4 | Bin 0 -> 190208 bytes .../trek3d/clips/idle-dark-poke.mp4 | Bin 0 -> 187748 bytes .../trek3d/clips/idle-light-loop.mp4 | Bin 0 -> 148190 bytes .../trek3d/clips/idle-light-poke.mp4 | Bin 0 -> 155508 bytes .../trek3d/clips/journey-dark-loop.mp4 | Bin 0 -> 476368 bytes .../trek3d/clips/journey-dark-poke.mp4 | Bin 0 -> 185164 bytes .../trek3d/clips/journey-light-loop.mp4 | Bin 0 -> 376943 bytes .../trek3d/clips/journey-light-poke.mp4 | Bin 0 -> 152137 bytes .../trek3d/clips/links-dark-loop.mp4 | Bin 0 -> 427431 bytes .../trek3d/clips/links-dark-poke.mp4 | Bin 0 -> 173857 bytes .../trek3d/clips/links-light-loop.mp4 | Bin 0 -> 339776 bytes .../trek3d/clips/links-light-poke.mp4 | Bin 0 -> 142718 bytes .../trek3d/clips/notes-dark-loop.mp4 | Bin 0 -> 532752 bytes .../trek3d/clips/notes-dark-poke.mp4 | Bin 0 -> 187396 bytes .../trek3d/clips/notes-light-loop.mp4 | Bin 0 -> 399790 bytes .../trek3d/clips/notes-light-poke.mp4 | Bin 0 -> 142136 bytes .../trek3d/clips/notifications-dark-loop.mp4 | Bin 0 -> 460705 bytes .../trek3d/clips/notifications-dark-poke.mp4 | Bin 0 -> 155025 bytes .../trek3d/clips/notifications-light-loop.mp4 | Bin 0 -> 376040 bytes .../trek3d/clips/notifications-light-poke.mp4 | Bin 0 -> 125065 bytes .../trek3d/clips/packing--happy-dark-loop.mp4 | Bin 0 -> 312682 bytes .../clips/packing--happy-light-loop.mp4 | Bin 0 -> 249608 bytes .../trek3d/clips/packing-dark-loop.mp4 | Bin 0 -> 324284 bytes .../trek3d/clips/packing-dark-poke.mp4 | Bin 0 -> 182794 bytes .../trek3d/clips/packing-light-loop.mp4 | Bin 0 -> 256024 bytes .../trek3d/clips/packing-light-poke.mp4 | Bin 0 -> 148936 bytes .../trek3d/clips/polls-dark-loop.mp4 | Bin 0 -> 468852 bytes .../trek3d/clips/polls-dark-poke.mp4 | Bin 0 -> 139057 bytes .../trek3d/clips/polls-light-loop.mp4 | Bin 0 -> 374405 bytes .../trek3d/clips/polls-light-poke.mp4 | Bin 0 -> 114056 bytes .../trek3d/clips/search--sad-dark-loop.mp4 | Bin 0 -> 363312 bytes .../trek3d/clips/search--sad-light-loop.mp4 | Bin 0 -> 292002 bytes .../trek3d/clips/search-dark-loop.mp4 | Bin 0 -> 366928 bytes .../trek3d/clips/search-dark-poke.mp4 | Bin 0 -> 147436 bytes .../trek3d/clips/search-light-loop.mp4 | Bin 0 -> 295191 bytes .../trek3d/clips/search-light-poke.mp4 | Bin 0 -> 119624 bytes .../trek3d/clips/tasks-dark-loop.mp4 | Bin 0 -> 437465 bytes .../trek3d/clips/tasks-dark-poke.mp4 | Bin 0 -> 168870 bytes .../trek3d/clips/tasks-light-loop.mp4 | Bin 0 -> 340708 bytes .../trek3d/clips/tasks-light-poke.mp4 | Bin 0 -> 136706 bytes .../trek3d/clips/tours-dark-loop.mp4 | Bin 0 -> 500237 bytes .../trek3d/clips/tours-dark-poke.mp4 | Bin 0 -> 200530 bytes .../trek3d/clips/tours-light-loop.mp4 | Bin 0 -> 421886 bytes .../trek3d/clips/tours-light-poke.mp4 | Bin 0 -> 171684 bytes .../clips/transport--happy-dark-loop.mp4 | Bin 0 -> 415482 bytes .../clips/transport--happy-light-loop.mp4 | Bin 0 -> 328511 bytes .../trek3d/clips/transport--sad-dark-loop.mp4 | Bin 0 -> 416639 bytes .../clips/transport--sad-light-loop.mp4 | Bin 0 -> 323679 bytes .../trek3d/clips/transport-dark-loop.mp4 | Bin 0 -> 431983 bytes .../trek3d/clips/transport-dark-poke.mp4 | Bin 0 -> 122268 bytes .../trek3d/clips/transport-light-loop.mp4 | Bin 0 -> 332973 bytes .../trek3d/clips/transport-light-poke.mp4 | Bin 0 -> 101492 bytes 101 files changed, 619 insertions(+), 67 deletions(-) create mode 100644 client/src/mobile/components/trek3d/MascotClip.tsx create mode 100644 client/src/mobile/components/trek3d/clips.meta.json create mode 100644 client/src/mobile/components/trek3d/clips/atlas-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/atlas-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/atlas-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/atlas-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/bookings-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/bookings-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/bookings-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/bookings-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/chat-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/chat-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/chat-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/chat-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/collections--happy-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/collections--happy-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/collections-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/collections-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/collections-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/collections-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/costs-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/costs-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/costs-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/costs-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/dashboard--happy-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/dashboard--happy-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/dashboard-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/dashboard-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/dashboard-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/dashboard-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/files-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/files-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/files-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/files-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/guide-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/guide-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/guide-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/guide-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--confused-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--confused-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--error-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--error-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--happy-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--happy-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--sad-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--sad-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--sleepy-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle--sleepy-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/idle-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/journey-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/journey-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/journey-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/journey-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/links-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/links-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/links-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/links-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/notes-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/notes-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/notes-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/notes-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/notifications-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/notifications-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/notifications-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/notifications-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/packing--happy-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/packing--happy-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/packing-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/packing-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/packing-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/packing-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/polls-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/polls-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/polls-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/polls-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/search--sad-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/search--sad-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/search-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/search-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/search-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/search-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/tasks-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/tasks-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/tasks-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/tasks-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/tours-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/tours-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/tours-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/tours-light-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/transport--happy-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/transport--happy-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/transport--sad-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/transport--sad-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/transport-dark-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/transport-dark-poke.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/transport-light-loop.mp4 create mode 100644 client/src/mobile/components/trek3d/clips/transport-light-poke.mp4 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