307 Commits

Author SHA1 Message Date
math-pixel 1ebc3069dd update: upload-gltf add a new model -> blocking
📦 Model
   model.gltf
2026-05-17 12:19:52 +00:00
Tom Boullay 214c2bdaa6 Merge branch 'develop' into design 2026-05-12 16:36:32 +02:00
math-pixel aa2250aba1 Merge pull request 'chore: add three devtools debug mode' (#5) from feat/code-review into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
Reviewed-on: #5
2026-05-12 14:05:33 +00:00
math-pixel 544e540c6a Merge branch 'develop' into feat/code-review
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (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
2026-05-12 14:05:23 +00:00
Tom Boullay 832f8b59be Update repairMissions.ts
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (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
2026-05-12 15:38:04 +02:00
Tom Boullay 8c54451b2e Create code-review-preparation.md
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
2026-05-12 11:52:40 +02:00
Tom Boullay c09818aa67 docs: audit app architecture and refresh feature documentation
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
2026-05-12 11:46:11 +02:00
Tom Boullay e65ad62b76 chore: add three devtools debug mode 2026-05-12 11:45:43 +02:00
math-pixel b04aa95966 Merge pull request 'Feat/env-manager2' (#4) from feat/env-manager into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
Reviewed-on: #4
2026-05-12 08:59:27 +00:00
Tom Boullay cfb761e66c merge: sync develop into env manager
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (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
2026-05-12 10:56:56 +02:00
Tom Boullay 3bef55e12f fix: restore animated model
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
2026-05-12 10:41:31 +02:00
Tom Boullay 15361db203 update: organize editor controls panel
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (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
2026-05-12 10:18:12 +02:00
Tom Boullay 45fc8e9a83 fix: align router and rapier dependencies
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
2026-05-12 09:50:58 +02:00
Tom Boullay ffca1e9e5f fix: stabilize game scene loading and player spawn 2026-05-11 23:52:57 +02:00
math-pixel e05c67ee73 Merge pull request 'Feat/repair game' (#2) from feat/repair-game into develop
Reviewed-on: #2
2026-05-11 15:33:18 +00:00
Tom Boullay 5c8c35cc72 fix: lint and format 2026-05-11 17:32:47 +02:00
Tom Boullay 711c336f1e Merge branch 'develop' into feat/repair-game 2026-05-11 17:31:14 +02:00
math-pixel 12c96b43c1 Merge pull request 'Feat/env-manager' (#1) from feat/env-manager into develop
Reviewed-on: #1
2026-05-11 15:23:31 +00:00
Tom Boullay 2bb2fff310 Update .gitignore 2026-05-11 17:23:00 +02:00
Tom Boullay 8c52d2e84f update: new and actual models 2026-05-11 17:13:08 +02:00
Tom Boullay 052ab34740 fix: merge 2026-05-11 17:01:02 +02:00
Tom Boullay ff21b80d57 fix: a pb with octree 2026-05-11 16:43:02 +02:00
Tom Boullay 8088e67625 fix: a pb with octree 2026-05-11 16:41:11 +02:00
Tom Boullay 6f5137229d update: en dialogue sub 2026-05-11 13:37:12 +02:00
Tom Boullay 6d3a8fde52 update: cinematic references 2026-05-11 13:22:15 +02:00
Tom Boullay 26a9c1c4d4 Update docsTranslations.ts 2026-05-11 13:21:01 +02:00
Tom Boullay f91756bc27 update: document repair movement lock indicator 2026-05-11 13:17:20 +02:00
Tom Boullay e9875206ff update: show repair movement lock indicator 2026-05-11 13:15:16 +02:00
Tom Boullay 841f96f3fd update: doc 2026-05-11 13:14:08 +02:00
Tom Boullay 00b1780b10 update: document repair movement lock 2026-05-11 13:12:37 +02:00
Tom Boullay c81bacc208 update: doc dialogue and cinematic tools 2026-05-11 13:10:26 +02:00
Tom Boullay 49d1a7324c update: lock player movement during repair 2026-05-11 13:09:50 +02:00
Tom Boullay 131fe39311 docs: repair interaction flow 2026-05-11 13:05:46 +02:00
Tom Boullay 3c72298a45 update: assit dialogue and srt creation 2026-05-11 13:05:03 +02:00
Tom Boullay 49e96c7f5a update: improve repair debug mission switching 2026-05-11 13:03:46 +02:00
Tom Boullay e5aeab6534 update: edit cinematic dialogue 2026-05-11 13:01:56 +02:00
Tom Boullay cec4d6ad0d update: reset repair runtime state 2026-05-11 13:01:32 +02:00
Tom Boullay 202ac4628d fix: sequence repair case completion exit 2026-05-11 12:58:37 +02:00
Tom Boullay 6a4d0f7eb1 update: sync dialogue and cinematic 2026-05-11 12:58:12 +02:00
Tom Boullay 40368c22fc add: blocked repair install feedback 2026-05-11 12:56:54 +02:00
Tom Boullay 788e9f0fb3 update: feedback repair model and improve repair case interaction feedback 2026-05-11 12:54:54 +02:00
Tom Boullay 7a439d0879 add: cinematic preview 2026-05-11 12:53:18 +02:00
Tom Boullay 9f5c105c1b update: add dialogue preview 2026-05-11 12:48:59 +02:00
Tom Boullay 72f8acb61c Update RepairCaseModel.tsx 2026-05-11 12:48:38 +02:00
Tom Boullay 3791ab71cd update: add cinecmatic editor 2026-05-11 12:11:58 +02:00
Tom Boullay 1ad4a164ea fix: clarify repair mission locked flow 2026-05-11 12:10:35 +02:00
Tom Boullay cc8ea20536 update: stabilize repair mission stage mounting 2026-05-11 11:51:06 +02:00
Tom Boullay bd14042ca0 update: add dialogue manifest 2026-05-11 11:48:05 +02:00
Tom Boullay 7036b7b0f9 fix: preload repair mission assets 2026-05-11 11:47:20 +02:00
Tom Boullay f4635def91 update: align srt duration 2026-05-11 11:38:19 +02:00
Tom Boullay e16d6e15fa fix repair game suspense boundaries 2026-05-11 11:37:54 +02:00
Tom Boullay f9a6390260 fix: add tracking + add new models 2026-05-11 11:25:17 +02:00
Tom Boullay b521cdd361 update: french srt 2026-05-11 11:22:06 +02:00
Tom Boullay 4218fde63a update: add runtine camera keyframe 2026-05-11 11:13:49 +02:00
Tom Boullay c2ba26ca86 add: loading 2026-05-11 11:11:46 +02:00
Tom Boullay f74a0c5eae update: audio already use 2026-05-11 10:29:46 +02:00
Tom Boullay a93d2dc92c update: audimanager 2026-05-11 10:22:12 +02:00
Tom Boullay 4b7498ae13 update: trigger dialogue en fonction du gameplay 2026-05-11 10:03:07 +02:00
Tom Boullay a34396b958 docs: queue dialogue 2026-05-11 09:43:40 +02:00
Tom Boullay 8cbf696b6e docs: add some docs 2026-05-11 09:18:46 +02:00
Tom Boullay 311c243506 add: dev dialogue manisfest validation panel 2026-05-11 09:09:34 +02:00
math-pixel e8fb859f79 update 2026-05-11 08:56:54 +02:00
math-pixel 17836ec889 update 2026-05-11 08:32:16 +02:00
Tom Boullay 6101bca84f add: dev manifest api validation 2026-05-10 00:54:33 +01:00
Tom Boullay 5889a325ec update: add stereo 2026-05-10 00:50:34 +01:00
Tom Boullay 08f89c9de5 fix: add config vite error srt 2026-05-10 00:49:19 +01:00
Tom Boullay 5ec10f4a35 update: gros commit fix editor srt panel 3 2026-05-10 00:40:26 +01:00
Tom Boullay 71b46b5fea update: gros commit fix editor srt panel 2 2026-05-10 00:37:28 +01:00
Tom Boullay 4a8a1368b2 update: gros commit fix editor srt panl 2026-05-10 00:35:23 +01:00
Tom Boullay c9e5ce3279 update: confort + ui 2026-05-10 00:33:18 +01:00
Tom Boullay f55be58c0b add: audio preview 2026-05-10 00:31:16 +01:00
Tom Boullay 48f2c9ef80 update: generate complete srt template 2026-05-10 00:29:42 +01:00
Tom Boullay 807503fde5 update: fix bug de merde 2026-05-10 00:27:48 +01:00
Tom Boullay 75b77a52bf update: validation/errors srt 2026-05-10 00:25:45 +01:00
Tom Boullay 916b5d304f update: save srt files 2026-05-10 00:23:37 +01:00
Tom Boullay 3d81a9281a add: french subtitles 2026-05-10 00:20:16 +01:00
Tom Boullay 0c8b9070bb add: add str editing panel 2026-05-10 00:13:42 +01:00
Tom Boullay 0fbf6bfa0e add: trigger dialogue with timecode 2026-05-10 00:10:16 +01:00
Tom Boullay 53fdf3cb1e update: play audio + srt sync 2026-05-10 00:07:56 +01:00
Tom Boullay 8ef1da0e9a upatde: load dialogue en fonction du language 2026-05-10 00:04:59 +01:00
Tom Boullay 1c30b73253 add: load dialohue manifest 2026-05-10 00:02:48 +01:00
Tom Boullay f5bc7cb08e add: dialoguejson 2026-05-10 00:00:36 +01:00
Tom Boullay d9525b0aaf add: parser srt files 2026-05-09 23:53:19 +01:00
Tom Boullay 5b43f503ac add: basic subtitle 2026-05-09 23:51:22 +01:00
Tom Boullay 974f1e33fb add: settings menu + menu store 2026-05-09 23:45:05 +01:00
Tom Boullay ce67d07107 add: global cat volumes 2026-05-09 23:37:07 +01:00
Tom Boullay 53add29a48 add: type audio playback cat 2026-05-09 23:30:14 +01:00
Tom Boullay 33524f8409 add: reusable world video prompt billboard 2026-05-09 01:28:06 +01:00
Tom Boullay e0eae67ace fix repair game interaction coordinate spaces 2026-05-09 01:19:16 +01:00
Tom Boullay 254311bddf add: playground in testmap 2026-05-08 03:07:52 +01:00
Tom Boullay 6d9eac291e big clean up 2026-05-08 03:02:26 +01:00
Tom Boullay e4f6ec211c add: configure mission-specific repair variants 2026-05-08 02:41:57 +01:00
Tom Boullay ead3634aab add: animate repair reassembly 2026-05-08 02:40:31 +01:00
Tom Boullay 19a83982a9 add: require broken part deposit before repair 2026-05-08 02:36:14 +01:00
Tom Boullay bebb9ac5a3 add: snap repair parts to case placeholders 2026-05-08 02:33:06 +01:00
Tom Boullay d02ef54bdc add: focus repair case view 2026-05-08 02:22:15 +01:00
Tom Boullay 3f3f623832 fix: track Logger filename casing 2026-05-08 02:20:14 +01:00
Tom Boullay 0256dfa812 add: show broken part prompt during scan 2026-05-08 02:18:00 +01:00
Tom Boullay 5c688fdaf7 add: highlight broken repair parts during scan 2026-05-08 02:16:13 +01:00
Tom Boullay 7a3baa4c0b add: scan fragmented repair parts sequentially 2026-05-08 02:12:58 +01:00
Tom Boullay 95d9bd4f3e add: animation on repair case 2026-05-08 02:10:19 +01:00
Tom Boullay eee69825c6 clean: remove obsolete repair debug code + unused core utilities 2026-05-08 02:07:03 +01:00
Tom Boullay 15c3d1858f add: repair mission completion step 2026-05-08 01:48:40 +01:00
Tom Boullay 7ee842c535 update: validate correct repair replacement part 2026-05-08 01:47:07 +01:00
Tom Boullay d4f215a948 update: require replacement placement before repair completion 2026-05-08 01:45:00 +01:00
Tom Boullay 7bbcf4359e add: repair install completion step 2026-05-08 01:41:29 +01:00
Tom Boullay eed0077dd1 add: repair fragmentation and scan flow 2026-05-08 01:39:23 +01:00
Tom Boullay f5da2f4994 update: enable hand tracking for repair steps (not only when we are close to something) 2026-05-08 01:30:27 +01:00
Tom Boullay ed60114d06 add: repair game inspection sub state 2026-05-08 01:27:32 +01:00
Tom Boullay 861a369776 add: physics in game scene 2026-05-08 01:17:35 +01:00
Tom Boullay 96796bca65 add: repair mission config 2026-05-08 01:14:30 +01:00
Tom Boullay 8fa4b087ba update: add generic repair mission store helpers 2026-05-08 01:09:42 +01:00
Tom Boullay 9371c50110 Merge pull request #7 from La-Fabrik-Durable/feat/main-feature
Feat/main feature
2026-05-08 01:56:18 +02:00
Tom Boullay fb230911a7 Update HandTrackingGlove.tsx 2026-05-08 00:54:42 +01:00
Tom Boullay 457eebc0e3 add hand tracking source debug switch 2026-05-06 23:23:10 +01:00
Tom Boullay 03dfef4aad add browser hand tracking source 2026-05-06 23:23:04 +01:00
Tom Boullay 4bcdbef974 fix hand tracking glove root transform 2026-05-06 23:22:56 +01:00
Tom Boullay 553dc6eb0a fix: flickering hands 2026-05-06 23:16:58 +01:00
Tom Boullay 7ae45d4cfa fix; distance grab objetc 2026-05-02 21:34:23 +02:00
Tom Boullay 27928b00a6 add hand tracking glove bone mapping 2026-05-02 11:38:02 +02:00
Tom Boullay ac7f60060c fix hand tracking glove fallback and loading 2026-05-02 11:35:28 +02:00
Tom Boullay fe662ebe7d fix hand tracking glove rendering 2026-05-02 11:32:00 +02:00
Tom Boullay bdc06f772f update: remove old model elec 2026-05-02 11:02:51 +02:00
Tom Boullay f7a589a11f fix three and rapier warning dependencies 2026-05-02 11:01:50 +02:00
Tom Boullay c71cd35f4d fix electricienne debug model loading 2026-05-02 10:58:00 +02:00
Tom Boullay 0cb5f57182 feat add left hand tracking glove model 2026-05-02 00:14:56 +02:00
Tom Boullay 1d64582383 feat add model loading diagnostics 2026-05-02 00:14:47 +02:00
Tom Boullay 4d7d2efdcc cleaning repo models 2026-05-01 23:54:48 +02:00
Tom Boullay d438c02ad9 update: models made them working 2026-05-01 23:45:58 +02:00
Tom Boullay eef39ab53d update: debug overlay layout controls 2026-05-01 23:39:04 +02:00
Tom Boullay 1a783f1867 Update TestMap.tsx 2026-04-30 16:29:56 +02:00
Tom Boullay bb08054722 connect repair gameplay to zustand progression 2026-04-30 16:25:54 +02:00
Tom Boullay 1625895708 fix: models 2026-04-30 15:48:45 +02:00
Tom Boullay a87ffac63f Merge branch 'develop' into feat/main-feature 2026-04-30 15:48:35 +02:00
Tom Boullay 44a77a0197 Update arbre.bin 2026-04-30 15:09:27 +02:00
Tom Boullay f301a1e318 Merge branch 'design' into feat/main-feature 2026-04-30 15:09:22 +02:00
Tom Boullay 39fbf2333d Merge pull request #11 from La-Fabrik-Durable/feat/zustand
Feat/zustand
2026-04-30 15:07:44 +02:00
Tom Boullay 5f018e6de7 resolve three component type exports 2026-04-30 15:06:26 +02:00
Tom Boullay a14f776e5d address zustand progression review feedback 2026-04-30 14:59:41 +02:00
Tom Boullay 8884edb281 clarify managers and zustand store responsibilities 2026-04-30 14:38:07 +02:00
Tom Boullay b9970c4e03 add zustand game state 2026-04-30 14:29:29 +02:00
Tom Boullay 6a29cfdb0b add : some sounds 2026-04-30 14:25:36 +02:00
Tom Boullay 7c7dbdb588 connect game progression state to world 2026-04-30 14:24:59 +02:00
Tom Boullay 2696289483 add zustand game progression store 2026-04-30 14:04:01 +02:00
Tom Boullay b79f5c9314 chore: align repo health checks and docs 2026-04-30 13:51:39 +02:00
Tom Boullay 5265cdc7e5 add: prettier eslint 2026-04-30 13:36:07 +02:00
Tom Boullay cfb1eaf39a refactor: clean architecture and remove unused code 2026-04-30 13:33:28 +02:00
Tom Boullay b1187b68ae refactor: split hooks types and utils by domain 2026-04-30 11:49:18 +02:00
Tom Boullay 9ac5844182 refactor: organize three components by domain 2026-04-30 11:35:53 +02:00
Tom Boullay 37eded8d7e fix: correct repair case open state rotation 2026-04-30 10:42:47 +02:00
Tom Boullay 92097e5256 feat: add game music loop and mallette sounds 2026-04-30 10:06:00 +02:00
Tom Boullay 01c583ba96 refactor: prepare main feature gameplay object and use GLB sky model 2026-04-30 10:02:00 +02:00
Tom Boullay f66609178b feat: expand main feature model catalog 2026-04-29 23:30:40 +02:00
Tom Boullay 2783b13488 feat: add main feature module selection 2026-04-29 23:30:31 +02:00
Tom Boullay 8b3f24b90b feat: add openable repair case model 2026-04-29 23:30:22 +02:00
Tom Boullay 5173002283 chore: track bin assets with lfs 2026-04-29 17:07:44 +02:00
Tom Boullay fd5294fb54 Merge remote-tracking branch 'origin/feat/main-feature' into feat/main-feature
# Conflicts:
#	src/world/GameMap.tsx
2026-04-29 16:57:58 +02:00
Tom Boullay 95e7e9c0a5 feat: support glb model assets 2026-04-29 16:18:24 +02:00
Tom Boullay 94b8dd0385 Merge branch 'develop' into feat/main-feature 2026-04-29 15:01:17 +02:00
Tom Boullay ad6f1552c6 update: docs 2026-04-29 13:01:10 +02:00
math-pixel d3ba5f6cfb Merge branch 'design' of https://github.com/La-Fabrik-Durable/La-Fabrik into design 2026-04-29 12:16:51 +02:00
math-pixel 05ef9e012a add object 2026-04-29 12:00:11 +02:00
math-pixel aa8b31033f feat: add tree 2026-04-29 11:57:43 +02:00
Tom Boullay 719ddef3ba fix: position perf panel beside debug gui 2026-04-29 11:56:46 +02:00
math-pixel 462b80e1c2 Merge pull request #10 from La-Fabrik-Durable/feat-animation
Feat/animation
2026-04-29 11:50:07 +02:00
Tom Boullay 90bd216efe feat: improve hand grab targeting 2026-04-29 11:40:17 +02:00
math-pixel ba9833d407 fix: pr 2026-04-29 11:35:17 +02:00
math-pixel b736de5f25 Merge branch 'feat-animation' of https://github.com/La-Fabrik-Durable/La-Fabrik into feat-animation 2026-04-29 11:23:57 +02:00
math-pixel d482ae4634 fix: pr issues 2026-04-29 11:23:40 +02:00
Tom Boullay 7958b2c62a fix: decouple hand tracking from crosshair focus 2026-04-29 11:13:11 +02:00
math-pixel d3a3d5eeca Update src/hooks/useCharacterAnimation.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-29 11:07:40 +02:00
math-pixel dca8f5a0d3 update: animation doc 2026-04-29 11:00:32 +02:00
Tom Boullay 8e08c3d5e0 update: upload-gltf add a new model -> createurdepluie
📦 Model
   model.gltf

🎨 Textures (color)
   color_bac_eau.png (compressed)
   color_cable_1.png (compressed)
   color_cable_2.png (compressed)
   color_refroidisseur.png (compressed)
   color_resistance.png (compressed)
   color_shell.png (compressed)
   color_tuyau.png (compressed)

🧭 Textures (normal)
   normal_bac_eau.png (compressed)
   normal_cable_1.png (compressed)
   normal_cable_2.png (compressed)
   normal_refroidisseur.png (compressed)
   normal_resistance.png (compressed)
   normal_shell.png (compressed)
   normal_tuyau.png (compressed)

🧱 Textures (orm)
   orm_bac_eau.png (compressed)
   orm_cable_1.png (compressed)
   orm_cable_2.png (compressed)
   orm_refroidisseur.png (compressed)
   orm_resistance.png (compressed)
   orm_shell.png (compressed)
   orm_tuyau.png (compressed)

🧩 Assets
   createurdepluie2.bin
2026-04-29 11:00:08 +02:00
Tom Boullay 882f3cc71b feat: improve fist grab depth tracking 2026-04-29 10:52:35 +02:00
math-pixel 6c85485622 fix : comflic 2026-04-29 10:51:40 +02:00
Tom Boullay a14ff9d913 feat: grab objects with closed fist raycast 2026-04-29 10:40:48 +02:00
Tom Boullay cc4c11f934 refactor: replace pinch gesture with fist gesture 2026-04-29 10:34:11 +02:00
Tom Boullay 0a0519cead update: upload-gltf add a new model -> gant_r
📦 Model
   model.gltf

🎨 Textures (color)
   color_gant.png (compressed)

🧭 Textures (normal)
   normal_gant.png (compressed)

🧱 Textures (orm)
   orm_gant.png (compressed)

🧩 Assets
   gant_r.bin
2026-04-29 10:03:04 +02:00
Tom Boullay 7d09c29828 update: upload-gltf add a new model -> gant_r_pad
📦 Model
   model.gltf

🎨 Textures (color)
   color_galet.png (compressed)
   color_gant.png (compressed)

🧭 Textures (normal)
   normal_galet.png (compressed)
   normal_gant.png (compressed)

🧱 Textures (orm)
   orm_galet.png (compressed)
   orm_gant.png (compressed)

🧩 Assets
   gant_r_pad.bin
2026-04-29 10:02:21 +02:00
Tom Boullay 28e3ac4c06 fix: guard hand landmark visualization 2026-04-29 09:52:46 +02:00
Tom Boullay cc78420d9c Create model.gltf 2026-04-29 09:05:04 +02:00
Tom Boullay 2747a95847 Merge branch 'design' into feat/main-feature 2026-04-29 09:05:00 +02:00
Tom Boullay d116f26a42 update: upload-gltf add a new model -> talkie
📦 Model
   model.gltf

🎨 Textures (color)
   color_boutonb.png (compressed)
   color_e_cran.png (compressed)
   color_boutona.png (compressed)
   color_cadre.png (compressed)
   color_hautparleur.png (compressed)
   color_touches.png (compressed)
   color_cable2.png (compressed)
   color_talkie.png (compressed)
   color_antenne.png (compressed)
   color_prise.png (compressed)
   color_cable1.png (compressed)

🪶 Textures (roughness)
   roughness_talkie.png (compressed)
   roughness_antenne.png (compressed)
   roughness_touches.png (compressed)
   roughness_prise.png (compressed)
   roughness_hautparleur.png (compressed)
   roughness_cable2.png (compressed)
   roughness_cable1.png (compressed)
   roughness_cadre.png (compressed)
   roughness_boutonb.png (compressed)
   roughness_e_cran.png (compressed)
   roughness_boutona.png (compressed)

🧭 Textures (normal)
   cadre_normal_opengl.png (compressed)
   talkie_normal_opengl.png (compressed)
   hautparleur_normal_opengl.png (compressed)
   hautparleur_normal.png (compressed)
   prise_normal_opengl.png (compressed)
   boutonb_normal_opengl.png (compressed)
   touches_normal.png (compressed)
   antenne_normal.png (compressed)
   boutona_normal_opengl.png (compressed)
   cadre_normal.png (compressed)
   touches_normal_opengl.png (compressed)
   e_cran_normal.png (compressed)
   cable2_normal.png (compressed)
   boutona_normal.png (compressed)
   talkie_normal.png (compressed)
   cable1_normal.png (compressed)
   boutonb_normal.png (compressed)
   prise_normal.png (compressed)
   cable2_normal_opengl.png (compressed)
   antenne_normal_opengl.png (compressed)
   cable1_normal_opengl.png (compressed)
   e_cran_normal_opengl.png (compressed)

🔩 Textures (metalness)
   metalness_cable1.png (compressed)
   metalness_boutonb.png (compressed)
   metalness_touches.png (compressed)
   metalness_e_cran.png (compressed)
   metalness_boutona.png (compressed)
   metalness_talkie.png (compressed)
   metalness_antenne.png (compressed)
   metalness_hautparleur.png (compressed)
   metalness_cadre.png (compressed)
   metalness_cable2.png (compressed)
   metalness_prise.png (compressed)

⛰ Textures (height)
   height_hautparleur.png (compressed)
   height_touches.png (compressed)
   height_antenne.png (compressed)
   height_cadre.png (compressed)
   height_e_cran.png (compressed)
   height_cable2.png (compressed)
   height_boutona.png (compressed)
   height_talkie.png (compressed)
   height_cable1.png (compressed)
   height_boutonb.png (compressed)
   height_prise.png (compressed)

🌑 Textures (ao)
   ao_cable1.png (compressed)
   ao_e_cran.png (compressed)
   ao_boutonb.png (compressed)
   ao_touches.png (compressed)
   ao_antenne.png (compressed)
   ao_talkie.png (compressed)
   ao_boutona.png (compressed)
   ao_cable2.png (compressed)
   ao_prise.png (compressed)
   ao_hautparleur.png (compressed)
   ao_cadre.png (compressed)

🧩 Assets
   model.bin
2026-04-29 08:44:37 +02:00
Tom Boullay 9f746aa7b0 update: upload-gltf add a new model -> refroidisseur
📦 Model
   model.gltf

🎨 Textures (color)
   color_refroidisseur.png (compressed)

🪶 Textures (roughness)
   roughness_refroidisseur.png (compressed)

🧭 Textures (normal)
   refroidisseur_normal.png (compressed)
   refroidisseur_normal_opengl.png (compressed)

🔩 Textures (metalness)
   metalness_refroidisseur.png (compressed)

⛰ Textures (height)
   height_refroidisseur.png (compressed)

🌑 Textures (ao)
   ao_refroidisseur.png (compressed)

🧩 Assets
   model.bin
2026-04-29 08:42:35 +02:00
Tom Boullay 847d6834fd update: upload-gltf add a new model -> immeuble1
📦 Model
   model.gltf

🎨 Textures (color)
   color_buisson.png (compressed)
   color_fenetre.png (compressed)
   color_feuilles.png (compressed)
   color_maison.png (compressed)
   color_panneau.png (compressed)
   color_porte.png (compressed)
   color_tronc.png (compressed)

🧭 Textures (normal)
   normal_buisson.png (compressed)
   normal_fenetre.png (compressed)
   normal_feuilles.png (compressed)
   normal_maison.png (compressed)
   normal_panneau.png (compressed)
   normal_porte.png (compressed)
   normal_tronc.png (compressed)

🧱 Textures (orm)
   orm_buisson.png (compressed)
   orm_fenetre.png (compressed)
   orm_feuilles.png (compressed)
   orm_maison.png (compressed)
   orm_panneau.png (compressed)
   orm_porte.png (compressed)
   orm_tronc.png (compressed)

🧩 Assets
   immeuble1-2.bin
2026-04-28 20:18:56 +02:00
math-pixel bd641328b0 feat: animator 2026-04-28 20:14:37 +02:00
Tom Boullay dbbb67e55a update: upload-gltf add a new model -> maison1
📦 Model
   model.gltf

🎨 Textures (color)
   color_buisson.png (compressed)
   color_contours.png (compressed)
   color_fenetre.png (compressed)
   color_maison.png (compressed)
   color_panneau.png (compressed)
   color_porte.png (compressed)
   color_toit.png (compressed)

🧭 Textures (normal)
   normal_buisson.png (compressed)
   normal_contours.png (compressed)
   normal_fenetre.png (compressed)
   normal_maison.png (compressed)
   normal_panneau.png (compressed)
   normal_porte.png (compressed)
   normal_toit.png (compressed)

🧱 Textures (orm)
   orm_buisson.png (compressed)
   orm_contours.png (compressed)
   orm_fenetre.png (compressed)
   orm_maison.png (compressed)
   orm_panneau.png (compressed)
   orm_porte.png (compressed)
   orm_toit.png (compressed)

🧩 Assets
   maison.bin
2026-04-28 20:07:17 +02:00
Tom Boullay 10dac2e56b update: upload-gltf add a new model -> persoprincipal
📦 Model
   model.gltf

🎨 Textures (color)
   color_defaultmaterial.png (compressed)

🧭 Textures (normal)
   normal_defaultmaterial.png (compressed)

🧱 Textures (orm)
   orm_defaultmaterial.png (compressed)

🧩 Assets
   mc.bin
2026-04-28 20:02:25 +02:00
Tom Boullay aaad1b5884 update: upload-gltf add a new model -> fermier
📦 Model
   model.gltf

🎨 Textures (color)
   color_defaultmaterial.png (compressed)

🧭 Textures (normal)
   normal_defaultmaterial.png (compressed)

🧱 Textures (orm)
   orm_defaultmaterial.png (compressed)

🧩 Assets
   fermier.bin
2026-04-28 19:58:21 +02:00
Tom Boullay bf98f028fe update: upload-gltf add a new model -> gerant
📦 Model
   model.gltf

🎨 Textures (color)
   defaultmaterial_basecolor.png (compressed)
   defaultmaterial_base_color.png (compressed)

🪶 Textures (roughness)
   roughness_defaultmaterial.png (compressed)

🧭 Textures (normal)
   defaultmaterial_normal.png (compressed)
   defaultmaterial_normal_opengl.png (compressed)

🔩 Textures (metalness)
   metalness_defaultmaterial.png (compressed)

⛰ Textures (height)
   height_defaultmaterial.png (compressed)

🧱 Textures (orm)
   orm_defaultmaterial.png (compressed)

🧩 Assets
   gerant.bin
2026-04-28 19:56:34 +02:00
Tom Boullay bff24030ee update: upload-gltf add a new model -> sapin
📦 Model
   model.gltf

🎨 Textures (color)
   color_mat.1.png (compressed)
   color_mat.png (compressed)

🧭 Textures (normal)
   normal_mat.1.png (compressed)
   normal_mat.png (compressed)

🧱 Textures (orm)
   orm_mat.1.png (compressed)
   orm_mat.png (compressed)

🧩 Assets
   sapin.bin
2026-04-28 19:54:38 +02:00
Tom Boullay 6531d9b183 update: upload-gltf add a new model -> eolienne
📦 Model
   model.gltf

🎨 Textures (color)
   color_feuilles1st.png (compressed)
   color_he_lisse.png (compressed)
   color_pied.png (compressed)
   color_tiges1st.png (compressed)
   color_moteur.png (compressed)
   color_feuilles2nd.png (compressed)
   color_tiges2nd.png (compressed)
   color_cul.png (compressed)

🪶 Textures (roughness)
   roughness_tiges2nd.png (compressed)
   roughness_moteur.png (compressed)
   roughness_feuilles2nd.png (compressed)
   roughness_tiges1st.png (compressed)
   roughness_he_lisse.png (compressed)
   roughness_cul.png (compressed)
   roughness_pied.png (compressed)
   roughness_feuilles1st.png (compressed)

🧭 Textures (normal)
   pied_normal.png (compressed)
   feuilles2nd_normal.png (compressed)
   tiges1st_normal.png (compressed)
   tiges1st_normal_opengl.png (compressed)
   cul_normal.png (compressed)
   he_lisse_normal.png (compressed)
   tiges2nd_normal_opengl.png (compressed)
   pied_normal_opengl.png (compressed)
   cul_normal_opengl.png (compressed)
   feuilles2nd_normal_opengl.png (compressed)
   feuilles1st_normal_opengl.png (compressed)
   tiges2nd_normal.png (compressed)
   moteur_normal_opengl.png (compressed)
   feuilles1st_normal.png (compressed)
   moteur_normal.png (compressed)
   he_lisse_normal_opengl.png (compressed)

🔩 Textures (metalness)
   metalness_feuilles2nd.png (compressed)
   metalness_feuilles1st.png (compressed)
   metalness_cul.png (compressed)
   metalness_he_lisse.png (compressed)
   metalness_tiges1st.png (compressed)
   metalness_moteur.png (compressed)
   metalness_tiges2nd.png (compressed)
   metalness_pied.png (compressed)

⛰ Textures (height)
   height_pied.png (compressed)
   height_feuilles2nd.png (compressed)
   height_tiges1st.png (compressed)
   height_cul.png (compressed)
   height_he_lisse.png (compressed)
   height_tiges2nd.png (compressed)
   height_feuilles1st.png (compressed)
   height_moteur.png (compressed)

🪟 Textures (opacity)
   opacity_he_lisse.png (compressed)

🌑 Textures (ao)
   ao_feuilles2nd.png (compressed)
   ao_cul.png (compressed)
   ao_feuilles1st.png (compressed)
   ao_moteur.png (compressed)
   ao_tiges1st.png (compressed)
   ao_he_lisse.png (compressed)
   ao_pied.png (compressed)
   ao_tiges2nd.png (compressed)
2026-04-28 19:19:25 +02:00
Tom Boullay 38fa9c1ff2 update: upload-gltf add a new model -> packderelance
📦 Model
   model.gltf

🎨 Textures (color)
   color_tetemart.png (compressed)
   color_charnie_res.png (compressed)
   color_mousse.png (compressed)
   color_patinf.png (compressed)
   color_lock.png (compressed)
   color_cabledroit.png (compressed)
   color_cablegauche.png (compressed)
   color_puces.png (compressed)
   color_manchemart.png (compressed)
   color_mousse_bas.png (compressed)
   color_patsup.png (compressed)

🪶 Textures (roughness)
   roughness_mousse.png (compressed)
   roughness_charnie_res.png (compressed)
   roughness_cablegauche.png (compressed)
   roughness_patsup.png (compressed)
   roughness_mousse_bas.png (compressed)
   roughness_manchemart.png (compressed)
   roughness_cabledroit.png (compressed)
   roughness_patinf.png (compressed)
   roughness_lock.png (compressed)
   roughness_puces.png (compressed)
   roughness_tetemart.png (compressed)

🧭 Textures (normal)
   patsup_normal.png (compressed)
   mousse_normal_opengl.png (compressed)
   patinf_normal_opengl.png (compressed)
   cablegauche_normal.png (compressed)
   cablegauche_normal_opengl.png (compressed)
   lock_normal.png (compressed)
   patinf_normal.png (compressed)
   patsup_normal_opengl.png (compressed)
   mousse_bas_normal.png (compressed)
   mousse_normal.png (compressed)
   cabledroit_normal_opengl.png (compressed)
   cabledroit_normal.png (compressed)
   manchemart_normal.png (compressed)
   tetemart_normal_opengl.png (compressed)
   tetemart_normal.png (compressed)
   manchemart_normal_opengl.png (compressed)
   puces_normal.png (compressed)
   charnie_res_normal.png (compressed)
   lock_normal_opengl.png (compressed)
   mousse_bas_normal_opengl.png (compressed)
   puces_normal_opengl.png (compressed)
   charnie_res_normal_opengl.png (compressed)

🔩 Textures (metalness)
   metalness_mousse.png (compressed)
   metalness_puces.png (compressed)
   metalness_tetemart.png (compressed)
   metalness_charnie_res.png (compressed)
   metalness_mousse_bas.png (compressed)
   metalness_cabledroit.png (compressed)
   metalness_manchemart.png (compressed)
   metalness_lock.png (compressed)
   metalness_patinf.png (compressed)
   metalness_patsup.png (compressed)
   metalness_cablegauche.png (compressed)

⛰ Textures (height)
   height_patsup.png (compressed)
   height_cablegauche.png (compressed)
   height_lock.png (compressed)
   height_patinf.png (compressed)
   height_mousse_bas.png (compressed)
   height_mousse.png (compressed)
   height_cabledroit.png (compressed)
   height_manchemart.png (compressed)
   height_tetemart.png (compressed)
   height_puces.png (compressed)
   height_charnie_res.png (compressed)

🌑 Textures (ao)
   ao_puces.png (compressed)
   ao_mousse.png (compressed)
   ao_charnie_res.png (compressed)
   ao_mousse_bas.png (compressed)
   ao_cabledroit.png (compressed)
   ao_tetemart.png (compressed)
   ao_manchemart.png (compressed)
   ao_lock.png (compressed)
   ao_patinf.png (compressed)
   ao_patsup.png (compressed)
   ao_cablegauche.png (compressed)
2026-04-28 19:14:52 +02:00
math-pixel e8f621d35f wip 2026-04-28 16:54:00 +02:00
Tom Boullay aa211d16b7 fix: persist debug modes and skip missing map models 2026-04-28 16:35:33 +02:00
Tom Boullay 44c27be640 Merge branch 'develop' into feat/main-feature 2026-04-28 16:27:05 +02:00
Tom Boullay 8a58f75864 update: upload-gltf add a new model -> gants
📦 Model
   model.gltf

🎨 Textures (color)
   color_galet.png (compressed)
   color_gant.png (compressed)

🧭 Textures (normal)
   normal_galet.png (compressed)
   normal_gant.png (compressed)

🧱 Textures (orm)
   orm_galet.png (compressed)
   orm_gant.png (compressed)

🧩 Assets
   gants.bin
2026-04-28 16:07:26 +02:00
Tom Boullay 0c7d5b61ac update: upload-gltf add a new model -> galet
📦 Model
   model.gltf

🎨 Textures (color)
   color_galet.png (compressed)

🧭 Textures (normal)
   normal_galet.png (compressed)

🧱 Textures (orm)
   orm_galet.png (compressed)

🧩 Assets
   galet.bin
2026-04-28 16:02:46 +02:00
Tom Boullay 5c5ba0937e Merge pull request #6 from La-Fabrik-Durable/feat/docs-routing
Feat/docs-routing
2026-04-28 15:04:29 +02:00
Tom Boullay dc7de253de address docs routing review feedback 2026-04-28 15:02:50 +02:00
Tom Boullay 97e0606439 clean docs router declarations 2026-04-28 14:53:28 +02:00
Tom Boullay a11db585de move debug components out of utils 2026-04-28 14:47:26 +02:00
Tom Boullay 6d858cfa7d standardize source naming conventions 2026-04-28 14:46:27 +02:00
Tom Boullay 19bad2c8be fix runtime map loading lifecycle 2026-04-28 14:42:49 +02:00
Tom Boullay e01d6f27ba rename pages 2026-04-28 14:25:29 +02:00
Tom Boullay 64b53a762d clean branch-scoped code quality issues 2026-04-28 14:23:37 +02:00
Tom Boullay 9818e719ce organize data configs by domain 2026-04-28 14:17:21 +02:00
Tom Boullay 2251a81ac1 refactor feature folders by code type 2026-04-28 14:14:15 +02:00
Tom Boullay eebeee9ed8 refactor docs into feature folder 2026-04-28 13:54:41 +02:00
Tom Boullay 9c7c59973e group docs navigation by audience 2026-04-28 13:48:03 +02:00
Tom Boullay dee232ac45 add editor documentation pages 2026-04-28 13:47:56 +02:00
Tom Boullay 2117146de5 fix react three peer dependencies 2026-04-28 13:47:49 +02:00
Tom Boullay 632d4b5c95 update: app and main 2026-04-28 13:32:54 +02:00
Tom Boullay 291e747f7b Merge branch 'develop' into feat/docs-routing 2026-04-28 13:31:40 +02:00
Tom Boullay 48b53746d1 Merge pull request #4 from La-Fabrik-Durable/feat-editor
Feat/editor
2026-04-28 13:22:44 +02:00
Tom Boullay ddde5e33f3 fix editor map reliability 2026-04-28 11:06:09 +02:00
Tom Boullay b5f2b7c990 Update Map.tsx 2026-04-28 10:53:57 +02:00
Tom Boullay bce1a05615 Create Map.tsx 2026-04-28 10:52:05 +02:00
Tom Boullay 81515e3e31 Update debugConfig.ts 2026-04-28 10:48:42 +02:00
math-pixel ed9582562a Merge pull request #9 from La-Fabrik-Durable/feat/deploy-test
Feat/deploy test
2026-04-28 10:45:25 +02:00
math-pixel 2c295fa7ee Merge pull request #8 from La-Fabrik-Durable/feat/deploy-test
Feat/deploy test
2026-04-28 10:44:12 +02:00
Tom Boullay 4a2f9cfb50 Merge branch 'feat-editor' of https://github.com/La-Fabrik-Durable/La-Fabrik into feat-editor 2026-04-28 10:43:08 +02:00
Tom Boullay d70de8c9a4 cleaaning 2026-04-28 10:42:57 +02:00
math-pixel f83df3dd21 Delete test-editor.html 2026-04-28 10:39:58 +02:00
Tom Boullay 5e528a90f9 fix: style 2026-04-28 10:30:31 +02:00
Tom Boullay f3888ed0bf tyle: refresh editor controls with monochrome UI 2026-04-28 10:08:17 +02:00
Tom Boullay b8f6fe59db refactor: move game map into world folder 2026-04-28 09:47:09 +02:00
Tom Boullay d7425ac17d docs: document editor architecture and user features 2026-04-28 09:43:51 +02:00
Tom Boullay f8b2c180cd refactor: move editor page and types to conventional folders 2026-04-28 09:29:18 +02:00
Tom Boullay c6b317448f add: stylesheet 2026-04-28 09:07:56 +02:00
math-pixel bed5010f41 update: deploy file 2026-04-27 20:46:52 +02:00
math-pixel 1b1f2088aa update: deploy file 2026-04-27 20:40:05 +02:00
math-pixel 18d0c2baa5 feat: change version 2026-04-27 20:27:21 +02:00
math-pixel 4aaff26e82 fix :editor 2026-04-27 17:25:56 +02:00
Tom Boullay 31c3e43698 docs: clarify backend virtual environment setup 2026-04-27 17:11:08 +02:00
math-pixel c5b04764ec fix: main model map 2026-04-27 16:38:05 +02:00
Tom Boullay 1e444620c1 fix: address docs routing review 2026-04-27 16:32:23 +02:00
Tom Boullay eeca87dd0e clean: package json 2026-04-27 16:27:57 +02:00
math-pixel fde724f3f0 update models loading in /editor 2026-04-27 16:27:56 +02:00
Tom Boullay d5a295e18f feat: add localized docs pages 2026-04-27 16:27:08 +02:00
math-pixel 14626cd6b1 fix: load all models/ 2026-04-27 16:07:57 +02:00
Tom Boullay 9c602cdc63 feat move debug cube with remote hand tracking 2026-04-27 16:07:54 +02:00
Tom Boullay fa8bc229c3 feat add remote hand tracking backend 2026-04-27 15:49:02 +02:00
Tom Boullay 7139ae559e feat: add docs routing 2026-04-27 15:35:56 +02:00
Tom Boullay 1c9d113050 update: upload-gltf add a new model -> pylone
📦 Model
   model.gltf

🎨 Textures (color)
   pied_base_color.png (compressed)
   panneaux_base_color.png (compressed)
   cable2_base_color.png (compressed)
   chap_base_color.png (compressed)
   puces_base_color.png (compressed)
   lampe_base_color.png (compressed)
   cable1_base_color.png (compressed)

🪶 Textures (roughness)
   lampe_roughness.png (compressed)
   panneaux_roughness.png (compressed)
   cable2_roughness.png (compressed)
   cable1_roughness.png (compressed)
   chap_roughness.png (compressed)
   puces_roughness.png (compressed)
   pied_roughness.png (compressed)

🧭 Textures (normal)
   panneaux_normal.png (compressed)
   pied_normal.png (compressed)
   chap_normal.png (compressed)
   lampe_normal.png (compressed)
   chap_normal_opengl.png (compressed)
   panneaux_normal_opengl.png (compressed)
   lampe_normal_opengl.png (compressed)
   cable2_normal.png (compressed)
   cable1_normal.png (compressed)
   pied_normal_opengl.png (compressed)
   cable2_normal_opengl.png (compressed)
   puces_normal.png (compressed)
   cable1_normal_opengl.png (compressed)
   puces_normal_opengl.png (compressed)

🔩 Textures (metalness)
   lampe_metallic.png (compressed)
   cable1_metallic.png (compressed)
   puces_metallic.png (compressed)
   panneaux_metallic.png (compressed)
   chap_metallic.png (compressed)
   cable2_metallic.png (compressed)
   pied_metallic.png (compressed)

🧩 Assets
   cable1_mixed_ao.png (compressed)
   puces_mixed_ao.png (compressed)
   panneaux_height.png (compressed)
   chap_height.png (compressed)
   lampe_opacity.png (compressed)
   lampe_mixed_ao.png (compressed)
   pied_height.png (compressed)
   lampe_height.png (compressed)
   cable2_height.png (compressed)
   cable1_height.png (compressed)
   panneaux_mixed_ao.png (compressed)
   cable2_mixed_ao.png (compressed)
   puces_height.png (compressed)
   pied_mixed_ao.png (compressed)
   chap_mixed_ao.png (compressed)
2026-04-27 14:35:33 +02:00
math-pixel 70720fbdcf fix: address code review comments
- vite.config.ts: fix __dirname for ESM, add 1MB payload limit + JSON validation
- MapViewer.tsx: remove broken window.isTransforming checks, fix callback order
- EditorPage.tsx: derive undoCount from historyManager in handleTransformEnd
- package.json: support Node 20 or 22 in engines
2026-04-27 14:21:50 +02:00
math-pixel cd7ba7503c fix: lint 2026-04-27 14:19:26 +02:00
math-pixel 2bd0fe5b96 fix: format 2026-04-27 13:57:17 +02:00
math-pixel 037174a409 Merge branch 'develop' into feat-editor 2026-04-27 13:55:13 +02:00
math-pixel 7de5678db1 update: map & update: package json for CI 2026-04-27 13:44:14 +02:00
Tom Boullay b523e98bea Update model.gltf
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
2026-04-27 13:43:59 +02:00
Tom Boullay 99d3eb2a2a cleaning: repo + model 2026-04-27 13:36:35 +02:00
Tom Boullay 4a97e265c1 Merge pull request #3 from La-Fabrik-Durable/design
upload: models
2026-04-27 13:31:21 +02:00
Tom Boullay 71d7ccbb4b Merge pull request #2 from La-Fabrik-Durable/feat/implem-map-scene-physique
Feat/implem map scene physique
2026-04-27 11:58:38 +02:00
Tom Boullay cd6831166b fix: docs update debug project tree 2026-04-27 11:57:21 +02:00
Tom Boullay 1b32fe9227 Create .prettierignore 2026-04-27 11:23:29 +02:00
Tom Boullay 7a01d86761 fix: format & lint 2026-04-27 11:20:59 +02:00
Tom Boullay 208b43295c clean 2026-04-27 11:14:43 +02:00
Tom Boullay 0e9d711be5 fix: archi 2026-04-27 10:53:50 +02:00
Tom Boullay 3c7fdbc6f5 update: upload-gltf add a new model -> lafabrik
📦 Model
   model.glb (compressed)
🎨 Textures
   tiges_normal.png
   comptoir_normal_opengl.png
   porte_metallic.png
   tiges_height.png
   comptoir_mixed_ao.png
   tiges_metallic.png
   tiges_normal_opengl.png
   comptoir_roughness.png
   fenetre_0_base_color.png
   bat_roughness.png
   tiges_base_color.png
   comptoir_normal.png
   porte_normal.png
   toit_roughness.png
   comptoir_height.png
   porte_height.png
   porte_mixed_ao.png
   comptoir_metallic.png
   tiges_mixed_ao.png
   porte_normal_opengl.png
   dashboard_metallic.png
   anneaux_metallic.png
   anneaux_base_color.png
   panneau_normal_opengl.png
   toit_mixed_ao.png
   anneaux_roughness.png
   porte_stock_base_color.png
   tuyaux_mixed_ao.png
   fenetre_0_mixed_ao.png
   dashboard_base_color.png
   anneaux_mixed_ao.png
   dashboard_mixed_ao.png
   bat_base_color.png
   tuyaux_height.png
   tuyaux_metallic.png
   verre_fenetre_base_color.png
   bat_height.png
   anneaux_normal.png
   toit_metallic.png
   porte_stock_height.png
   bat_normal.png
   porte_stock_roughness.png
   tuyaux_normal.png
   fenetre_0_metallic.png
   porte_stock_normal.png
   tuyaux_roughness.png
   anneaux_height.png
   comptoir_base_color.png
   panneau_height.png
   panneau_base_color.png
   porte_stock_mixed_ao.png
   panneau_normal.png
   bat_mixed_ao.png
   anneaux_normal_opengl.png
   stock_0_mixed_ao.png
   tuyaux_base_color.png
   fenetre_0_normal_opengl.png
   porte_roughness.png
   porte_base_color.png
   toit_normal_opengl.png
   plan_de_travail_normal.png
   plan_de_travail_roughness.png
   plan_de_travail_height.png
   porte_stock_metallic.png
   toit_base_color.png
   stock_0_metallic.png
   stock_0_normal.png
   verre_fenetre_normal.png
   plan_de_travail_normal_opengl.png
   bat_metallic.png
   stock_0_base_color.png
   stock_0_height.png
   tiges_roughness.png
   fenetre_0_roughness.png
   verre_fenetre_height.png
   panneau_roughness.png
   fenetre_0_normal.png
   plan_de_travail_metallic.png
   dashboard_normal_opengl.png
   tuyaux_normal_opengl.png
   plan_de_travail_base_color.png
   fenetre_0_height.png
   porte_stock_normal_opengl.png
   panneau_mixed_ao.png
   dashboard_height.png
   verre_fenetre_normal_opengl.png
   toit_normal.png
   dashboard_normal.png
   bat_normal_opengl.png
   verre_fenetre_metallic.png
   toit_height.png
   plan_de_travail_mixed_ao.png
   stock_0_roughness.png
   panneau_metallic.png
   dashboard_roughness.png
   stock_0_normal_opengl.png
   verre_fenetre_roughness.png
   verre_fenetre_mixed_ao.png
2026-04-24 16:44:54 +02:00
Tom Boullay 0c43c58dfe update: upload-gltf add a new model -> lafabrik
📦 Model
   model.glb (compressed)
🎨 Textures
   tiges_normal.png
   comptoir_normal_opengl.png
   porte_metallic.png
   tiges_height.png
   comptoir_mixed_ao.png
   tiges_metallic.png
   tiges_normal_opengl.png
   comptoir_roughness.png
   fenetre_0_base_color.png
   bat_roughness.png
   tiges_base_color.png
   comptoir_normal.png
   porte_normal.png
   toit_roughness.png
   comptoir_height.png
   porte_height.png
   porte_mixed_ao.png
   comptoir_metallic.png
   tiges_mixed_ao.png
   porte_normal_opengl.png
   dashboard_metallic.png
   anneaux_metallic.png
   anneaux_base_color.png
   panneau_normal_opengl.png
   toit_mixed_ao.png
   anneaux_roughness.png
   porte_stock_base_color.png
   tuyaux_mixed_ao.png
   fenetre_0_mixed_ao.png
   dashboard_base_color.png
   anneaux_mixed_ao.png
   dashboard_mixed_ao.png
   bat_base_color.png
   tuyaux_height.png
   tuyaux_metallic.png
   verre_fenetre_base_color.png
   bat_height.png
   anneaux_normal.png
   toit_metallic.png
   porte_stock_height.png
   bat_normal.png
   porte_stock_roughness.png
   tuyaux_normal.png
   fenetre_0_metallic.png
   porte_stock_normal.png
   tuyaux_roughness.png
   anneaux_height.png
   comptoir_base_color.png
   panneau_height.png
   panneau_base_color.png
   porte_stock_mixed_ao.png
   panneau_normal.png
   bat_mixed_ao.png
   anneaux_normal_opengl.png
   stock_0_mixed_ao.png
   tuyaux_base_color.png
   fenetre_0_normal_opengl.png
   porte_roughness.png
   porte_base_color.png
   toit_normal_opengl.png
   plan_de_travail_normal.png
   plan_de_travail_roughness.png
   plan_de_travail_height.png
   porte_stock_metallic.png
   toit_base_color.png
   stock_0_metallic.png
   stock_0_normal.png
   verre_fenetre_normal.png
   plan_de_travail_normal_opengl.png
   bat_metallic.png
   stock_0_base_color.png
   stock_0_height.png
   tiges_roughness.png
   fenetre_0_roughness.png
   verre_fenetre_height.png
   panneau_roughness.png
   fenetre_0_normal.png
   plan_de_travail_metallic.png
   dashboard_normal_opengl.png
   tuyaux_normal_opengl.png
   plan_de_travail_base_color.png
   fenetre_0_height.png
   porte_stock_normal_opengl.png
   panneau_mixed_ao.png
   dashboard_height.png
   verre_fenetre_normal_opengl.png
   toit_normal.png
   dashboard_normal.png
   bat_normal_opengl.png
   verre_fenetre_metallic.png
   toit_height.png
   plan_de_travail_mixed_ao.png
   stock_0_roughness.png
   panneau_metallic.png
   dashboard_roughness.png
   stock_0_normal_opengl.png
   verre_fenetre_roughness.png
   verre_fenetre_mixed_ao.png
2026-04-24 16:44:44 +02:00
Tom Boullay 089763713a add: a logger utils 2026-04-24 14:02:16 +02:00
math-pixel 1ca13ca13e feat : save map.json on project 2026-04-23 15:40:10 +02:00
math-pixel ca9abf3f3b feat editor 2026-04-23 15:24:40 +02:00
Tom Boullay a8138a5180 Create package-lock.json 2026-04-19 16:51:10 +02:00
Tom Boullay 0f96b5597b refacto : cleaning the codebasebase again 2026-04-19 16:50:11 +02:00
Tom Boullay 1c48441535 refacto: cleanning the codebase 2026-04-17 16:03:29 +02:00
Tom Boullay 7e72f1e803 update : put every constante in the data folder 2026-04-17 15:42:10 +02:00
Tom Boullay 4b14295749 update : add map model + octree algo 2026-04-17 11:36:03 +02:00
Tom Boullay 5111f2e558 update: add a physic scenne 2026-04-17 10:48:18 +02:00
Tom Boullay 1d4f223c35 refacto: enleve la map 2026-04-16 16:11:20 +02:00
Tom Boullay a3feb96bc3 update: upload-gltf add a new model -> workshop/lafabrik
📦 Model
   model.gltf
🎨 Textures
   roughness (manquant)
   normal (manquant)
   metalness (manquant)
   color (manquant)
   displace (manquant)
2026-04-16 15:43:42 +02:00
Tom Boullay 3d490b7fdd update: upload-gltf add a new model -> general/blocking
📦 Model
   model.gltf
🎨 Textures
   roughness (manquant)
   normal (manquant)
   metalness (manquant)
   color (manquant)
   displace (manquant)
2026-04-16 11:04:48 +02:00
Tom Boullay 269cf81595 fix: archi player 2026-04-16 11:00:08 +02:00
Tom Boullay 71c22386be refactor: tighten project structure and strengthen tooling 2026-04-16 10:45:05 +02:00
Tom Boullay fd8b462e1c fix: lint 2026-04-15 16:42:06 +02:00
Tom Boullay 02283946fa feat: add player camera 2026-04-15 16:40:52 +02:00
Tom Boullay 9e9ac8066c feat: add the map 2026-04-15 16:09:02 +02:00
Tom Boullay aded4ee209 feat: add map blocking and cleanup 2026-04-15 13:36:53 +02:00
Tom Boullay e86d4f2077 Merge branch 'main' into design 2026-04-15 13:32:38 +02:00
Tom Boullay c1ca8ca8e0 cleaning 2026-04-15 13:32:21 +02:00
Tom Boullay 753bdafd2e fix ci 2026-04-15 13:30:04 +02:00
Tom Boullay 18fdd8b2a6 update: add more CI 2026-04-15 11:17:48 +02:00
Tom Boullay 2c771d548b update : docs and skills 2026-04-15 11:06:41 +02:00
Tom Boullay 9eff211958 update: upload-gltf add a new model -> map/blocking
📦 Model
   model.gltf
🎨 Textures
   roughness (manquant)
   normal (manquant)
   metalness (manquant)
   color (manquant)
   displace (manquant)
2026-04-15 09:48:07 +02:00
Tom Boullay 1ce0267d12 update: remove eveything 2026-04-15 09:41:46 +02:00
Tom Boullay f0471a3afe update: upload-gltf update -> general/vase
📦 Model
  ↔️ model.gltf (inchange)
🎨 Textures
   normal.jpg
2026-04-15 09:25:02 +02:00
Tom Boullay 9815a38656 update: upload-gltf update -> general/vase
📦 Model
  ↔️ model.gltf (inchange)
🎨 Textures
  🔄 roughness.jpg
  🔄 metalness.jpg
  🔄 color.jpg
  🔄 displace.jpg
   normal.jpg (supprime)
2026-04-15 09:18:02 +02:00
Tom Boullay 9d63bd6444 Merge branch 'design' of https://github.com/La-Fabrik-Durable/La-Fabrik into design 2026-04-15 09:02:24 +02:00
Tom Boullay 89af15683c update: upload-gltf update -> general/vase
📦 Model
  🔄 model.gltf (compressed)
🎨 Textures
   color.jpg
2026-04-14 16:41:58 +02:00
Tom Boullay 980b16895e update: upload-gltf update -> general/vase
📦 Model
  🔄 model.gltf (compressed)
🎨 Textures
   color.jpg (supprime)
2026-04-14 16:40:55 +02:00
Tom Boullay 0b4d59223f update: upload-gltf add a new model -> general/vase
📦 Model
   model.gltf (compressed)
🎨 Textures
   roughness.jpg
   normal.jpg
   metalness.jpg
   color.jpg
   displace.jpg
2026-04-14 16:23:19 +02:00
Tom Boullay 7d5abfc2af update: upload-gltf update -> general/coffeetest
🎨 Textures
   metalness.jpg
2026-04-14 13:56:37 +02:00
Tom Boullay 4a833c4484 update: upload-gltf replace model -> general/coffeetest
📦 Model
  🔄 model.gltf (compressed)
🎨 Textures
  🔄 roughness.jpg
  🔄 normal.jpg
   metalness (manquant)
  🔄 color.jpg
  🔄 displace.jpg
2026-04-14 13:43:44 +02:00
Tom Boullay c36d3631f2 update: upload-gltf add a new model -> general/coffeetest
📦 Model
   model.gltf (compressed)
🎨 Textures
   roughness.jpg
   normal.jpg
   metalness.jpg
   color.jpg
   displace.jpg
2026-04-14 13:30:53 +02:00
Tom Boullay 5d7ce27285 update: from upload-gltf add a new model -> general/coffeetest
📦 Model
   model.gltf

🎨 Textures
   roughness.jpg
   normal.jpg
   metalness.jpg
   color.jpg
   displace.jpg
2026-04-14 12:24:24 +02:00
Tom Boullay 32d4e07ceb add some folder 2026-04-14 12:22:20 +02:00
Tom Boullay af3262376d update: add agent.md + skills 2026-04-14 09:20:30 +02:00
Tom Boullay 2fcb0ca56f Create ci.yml 2026-04-14 09:02:12 +02:00
Tom Boullay e32be4be53 upatde: add prettier 2026-04-14 08:59:36 +02:00
Tom Boullay 71916b2329 update: add basic structure 2026-04-14 08:39:09 +02:00
Tom Boullay 429ae66703 add : finish readme, git lfs and gitignore 2026-04-13 23:30:33 +02:00
Tom Boullay af2cfe128c Update README.md 2026-04-13 23:17:59 +02:00
Tom Boullay a37e46ac49 Update README.md 2026-04-13 22:59:56 +02:00
Tom Boullay 843d61fd54 add: license + v1 archi 2026-04-13 22:24:20 +02:00
Tom Boullay e622646349 add readme 2026-04-13 16:37:37 +02:00
Tom Boullay 9dc89b438b Initial commit 2026-04-13 16:12:21 +02:00
100 changed files with 9596 additions and 1445 deletions
+1
View File
@@ -8,6 +8,7 @@ __pycache__/
# Build # Build
dist/ dist/
dist-ssr/ dist-ssr/
.vite/
*.local *.local
# Environment # Environment
+142 -103
View File
@@ -1,124 +1,163 @@
# La-Fabrik # La-Fabrik
An interactive 3D web experience for La Fabrik Durable — a low-tech repair and transformation service in Altera, a post-capitalist city rebuilt in 2039. Players step into the role of a newly onboarded technician and experience a day at the service: repairing an e-bike, fixing a power grid, and upgrading a vertical farm's irrigation system. La-Fabrik is an interactive 3D web experience built with React, Vite, Three.js, React Three Fiber, Rapier, GSAP, MediaPipe, and Zustand.
Built with React, Three.js, and Vite. Runs in the browser, no installation required. The current prototype puts the player in a repair-oriented world where they progress through a short mission chain: intro, e-bike repair, power pylon repair, vertical farm repair, then outro. The project also includes a local editor for map, dialogue, subtitle, and cinematic data.
## 📦 Tech Stack ## Current Scope
### Build & Language - Playable fullscreen 3D scene at `/`
- Production map loaded from `public/map.json`
- Progressive map/model/collision/stage loading overlay
- Player controller with pointer lock, `ZQSD` movement, jump, octree collision, trigger input, and grab input
- Reusable repair-game flow for `bike`, `pylone`, and `ferme`
- Repair case animation, exploded model scan, broken-part markers, grabbable replacements, snap-to-placeholder placement, install validation, reassembly, and completion
- Shared interaction system for trigger and grab objects
- Rapier physics for gameplay objects while the player keeps a Three.js octree collision controller
- Hand tracking through either a local Python WebSocket backend or browser-side MediaPipe
- Category-based audio manager for music, SFX, and dialogue
- Dialogue manifest, SRT subtitles, subtitle overlay, and dialogue queueing
- Cinematic manifest with GSAP camera keyframes and optional dialogue cues
- In-game settings menu for volumes, subtitles, subtitle language, and the currently staged repair-runtime toggle
- Debug mode with `?debug`, lil-gui controls, game-state panel, hand-tracking panel, debug camera, physics playground, and R3F perf
- `/editor` route for map transforms, SRT editing, dialogue manifest editing, cinematic manifest editing, preview, validation, export, and dev-server saves
- `/docs` route that renders the repository documentation inside the app
| Package | ## Routes
| -------------------------------------------------- |
| [TypeScript](https://www.typescriptlang.org/docs/) |
| [React](https://react.dev/learn) |
| [Vite](https://vite.dev/guide/) |
| [ESLint](https://eslint.org/docs/latest/) |
| [Prettier](https://prettier.io/docs/) |
### 3D Engine | Route | Purpose |
| --------- | --------------------------------------------------- |
| `/` | Playable 3D experience |
| `/?debug` | Playable scene with debug GUI and overlays |
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
| `/docs` | In-app documentation index |
| Package | ## Tech Stack
| ----------------------------------------------------------------------------------------- |
| [Three.js](https://threejs.org/docs/) |
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
| [@react-three/drei](https://pmndrs.github.io/drei) |
| [@react-three/rapier](https://rapier.rs/docs/) |
| [GSAP](https://gsap.com/docs/v3/Installation/) |
### Performance & Effects | Area | Packages |
| --------------------- | ------------------------------------------------------------------------------ |
| App | React 19, TypeScript, Vite, TanStack Router |
| 3D | Three.js, React Three Fiber, drei |
| Physics and animation | `@react-three/rapier`, GSAP, Three.js `AnimationMixer` |
| State | Zustand, custom singleton managers where imperative runtime objects are needed |
| Hand tracking | `@mediapipe/tasks-vision`, optional FastAPI backend |
| Docs | `react-markdown`, `remark-gfm` |
| Quality | ESLint, Prettier, TypeScript project build |
| Package | ## Project Structure
| --------------------------------------------------------------------------- |
| [r3f-perf](https://github.com/utsuboco/r3f-perf) |
| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) |
## 🗂 Project Structure ```txt
.
``` |-- backend/ # Optional Python hand-tracking backend
la-fabrik/ |-- docs/
├── public/ | +-- technical/ # Architecture and implementation notes
│ ├── models/ | +-- user/ # Feature and user-facing guides
│ │ ├── map/ # Base map — loaded once at start |-- public/
│ │ ├── workshop/ | +-- assets/ # UI videos, PDFs, logos, world videos
│ │ ├── powerGrid/ | +-- cinematics.json # Runtime cinematic manifest
│ │ └── farm/ | +-- map.json # Runtime/editor map data
│ ├── textures/ | +-- models/ # GLTF/GLB assets resolved by model folder name
└── sounds/ | +-- sounds/ # Music, SFX, dialogue audio, SRT subtitles
|-- src/
└── src/ | +-- components/
├── world/ # Persistent 3D world composition | | +-- docs/ # In-app docs layout and renderer
│ ├── World.tsx # Active scene composition | | +-- editor/ # Editor panels and editor scene
│ ├── GameMap.tsx # Map loading and progressive rendering | | +-- three/ # R3F components by domain
│ ├── GameMapCollision.tsx # Collision-only octree source | | +-- ui/ # HTML game/debug overlays
│ ├── Lighting.tsx # Ambient, directional, point lights | +-- controls/ # Editor fly/player-style controls
│ ├── Environment.tsx # Scene background / sky model | +-- data/ # Static tuning/config per domain
│ ├── GameMusic.tsx # Game scene music lifecycle | +-- hooks/ # React hooks by domain
│ ├── debug/ # Debug-only test scene | +-- lib/ # Browser hand-tracking helpers
│ │ └── TestMap.tsx | +-- managers/ # Audio, interaction, and Zustand stores
│ └── player/ | +-- pages/ # Route-level pages
│ ├── Player.tsx # Player rig composition | +-- providers/ # Docs and hand-tracking providers
│ ├── PlayerCamera.tsx # Player camera mount | +-- routes/ # Lazy route wrappers
│ └── PlayerController.tsx # Pointer lock movement and inputs | +-- types/ # Shared domain types
| +-- utils/ # Core, map, editor, dialogue, subtitle, Three helpers
├── components/ | +-- world/ # Production/debug world composition and player
│ ├── three/ # Shared R3F components by domain `-- vite.config.ts # Vite config plus local editor save endpoints
│ │ ├── gameplay/ # Core repair gameplay prototype
│ │ ├── handTracking/ # R3F hand tracking debug models
│ │ ├── interaction/ # Trigger, grab, focus wrappers
│ │ ├── models/ # GLTF model components
│ │ └── world/ # Environment-specific 3D objects
│ └── ui/ # HTML overlays — outside Canvas
│ ├── Crosshair.tsx
│ ├── debug/ # Debug-only HTML overlay panels
│ │ ├── DebugOverlayLayout.tsx
│ │ ├── GameStateDebugPanel.tsx
│ │ └── HandTrackingDebugPanel.tsx
│ ├── HandTrackingVisualizer.tsx
│ └── InteractPrompt.tsx
├── managers/ # Current singleton-style services
│ ├── AudioManager.ts # Music and SFX playback
│ └── InteractionManager.ts # Focus, nearby, grab state
├── hooks/ # React hooks by domain
│ ├── debug/ # Debug state and GUI folders
│ ├── docs/ # Docs language context access
│ ├── editor/ # Editor loading and history
│ ├── gameplay/ # Repair gameplay helpers
│ ├── handTracking/ # Webcam/WebSocket hand tracking
│ ├── interaction/ # Interaction manager subscriptions
│ └── three/ # Three.js/R3F helpers
├── data/
│ ├── interaction/ # Interaction tuning
│ ├── player/ # Player tuning
│ ├── gameplay/ # Repair gameplay static config
│ └── world/ # Environment and lighting config
├── utils/
│ ├── core/ # Logger and generic utilities
│ ├── debug/ # Dev-only tools and scene inspection
│ ├── editor/ # Editor-only parsing utilities
│ ├── map/ # Map loading and validation
│ └── three/ # Three.js helpers
├── types/ # Shared TypeScript domain types
├── App.tsx # App bootstrap and route switch
└── main.tsx
``` ```
## 🚀 Getting Started ## Getting Started
Install and run the frontend:
```bash ```bash
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
cd La-Fabrik
npm install npm install
npm run dev npm run dev
``` ```
- app: `http://localhost:5173` Open:
- debug mode: `http://localhost:5173?debug`
## 📜 License ```txt
http://localhost:5173
```
See [LICENSE](./LICENSE) file. Useful local URLs:
```txt
http://localhost:5173/?debug
http://localhost:5173/editor
http://localhost:5173/docs
```
Run checks:
```bash
npm run typecheck
npm run lint
npm run format:check
npm run build
```
## Optional Hand-Tracking Backend
The app can use browser-side MediaPipe, but the default debug source is the local backend.
```bash
python3.11 -m venv backend/.venv
source backend/.venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r backend/requirements.txt
python backend/download_model.py
python -m backend.main
```
Backend endpoints:
```txt
GET http://localhost:8000/health
WS ws://localhost:8000/ws
```
## Documentation Index
| File | Purpose |
| --------------------------------------- | ---------------------------------------------------------- |
| `docs/technical/architecture.md` | Current runtime architecture |
| `docs/technical/scene-runtime.md` | Scene loading, world composition, and player spawn gates |
| `docs/technical/repair-game.md` | Repair-game implementation and state flow |
| `docs/technical/interaction.md` | Trigger, grab, focus, and hand-grab system |
| `docs/technical/target-architecture.md` | Intended medium-term architecture direction |
| `docs/technical/audio.md` | Music, SFX, dialogue, subtitles, and editor validation |
| `docs/technical/hand-tracking.md` | Webcam, backend/browser MediaPipe, glove, and gesture flow |
| `docs/technical/zustand.md` | Game, settings, and subtitle stores |
| `docs/technical/three-debugging.md` | DevTools workflow for stepping into Three.js internals |
| `docs/technical/editor.md` | Editor implementation details |
| `docs/technical/animation.md` | Animated, explodable, and reusable 3D model components |
| `docs/user/features.md` | Implemented feature inventory |
| `docs/user/main-feature.md` | User-facing repair-game walkthrough |
| `docs/user/editor.md` | Editor user guide |
| `docs/code-review-preparation.md` | French code-review preparation support |
## Current Caveats
- This is still a prototype, not a complete game runtime.
- The repair-runtime toggle is stored in settings and displayed in the UI, but the repair game currently still runs locally in React/Three.
- `useRepairMovementLocked()` currently returns `false`, so the movement-lock rule and indicator are present but disabled on `develop`.
- Production editor persistence does not exist. Save endpoints in `vite.config.ts` are local Vite dev-server helpers.
- The player uses octree collision while gameplay objects use Rapier. Keep that boundary deliberate unless the whole player controller is migrated.
## License
See `LICENSE`.
+930
View File
@@ -0,0 +1,930 @@
# Préparation Code Review - La-Fabrik
Ce document est une fiche de révision pour préparer la code review.
Il est basé sur `develop`, après le merge récent autour de l'environnement, du repair game, de l'audio, du store Zustand, du hand tracking, de l'éditeur et de la doc intégrée.
Le but n'est pas de réciter le repo. Le but est de savoir :
- expliquer l'architecture générale ;
- naviguer vite entre les bons fichiers ;
- défendre les choix techniques ;
- montrer une démo propre avec Chrome DevTools ;
- reconnaître honnêtement les limites actuelles.
## Ce qu'il faut retenir en premier
La-Fabrik est une expérience web 3D en React, Vite, Three.js et React Three Fiber.
Le joueur est dans un monde 3D et avance dans une progression de réparation :
```txt
intro -> bike -> pylone -> ferme -> outro
```
Les trois piliers à connaître pour la review :
1. `RepairGame` : boucle de gameplay principale.
2. `AudioManager` : musique, SFX, dialogues et sous-titres.
3. `useGameStore` / Zustand : progression globale du jeu.
Le reste du repo sert à intégrer ces piliers :
- `World` monte la scène 3D ;
- `InteractionManager` relie les objets interactifs aux inputs ;
- `HandTrackingProvider` active la webcam seulement quand elle est utile ;
- `/editor` permet de modifier map, dialogues, SRT et cinématiques ;
- `/docs` rend la documentation Markdown dans l'app.
## Pitch en 30 secondes
Phrase simple à savoir dire :
> La-Fabrik est une expérience 3D interactive en React Three Fiber. Le joueur progresse dans des missions de réparation. Le coeur du gameplay est un `RepairGame` réutilisable pour le vélo, le pylône et la ferme. La progression est centralisée dans Zustand, les interactions passent par un manager commun, les objets manipulables utilisent Rapier, et le joueur garde une collision octree séparée. L'audio est géré par un `AudioManager` avec volumes par catégorie, dialogues et sous-titres.
## Carte mentale du projet
```txt
src/main.tsx
-> App.tsx
-> router.tsx
-> / HomePage + Canvas + World + GameUI
-> /editor EditorPage
-> /docs DocsLayout + Markdown pages
```
Dans la scène jouable :
```txt
HomePage
-> HandTrackingProvider
-> Canvas
-> World
-> GameMap
-> GameStageContent
-> RepairGame bike/pylone/ferme
-> GameMusic
-> GameDialogues
-> Player
-> GameUI
```
## Features à connaître
### Runtime 3D
Fichiers :
- `src/pages/page.tsx`
- `src/world/World.tsx`
- `src/hooks/world/useWorldSceneLoading.ts`
- `src/world/GameMap.tsx`
- `src/world/GameMapCollision.tsx`
Ce que ça fait :
- charge `public/map.json` ;
- résout les modèles dans `public/models/{name}/model.glb` ou `model.gltf` ;
- affiche des cubes fallback si un modèle manque ;
- construit l'octree joueur depuis les nodes de collision ;
- attend que map, collision, stage et octree soient prêts avant de spawn le joueur.
Phrase à retenir :
> `World` ne lance pas tout d'un coup. Il attend que la map, l'octree et le stage gameplay soient prêts avant de monter le joueur, la musique et les dialogues.
Piège à connaître :
Les anciens flags comme `noMusic`, `noMap`, `noDialogues`, `noPlayer` ne sont plus branchés dans `World`. Pour la démo, utiliser surtout `?debug`.
### Player et collision
Fichiers :
- `src/world/player/PlayerController.tsx`
- `src/data/input/keybindings.ts`
- `src/data/player/playerConfig.ts`
- `src/world/GameMapCollision.tsx`
Ce que ça fait :
- déplacement `ZQSD` ;
- souris en pointer lock ;
- saut avec `Space` ;
- interaction avec `E` ;
- grab avec clic gauche ;
- collision joueur via capsule Three.js + octree.
Phrase à retenir :
> Le joueur n'est pas piloté par Rapier. Rapier sert aux objets manipulables, alors que le joueur utilise une capsule et un octree.
Piège à connaître :
`useRepairMovementLocked()` retourne actuellement `false`. Le lock de mouvement est prévu dans le code et l'UI, mais il est désactivé sur `develop`.
### Interaction
Fichiers :
- `src/managers/InteractionManager.ts`
- `src/hooks/interaction/useInteraction.ts`
- `src/components/three/interaction/InteractableObject.tsx`
- `src/components/three/interaction/TriggerObject.tsx`
- `src/components/three/interaction/GrabbableObject.tsx`
- `src/components/ui/InteractPrompt.tsx`
Ce que ça fait :
- détecte si un objet est proche ;
- raycast depuis la caméra pour savoir si le joueur vise l'objet ;
- affiche le prompt `E` pour les triggers ;
- gère les grabs souris et hand tracking ;
- expose un snapshot React via `useSyncExternalStore`.
Phrase à retenir :
> Les interactions sont séparées du gameplay. Un objet dit juste "je suis trigger" ou "je suis grabbable", puis le player controller déclenche l'action selon le focus courant.
### Repair game
Fichiers coeur :
- `src/components/three/gameplay/RepairGame.tsx`
- `src/components/three/gameplay/RepairRepairingStep.tsx`
- `src/components/three/gameplay/RepairScanSequence.tsx`
- `src/components/three/gameplay/RepairMissionCase.tsx`
- `src/components/three/gameplay/RepairCaseModel.tsx`
- `src/components/three/gameplay/RepairCompletionStep.tsx`
Fichiers data/state :
- `src/data/gameplay/repairMissions.ts`
- `src/data/gameplay/repairCaseConfig.ts`
- `src/data/gameplay/repairGameConfig.ts`
- `src/types/gameplay/repairMission.ts`
- `src/managers/stores/useGameStore.ts`
Flow :
```txt
locked
-> waiting
-> inspected
-> fragmented
-> scanning
-> repairing
-> reassembling
-> done
```
Ce que ça fait :
- inspecter l'objet de mission ;
- afficher une mallette ;
- ouvrir/interagir avec la mallette ;
- fragmenter le modèle ;
- scanner les pièces cassées ;
- proposer plusieurs pièces de remplacement ;
- attraper et snapper les pièces ;
- vérifier bonne pièce + pièces cassées rangées ;
- réassembler le modèle ;
- valider la mission et passer à la suivante.
Phrase à retenir :
> `RepairGame` est un orchestrateur de steps. Les variations entre vélo, pylône et ferme sont dans `repairMissions.ts`, pas hardcodées dans trois composants séparés.
### Audio, dialogues, sous-titres
Fichiers :
- `src/managers/AudioManager.ts`
- `src/world/GameMusic.tsx`
- `src/utils/dialogues/playDialogue.ts`
- `src/utils/dialogues/loadDialogueManifest.ts`
- `src/managers/stores/useSettingsStore.ts`
- `src/managers/stores/useSubtitleStore.ts`
- `src/components/ui/Subtitles.tsx`
Ce que ça fait :
- musique en loop ;
- sons one-shot avec pooling ;
- volumes par catégorie : `music`, `sfx`, `dialogue` ;
- dialogues depuis `public/sounds/dialogue/dialogues.json` ;
- SRT par voix et par langue ;
- sous-titres synchronisés avec `audio.currentTime` ;
- queue de dialogues pour éviter les overlaps.
Phrase à retenir :
> L'audio est impératif, donc il est dans un manager. React garde les réglages et les sous-titres, mais les vrais `HTMLAudioElement` sont gérés par `AudioManager`.
### Zustand
Fichiers :
- `src/managers/stores/useGameStore.ts`
- `src/managers/stores/useSettingsStore.ts`
- `src/managers/stores/useSubtitleStore.ts`
- `src/components/ui/debug/GameStateDebugPanel.tsx`
Ce que ça fait :
- `useGameStore` : progression globale ;
- `useSettingsStore` : menu, volumes, sous-titres, runtime repair ;
- `useSubtitleStore` : sous-titre actif ;
- debug panel : manipule le même store que le vrai gameplay.
Phrase à retenir :
> Zustand contient l'état durable. Les valeurs qui changent à chaque frame restent dans des refs, dans R3F ou dans les managers.
### Hand tracking
Fichiers :
- `src/providers/gameplay/HandTrackingProvider.tsx`
- `src/hooks/handTracking/useRemoteHandTracking.ts`
- `src/hooks/handTracking/useBrowserHandTracking.ts`
- `src/hooks/handTracking/useBothFistsHold.ts`
- `src/components/three/handTracking/HandTrackingGlove.tsx`
- `backend/main.py`
Ce que ça fait :
- source `backend` avec Python/FastAPI/MediaPipe ;
- source `browser` avec `@mediapipe/tasks-vision` ;
- activation seulement quand utile ;
- gesture deux poings fermés ;
- grab à la main pour certains objets ;
- visualisation gants et panel debug.
Phrase à retenir :
> Le hand tracking n'est pas actif tout le temps. Le provider l'active quand le contexte le justifie, par exemple pendant certaines étapes du repair game.
### Editor
Fichiers :
- `src/pages/editor/page.tsx`
- `src/components/editor/EditorControls.tsx`
- `src/components/editor/scene/EditorScene.tsx`
- `src/components/editor/scene/EditorMap.tsx`
- `vite.config.ts`
Ce que ça fait :
- édite `public/map.json` ;
- affiche et transforme les objets ;
- édite dialogues, SRT et cinématiques ;
- preview audio/cinématique ;
- sauvegarde locale via endpoints Vite.
Phrase à retenir :
> L'éditeur partage les mêmes formats que le runtime. Il n'y a pas un format map pour l'éditeur et un autre pour le jeu.
Piège à connaître :
Les endpoints `/api/save-*` sont des helpers Vite en dev local, pas une API de production.
## Bloc principal 1 : RepairGame
### Ce qu'il faut comprendre
`RepairGame` ne fait pas "juste afficher une réparation". Il coordonne :
- le state global Zustand ;
- les étapes de mission ;
- les assets GLTF ;
- les prompts vidéo ;
- la mallette ;
- les interactions `E` ;
- les objets Rapier grabbables ;
- le scan des pièces ;
- la validation gameplay.
### Navigation simple
Ouvrir dans cet ordre :
1. `src/world/GameStageContent.tsx`
2. `src/components/three/gameplay/RepairGame.tsx`
3. `src/data/gameplay/repairMissions.ts`
4. `src/components/three/gameplay/RepairRepairingStep.tsx`
5. `src/managers/stores/useGameStore.ts`
### Comment expliquer l'architecture
`GameStageContent` place les missions dans le monde.
`RepairGame` reçoit une mission :
```tsx
<RepairGame mission="bike" position={[8, 0, -6]} />
```
Puis il vérifie :
- est-ce que `mainState` correspond à cette mission ?
- quel est le `step` courant ?
- quelle config utiliser ?
- quel sous-composant monter ?
Les variations mission sont dans `repairMissions.ts` :
- modèle ;
- prompt vidéo ;
- pièces cassées ;
- bonnes pièces ;
- leurres ;
- timings ;
- placeholders.
### Pourquoi c'est bien
- Un seul flow réutilisable.
- Moins de duplication entre `bike`, `pylone`, `ferme`.
- Les règles générales restent dans les composants.
- Les variations restent dans la data.
- Le debug panel peut tester les mêmes steps que le vrai jeu.
### Compromis
Si une future mission devient très différente, la config ne suffira peut-être plus. Il faudra alors créer des composants spécifiques ou un vrai `MissionManager`.
### Points à dire si on ouvre `RepairRepairingStep`
Ce fichier gère l'étape la plus gameplay :
- pièces de remplacement ;
- pièces cassées à déposer ;
- snap vers placeholders ;
- feedback vert/rouge/bleu ;
- validation finale.
État local important :
- `placedPartIds`
- `depositedBrokenPartIds`
- `showBlockedInstallFeedback`
Phrase simple :
> Cette étape garde seulement l'état local de manipulation. La progression globale reste dans Zustand.
## Bloc principal 2 : Audio
### Ce qu'il faut comprendre
`AudioManager` est un singleton parce qu'il manipule des objets impératifs du navigateur :
- `HTMLAudioElement`
- `AudioContext`
- pools audio
- panner stereo
- musique active
### Navigation simple
Ouvrir dans cet ordre :
1. `src/managers/AudioManager.ts`
2. `src/world/GameMusic.tsx`
3. `src/managers/stores/useSettingsStore.ts`
4. `src/utils/dialogues/playDialogue.ts`
5. `src/components/ui/Subtitles.tsx`
### Architecture audio
Catégories :
```txt
music
sfx
dialogue
```
Volumes :
```txt
volume effectif = volume de base * volume catégorie
```
Exemple :
- `GameMusic` lance `/sounds/musique/test.mp3` avec base `0.33`.
- Si le volume musique settings vaut `0.5`, le volume effectif vaut `0.165`.
### Dialogues et sous-titres
Un dialogue référence :
- un `id` ;
- une voix ;
- un fichier audio ;
- un index de cue SRT.
Le SRT est par voix et par langue, pas un fichier SRT par dialogue.
Phrase simple :
> Les dialogues utilisent la catégorie audio `dialogue`, et `playDialogueById` synchronise le sous-titre actif avec le temps courant de l'audio.
### Risques à connaître
- Autoplay navigateur : la musique peut attendre un input utilisateur.
- Un seul track musique actif à la fois.
- Les dialogues sont queue-based, pas un vrai système de priorité.
- L'éditeur audio/SRT sauve via Vite local, pas en prod.
## Bloc principal 3 : Zustand
### Ce qu'il faut comprendre
Zustand contient l'état durable.
Il ne contient pas :
- la velocity joueur ;
- les raycasts ;
- les positions frame par frame ;
- les vecteurs temporaires ;
- l'état interne d'un grab.
### Navigation simple
Ouvrir dans cet ordre :
1. `src/managers/stores/useGameStore.ts`
2. `src/components/ui/debug/GameStateDebugPanel.tsx`
3. `src/components/three/gameplay/RepairGame.tsx`
4. `src/managers/stores/useSettingsStore.ts`
5. `src/managers/stores/useSubtitleStore.ts`
### Game store
Main states :
```txt
intro
bike
pylone
ferme
outro
```
Mission steps :
```txt
locked
waiting
inspected
fragmented
scanning
repairing
reassembling
done
```
Actions importantes :
- `setMissionStep`
- `completeMission`
- `advanceGameState`
- `rewindGameState`
- `resetGame`
Phrase simple :
> Le debug panel et le vrai gameplay utilisent la même source de vérité. Ce n'est pas un debug state séparé.
### Settings store
Gère :
- menu ouvert/fermé ;
- volumes ;
- sous-titres ;
- langue ;
- `repairRuntime`.
Piège :
`repairRuntime` est stocké et affiché, mais pas encore utilisé par `RepairGame`.
### Subtitle store
Gère seulement :
- le sous-titre actif ;
- clear/set.
Phrase simple :
> Le store de sous-titres est volontairement petit parce que le timing reste piloté par l'audio.
## Les pièges à ne pas rater
Si on te pose une question précise, réponds vrai.
| Sujet | Réponse honnête |
| ------------------------- | ------------------------------------------------------------------------ |
| Lock mouvement réparation | Le hook existe mais retourne `false`, donc pas actif actuellement. |
| `repairRuntime` JS/Python | Le choix est stocké dans settings, mais pas consommé par le repair game. |
| Old debug flags | `noMusic`, `noMap`, `noDialogues`, etc. ne sont plus branchés. |
| Player physics | Le joueur n'est pas Rapier, il utilise capsule + octree. |
| Collision map | L'octree vient de nodes dédiés, actuellement `terrain`. |
| Editor save | Ce sont des endpoints Vite dev, pas une API de prod. |
| Cinematics | `GameCinematics` est monté seulement pendant `outro` dans `World`. |
| Hand tracking depth | Le `z` MediaPipe est relatif, pas une vraie profondeur monde stable. |
## Si l'évaluateur ouvre un fichier au hasard
| Fichier | Ce qu'il faut dire |
| -------------------------- | ----------------------------------------------------------------------------- |
| `World.tsx` | Compositeur de scène. Gère game/debug, loading gates, player spawn. |
| `useWorldSceneLoading.ts` | Évite de spawn le joueur avant map + collision + stage. |
| `GameMap.tsx` | Charge map JSON, modèles, fallback cubes, signale quand les nodes sont prêts. |
| `GameMapCollision.tsx` | Construit l'octree joueur depuis des nodes de collision dédiés. |
| `PlayerController.tsx` | Inputs, pointer lock, mouvement, jump, interaction, collision capsule. |
| `InteractionManager.ts` | État courant des interactions, pas dans Zustand car frame-adjacent. |
| `RepairGame.tsx` | Orchestrateur de steps repair, data-driven par mission. |
| `RepairRepairingStep.tsx` | Validation gameplay : pièces, snap, dépôt, install target. |
| `repairMissions.ts` | Config des missions, évite de hardcoder chaque mission dans le composant. |
| `AudioManager.ts` | Manager impératif pour musique, SFX, dialogue, pooling, volumes. |
| `playDialogue.ts` | Queue dialogue + synchronisation sous-titre. |
| `useGameStore.ts` | Source de vérité progression. |
| `GameStateDebugPanel.tsx` | Outil debug qui manipule le même store que le jeu. |
| `HandTrackingProvider.tsx` | Active la webcam seulement quand utile. |
| `GrabbableObject.tsx` | Grab souris/main, Rapier body, snap target. |
| `EditorControls.tsx` | Panneau éditeur, pas runtime joueur. |
## Démo live avec Chrome DevTools
Cette partie correspond à :
> Session de test / démo live via les devtools, commentée
Le but n'est pas de tout montrer. Le but est de montrer que tu sais observer l'application proprement.
### Préparation
Lancer le front :
```bash
npm run dev
```
Ouvrir :
```txt
http://localhost:5173/?debug
```
Si Vite affiche un autre port, utiliser ce port.
Ouvrir DevTools :
- macOS : `Cmd + Option + I`
- Windows/Linux : `Ctrl + Shift + I`
Disposition conseillée :
- app à gauche ;
- DevTools docké à droite ;
- onglets prêts : `Console`, `Network`, `Sources`, `Application`.
### Étape 1 : Console
Objectif :
- vérifier que l'app ne crashe pas ;
- surveiller les erreurs quand on interagit.
À faire :
1. Ouvrir `Console`.
2. Reload la page.
3. Vérifier s'il y a des erreurs rouges.
4. Garder la console ouverte pendant la démo.
Phrase simple :
> Je commence par la console pour vérifier que la démo ne cache pas une erreur runtime.
### Étape 2 : Network
Objectif :
- montrer que les assets sont chargés ;
- vérifier map, modèles, sons, vidéos.
À faire :
1. Ouvrir `Network`.
2. Cocher `Disable cache`.
3. Reload.
4. Filtrer :
- `map.json`
- `model.gltf`
- `.webm`
- `.mp3`
- `.srt`
Ce que tu peux expliquer :
- `map.json` décrit la scène ;
- `model.gltf` charge les assets 3D ;
- `.webm` sert aux prompts in-game ;
- `.mp3` sert à musique/dialogues/SFX ;
- `.srt` sert aux sous-titres.
Phrase simple :
> Network me permet de vérifier que la feature n'est pas juste du code React, elle dépend aussi d'assets runtime.
### Étape 3 : Sources pour suivre le repair game
Objectif :
- montrer une transition de step ;
- prouver que `RepairGame` écrit dans Zustand.
À faire :
1. Ouvrir `Sources`.
2. `Cmd + P` ou `Ctrl + P`.
3. Chercher `RepairGame.tsx`.
4. Mettre un breakpoint sur un appel à `setMissionStep`.
5. Chercher `useGameStore.ts`.
6. Mettre un breakpoint dans `setMissionStep` ou `completeMission`.
Dans l'app :
1. Activer `?debug`.
2. Dans lil-gui, mettre `Scene = Physics`.
3. Garder `Camera Mode = Player`.
4. Dans le debug overlay, passer `Main state = Bike`.
5. Mettre le sub-state sur `waiting` si besoin.
6. Interagir avec l'objet ou avancer les steps via le debug panel.
Quand le breakpoint pause :
- regarder `mission` ;
- regarder `step` ;
- expliquer la transition.
Phrase simple :
> Là on voit que le composant ne change pas juste son état local. Il déclenche une transition dans le store global.
### Étape 4 : Sources pour suivre l'audio
Objectif :
- montrer qu'un son passe par `AudioManager`.
À faire :
1. Ouvrir `AudioManager.ts`.
2. Mettre un breakpoint dans `playSound`.
3. Déclencher un son, par exemple ouverture/fermeture de mallette.
4. Inspecter :
- `path`
- `volume`
- `options.category`
Ensuite :
1. Ouvrir le menu avec `Escape`.
2. Changer le volume SFX.
3. Mettre un breakpoint dans `setCategoryVolume`.
4. Vérifier que la catégorie change.
Phrase simple :
> Les sliders ne modifient pas directement un audio isolé. Ils mettent à jour un volume de catégorie dans le manager.
### Étape 5 : Application
Objectif :
- montrer les données locales du navigateur.
À faire :
1. Ouvrir `Application`.
2. Regarder `Local Storage`.
3. Chercher la clé de debug :
```txt
la-fabrik-debug-controls
```
Ce que ça montre :
- certains choix debug peuvent être persistés ;
- ce n'est pas la progression du jeu ;
- c'est juste du confort dev.
### Étape 6 : Performance, optionnel
Objectif :
- montrer que tu sais diagnostiquer si ça rame.
À faire seulement si demandé :
1. Ouvrir `Performance`.
2. Record 5 secondes.
3. Bouger dans la scène.
4. Stop.
5. Observer FPS, scripting, rendering.
Phrase simple :
> Si la scène rame, je ne devine pas. Je regarde si le temps part dans le JS, le rendu, ou le chargement d'assets.
## Démo recommandée en 5 minutes
### 1. Ouvrir la scène debug
```txt
http://localhost:5173/?debug
```
Dans lil-gui :
- `Scene = Physics`
- `Camera Mode = Player`
- `Debug Overlay = true`
### 2. Montrer le store
Dans le debug panel :
- passer `Main state = Bike`
- passer le step à `waiting`
- avancer avec `Next step`
Expliquer :
> Le panel debug écrit dans le même store que le vrai gameplay.
### 3. Montrer le repair game
Faire défiler les steps :
```txt
waiting -> inspected -> fragmented -> scanning -> repairing
```
Montrer :
- objet de mission ;
- mallette ;
- modèle éclaté ;
- scan ;
- pièces à grab ;
- validation bloquée si mauvaise pièce.
### 4. Montrer l'audio
Ouvrir DevTools :
- breakpoint dans `AudioManager.playSound` ;
- déclencher un son ;
- montrer `path` et `category`.
### 5. Montrer Zustand
Breakpoint dans `useGameStore`.
Expliquer :
> Quand la mission avance, ce n'est pas chaque composant qui invente son état. La progression passe par des actions centralisées.
## Questions probables et réponses simples
### Pourquoi Zustand ?
Parce qu'on a besoin d'une source de vérité partagée entre UI, monde 3D, debug panel et gameplay.
### Pourquoi pas tout dans Zustand ?
Parce que certaines valeurs changent trop souvent. Les positions, vitesses, raycasts et animations frame par frame restent dans des refs ou dans les composants R3F.
### Pourquoi un `AudioManager` ?
Parce que l'audio navigateur est impératif. On doit gérer des `HTMLAudioElement`, des pools, une musique active et des volumes de catégories.
### Pourquoi séparer octree et Rapier ?
Pour garder un player controller simple tout en utilisant Rapier pour les objets manipulables.
### Pourquoi `RepairGame` est data-driven ?
Pour réutiliser le même flow sur plusieurs missions et garder les variations dans `repairMissions.ts`.
### Qu'est-ce qui est incomplet ?
- pas de vrai `GameManager` global ;
- lock mouvement réparation désactivé ;
- `repairRuntime` pas consommé ;
- editor save uniquement en dev ;
- hand tracking encore approximatif sur profondeur et smoothing.
## Checklist avant la review
Commandes :
```bash
npm run format:check
npm run typecheck
npm run lint
npm run build
```
Pages à ouvrir :
- `/`
- `/?debug`
- `/editor`
- `/docs`
- `/docs/code-review`
Fichiers à avoir en tête :
- `src/world/World.tsx`
- `src/components/three/gameplay/RepairGame.tsx`
- `src/components/three/gameplay/RepairRepairingStep.tsx`
- `src/data/gameplay/repairMissions.ts`
- `src/managers/AudioManager.ts`
- `src/managers/stores/useGameStore.ts`
- `src/components/ui/debug/GameStateDebugPanel.tsx`
Réponses pièges à réviser :
- lock mouvement repair désactivé actuellement ;
- `repairRuntime` pas consommé ;
- player pas Rapier ;
- save editor pas production ;
- old boot flags non branchés.
## Mini plan de révision
### Session 1 : 20 minutes
Lire :
- cette fiche ;
- `docs/technical/repair-game.md` ;
- `docs/technical/zustand.md`.
Objectif :
- savoir expliquer le repair game et le store.
### Session 2 : 20 minutes
Lire :
- `docs/technical/audio.md` ;
- `docs/technical/interaction.md`.
Objectif :
- savoir expliquer audio + interaction.
### Session 3 : 20 minutes
Faire la démo :
- lancer `npm run dev` ;
- ouvrir `/?debug` ;
- ouvrir DevTools ;
- faire un breakpoint dans `RepairGame` ;
- faire un breakpoint dans `AudioManager`.
Objectif :
- ne pas découvrir DevTools le jour de la review.
## Version ultra-courte à garder en tête
Si tu paniques, reviens à ça :
```txt
World monte la scène.
GameStageContent place les missions.
RepairGame orchestre les steps.
repairMissions fournit la data.
useGameStore garde la progression.
InteractionManager gère focus/trigger/grab.
AudioManager gère sons, musique, dialogues.
DevTools permet de suivre assets, state et appels runtime.
```
+29 -1
View File
@@ -9,7 +9,9 @@ This document describes the 3D components that are currently used in the runtime
| Interaction | `InteractableObject` | Focus detection through distance and raycasting | | Interaction | `InteractableObject` | Focus detection through distance and raycasting |
| Interaction | `TriggerObject` | Press-to-trigger interactions, optional sound, optional spawned model | | Interaction | `TriggerObject` | Press-to-trigger interactions, optional sound, optional spawned model |
| Interaction | `GrabbableObject` | Physics grab and hand-tracking grab behavior | | Interaction | `GrabbableObject` | Physics grab and hand-tracking grab behavior |
| Model | `AnimatedModel` | GLTF animation playback with fade, speed, and context controls |
| Model | `ExplodableModel` | Split/reassemble a GLTF model into separated parts | | Model | `ExplodableModel` | Split/reassemble a GLTF model into separated parts |
| Model | `SimpleModel` | Lightweight static GLTF render helper |
| Gameplay | `RepairCaseModel` | Repair case lid animation, proximity float, and wobble | | Gameplay | `RepairCaseModel` | Repair case lid animation, proximity float, and wobble |
## Continuous Animation ## Continuous Animation
@@ -27,6 +29,30 @@ Use GSAP only for discrete timeline-style transitions. Current example:
- `RepairCaseModel` animates the case lid between open and closed rotations. - `RepairCaseModel` animates the case lid between open and closed rotations.
## Animated Models
`src/components/three/models/AnimatedModel.tsx` wraps drei `useAnimations()` around a loaded GLTF scene.
It supports:
- default animation playback
- optional autoplay
- fade duration
- speed multiplier
- `onLoaded`
- `onAnimationEnd`
- context controls through `AnimatedModelContext`
The debug physics scene currently uses it to preview:
```txt
public/models/electricienne_animated/model.gltf
```
with the `Dance` animation.
`src/hooks/animation/useCharacterAnimation.ts` is a hook-level alternative for components that need to own their group ref and animation controls directly.
## GLTF Reuse ## GLTF Reuse
Use `useClonedObject` when a GLTF scene is reused by a component instance. It memoizes `scene.clone(true)` and keeps clone creation out of render churn. Use `useClonedObject` when a GLTF scene is reused by a component instance. It memoizes `scene.clone(true)` and keeps clone creation out of render churn.
@@ -44,7 +70,9 @@ src/components/three/
│ ├── InteractableObject.tsx │ ├── InteractableObject.tsx
│ └── TriggerObject.tsx │ └── TriggerObject.tsx
├── models/ ├── models/
── ExplodableModel.tsx ── AnimatedModel.tsx
│ ├── ExplodableModel.tsx
│ └── SimpleModel.tsx
└── world/ └── world/
└── SkyModel.tsx └── SkyModel.tsx
``` ```
+275 -73
View File
@@ -4,99 +4,301 @@ This document describes the code that exists today in the repository.
## Runtime Structure ## Runtime Structure
- `src/main.tsx` mounts React. - `src/main.tsx` mounts React in `StrictMode`.
- `src/App.tsx` mounts the TanStack `RouterProvider`. - `src/App.tsx` mounts TanStack `RouterProvider`.
- `src/router.tsx` declares the top-level routes: - `src/router.tsx` declares `/`, `/editor`, and `/docs`.
- `/` mounts the playable 3D scene, debug perf overlay, and HTML overlays. - `src/pages/page.tsx` composes the playable route with `HandTrackingProvider`, React Three Fiber `Canvas`, `World`, `DebugPerf`, `GameUI`, and `SceneLoadingOverlay`.
- `/editor` mounts the map editor page. - `src/pages/editor/page.tsx` composes the local editor route.
- `src/world/World.tsx` composes the active scene, including: - `src/components/docs/DocsLayout.tsx` composes the in-app documentation route.
- environment and lighting
- debug helpers and debug camera mode Detailed runtime-loading notes live in `docs/technical/scene-runtime.md`.
- either the map scene or the debug physics test scene
- the player rig when the active camera mode is `player` ## World Composition
- `src/hooks/world/useWorldSceneLoading.ts` owns the production scene loading state shared by `World`, `GameMap`, and the player octree readiness.
- `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, renders them progressively, and shows fallback cubes for missing models. `src/world/World.tsx` is the main 3D scene composer.
- `src/world/GameMapCollision.tsx` builds the player collision octree from dedicated collision nodes only.
- `src/world/GameStageContent.tsx` is wrapped in Rapier `Physics` in the production game scene so stage gameplay objects can use physics without moving the map or player to Rapier. It now mounts reusable `RepairGame` instances for `bike`, `pylone`, and `ferme` mission states. Always-mounted scene systems:
- `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map with the existing grab/trigger/model-preview objects plus separate `Bike`, `Pylone`, and `Farm` repair playground zones.
- `src/world/player/Player.tsx` mounts the camera and controller. - `Environment`
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, repair-step movement locking, and interaction input. - `Lighting`
- debug helpers when `?debug` is active
- optional hand-tracking glove overlays
- optional debug camera controls
Game scene systems:
- `GameMap`
- Rapier `Physics` wrapping `GameStageContent`
- `GameMusic`
- `GameDialogues`
- `GameCinematics` only while `mainState === "outro"`
- `Player` after gameplay is ready
Debug physics scene systems:
- `TestMap`
- `Player` after the debug octree is ready
Debug scene and camera mode are controlled by `src/utils/debug/Debug.ts` and enabled with `?debug`.
## Scene Loading
The production game scene is considered ready only after:
- map data and visible map nodes have settled
- collision source models have settled
- the player octree exists
- the Rapier gameplay stage has mounted
The player is not spawned until that readiness gate is satisfied. This avoids starting player movement, music, dialogue timing, and interactions while the map/stage is still loading.
## Physics Boundaries ## Physics Boundaries
The project currently uses two collision layers with separate responsibilities: The project currently uses two collision systems with separate responsibilities:
- `GameMapCollision` builds an octree used by the player controller for map collision. - Player movement uses a Three.js `Capsule` and an `Octree`.
- The player octree must be built from a small collision-only subset of map nodes. It currently uses the `terrain` node only instead of traversing the full visible map, because building an octree from all rendered props can overload the browser renderer. - Gameplay objects use Rapier rigid bodies and colliders.
- `GameStageContent` is wrapped in Rapier `Physics` for gameplay objects such as repair triggers, cases, grabbables, and future mission-specific objects.
- `TestMap` owns its own Rapier `Physics` playground so repair gameplay can be tuned per mission state without depending on the production map layout.
Keep the player and map octree outside the Rapier provider until there is a deliberate migration plan. This avoids mixing player movement rules with object physics before the gameplay systems need it. `GameMapCollision` builds the player octree from explicit collision nodes. It currently uses only the `terrain` node.
`GameStageContent` is wrapped in Rapier `Physics` so repair cases, triggers, and grabbable parts can use physics without migrating the player controller to Rapier.
This split is deliberate. It keeps the player controller simple while still enabling physical manipulation for gameplay objects.
## Gameplay Layer
The current core gameplay feature is the reusable repair game.
Production placements live in:
```txt
src/world/GameStageContent.tsx
```
The reusable flow lives in:
```txt
src/components/three/gameplay/RepairGame.tsx
```
Mission-specific data lives in:
```txt
src/data/gameplay/repairMissions.ts
```
The repair game supports:
```txt
locked -> waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done
```
Detailed repair-game implementation notes live in `docs/technical/repair-game.md`.
## State Management
Durable progression state lives in:
```txt
src/managers/stores/useGameStore.ts
```
It owns:
- `mainState`
- intro state
- `bike`, `pylone`, and `ferme` mission state
- outro state
- `isCinematicPlaying`
- progression actions
- generic mission actions
Settings state lives in:
```txt
src/managers/stores/useSettingsStore.ts
```
Subtitle display state lives in:
```txt
src/managers/stores/useSubtitleStore.ts
```
Detailed Zustand notes live in `docs/technical/zustand.md`.
## Managers
Managers are used for imperative runtime systems that own browser or frame-adjacent objects.
Current managers:
- `src/managers/AudioManager.ts`
- `src/managers/InteractionManager.ts`
`AudioManager` owns `HTMLAudioElement` instances, music playback, one-shot pools, category volumes, and optional stereo panning.
`InteractionManager` owns focused/nearby/holding state for trigger and grab interactions and exposes a snapshot through `useSyncExternalStore`.
## Interaction Model ## Interaction Model
- `src/managers/InteractionManager.ts` is the current interaction state source. Core interaction files:
- `src/components/three/interaction/InteractableObject.tsx` handles focus detection through distance and raycasting.
- `src/components/three/interaction/TriggerObject.tsx` implements trigger-style interactions.
- `src/components/three/interaction/GrabbableObject.tsx` implements hold-and-release interactions.
- `src/hooks/interaction/useInteraction.ts` exposes the interaction snapshot to React UI.
- `src/components/ui/InteractPrompt.tsx` shows the `E` prompt for trigger interactions.
## Audio - `src/components/three/interaction/InteractableObject.tsx`
- `src/components/three/interaction/TriggerObject.tsx`
- `src/components/three/interaction/GrabbableObject.tsx`
- `src/hooks/interaction/useInteraction.ts`
- `src/components/ui/InteractPrompt.tsx`
- `src/managers/AudioManager.ts` currently provides pooled one-shot sound playback and looped music playback. The player controller bridges raw input to semantic interaction actions:
- Trigger interactions may play audio directly through `AudioManager`.
## Debug System - `E` triggers focused trigger objects
- primary mouse button grabs focused grabbable objects
- hand tracking can grab hand-controlled grabbable objects
- Debug mode is enabled with `?debug`. Detailed interaction notes live in `docs/technical/interaction.md`.
- `src/utils/debug/Debug.ts` owns the `lil-gui` instance and debug controls.
- `src/hooks/debug/useCameraMode.ts` and `src/hooks/debug/useSceneMode.ts` subscribe to debug state.
- `src/components/debug/DebugPerf.tsx` lazily mounts `r3f-perf` in debug mode.
- `src/components/ui/debug/DebugOverlayLayout.tsx` mounts the compact HTML debug overlay when enabled from `lil-gui`.
- `src/components/ui/debug/GameStateDebugPanel.tsx` exposes current game state, main/sub-state switching, previous/next step controls, and reset.
- `src/components/ui/debug/HandTrackingDebugPanel.tsx` shows hand tracking status, usage, loaded glove model, hand count, and fist state while hand tracking is active.
- `src/components/ui/SceneLoadingOverlay.tsx` displays the fullscreen loading state for 3D scenes, including the production game scene, debug physics scene, and editor scene.
- `src/components/three/handTracking/HandTrackingGlove.tsx` places the rigged `gant_l` and `gant_r` models on detected hands in the debug physics scene.
- `src/components/debug/scene/DebugHelpers.tsx` mounts debug helpers.
- `src/components/debug/scene/DebugCameraControls.tsx` mounts the free debug camera.
- `lil-gui` global debug controls include camera mode, scene mode, `R3F Perf`, and `Debug Overlay`; interaction-specific controls live in the `Interaction` folder.
## 3D Component Domains ## Audio, Dialogue, And Subtitles
- `src/components/three/models/` contains reusable model helpers such as `ExplodableModel`. Audio is split into:
- `src/components/three/interaction/` contains reusable interaction wrappers such as `InteractableObject`, `TriggerObject`, and `GrabbableObject`.
- `src/components/three/handTracking/` contains R3F hand tracking debug models such as the glove overlays. - `music`
- `src/components/three/gameplay/` contains the reusable production `RepairGame` flow, repair case, repair steps, and repair prompt components. - `sfx`
- `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`. - `dialogue`
Runtime dialogue data lives under:
```txt
public/sounds/dialogue/
```
The current subtitle model is one SRT file per voice and language. A dialogue entry references one cue by `subtitleCueIndex`.
`src/utils/dialogues/playDialogue.ts` queues dialogue playback and synchronizes the active subtitle cue against the playing audio element.
Detailed audio notes live in `docs/technical/audio.md`.
## Cinematics
Runtime cinematic data lives in:
```txt
public/cinematics.json
```
Cinematics support camera keyframes, GSAP timelines, optional dialogue cues, and `isCinematicPlaying` input locking. Current world integration mounts `GameCinematics` only during the outro state.
## Hand Tracking
Hand tracking can use:
- local Python backend over WebSocket
- browser-side MediaPipe through `@mediapipe/tasks-vision`
Important files:
- `src/providers/gameplay/HandTrackingProvider.tsx`
- `src/hooks/handTracking/useRemoteHandTracking.ts`
- `src/hooks/handTracking/useBrowserHandTracking.ts`
- `src/hooks/handTracking/useBothFistsHold.ts`
- `src/components/three/handTracking/HandTrackingGlove.tsx`
- `backend/main.py`
Hand tracking is activated lazily. In production it is enabled during repair steps that need hand input. In debug physics mode it is enabled when interaction context makes hand input useful.
Detailed hand-tracking notes live in `docs/technical/hand-tracking.md`.
## Editor System ## Editor System
- `src/pages/editor/page.tsx` is the route-level editor page for `/editor`. The editor route is:
- `src/components/editor/EditorControls.tsx` renders the HTML editor control panel.
- `src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, shortcuts, and map rendering. ```txt
- `src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls. /editor
- `src/controls/editor/FlyController.tsx` provides player-style editor navigation. ```
- `src/hooks/editor/useEditorSceneData.ts` loads scene data and handles folder upload fallback.
- `src/hooks/editor/useEditorHistory.ts` owns editor undo and redo state. Important editor files:
- `src/utils/editor/loadEditorScene.ts` handles editor-only folder upload parsing.
- `src/utils/map/loadMapSceneData.ts` is shared by the game scene and editor to load `public/map.json` and resolve model URLs. - `src/pages/editor/page.tsx`
- `src/types/editor/editor.ts` contains the shared `MapNode`, `SceneData`, and `TransformMode` types. - `src/components/editor/EditorControls.tsx`
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used across store, config, debug UI, and gameplay components. - `src/components/editor/scene/EditorScene.tsx`
- `src/components/editor/scene/EditorMap.tsx`
- `src/components/editor/EditorDialogueManifestPanel.tsx`
- `src/components/editor/EditorCinematicManifestPanel.tsx`
- `src/components/editor/EditorSrtPanel.tsx`
- `src/hooks/editor/useEditorSceneData.ts`
- `src/hooks/editor/useEditorHistory.ts`
- `src/controls/editor/FlyController.tsx`
The editor shares `MapNode` data with the runtime map loader.
Local save endpoints live in `vite.config.ts`:
- `POST /api/save-map`
- `POST /api/save-srt`
- `GET /api/validate-dialogues`
- `POST /api/save-dialogues`
- `POST /api/save-cinematics`
These are Vite dev-server helpers, not production backend APIs.
Detailed editor notes live in `docs/technical/editor.md`.
## Documentation System
The docs route uses:
- `src/components/docs/DocsLayout.tsx`
- `src/components/docs/DocsDocument.tsx`
- `src/data/docs/docsSections.ts`
- `src/routes/DocsRoute.tsx`
- `src/pages/docs/**/page.tsx`
Docs pages import Markdown files with `?raw` and render them through `react-markdown` plus `remark-gfm`.
## 3D Component Domains
`src/components/three/` is organized by domain:
- `gameplay`: repair-game flow and repair components
- `handTracking`: glove overlays
- `interaction`: trigger/grab/focus wrappers
- `models`: animated, simple, and explodable model helpers
- `world`: world/environment objects
## Map Data ## Map Data
- `public/map.json` is expected to be a `MapNode[]`. Runtime map data:
- Each map node `name` maps to `public/models/{name}/model.glb` when available, with `public/models/{name}/model.gltf` kept as fallback.
- The editor renders a fallback cube for missing models. ```txt
- The game scene renders fallback cubes for nodes whose model cannot be resolved. public/map.json
- The game scene currently uses `terrain` as the collision source for the player octree. Additional collision nodes should be explicit lightweight collision assets, not arbitrary visible decoration models. ```
Expected shape:
```ts
interface MapNode {
name: string;
type: string;
position: [number, number, number];
rotation: [number, number, number];
scale: [number, number, number];
}
```
Each `name` maps to:
```txt
public/models/{name}/model.glb
public/models/{name}/model.gltf
```
## Current Limitations ## Current Limitations
- The repository is a prototype, not the full intended game runtime. - The repository is still a prototype.
- `src/world/debug/TestMap.tsx` is part of the active scene composition. - There is no central production `GameManager`.
- There is no central gameplay orchestrator such as `GameManager`. - The repair game is implemented, but broader mission orchestration is still light.
- The mission state exists in Zustand, but zones, cinematics, dialogue, and the full repair sequence are not implemented. - `useRepairMovementLocked()` currently returns `false`, so repair movement lock is disabled even though the rule and UI component exist.
- The player uses octree collision and simple movement rules, not a complete gameplay physics stack. - The repair-runtime setting is stored in settings but not consumed by the repair-game implementation.
- Editor save-to-server is implemented as a Vite dev-server plugin, not a production backend API. - Player collision and Rapier gameplay physics are separate systems.
- Editor persistence is local development tooling only.
- Debug systems are still part of active scene composition and should remain easy to identify.
+217
View File
@@ -0,0 +1,217 @@
# Audio Technical Notes
This document describes the audio systems that exist in the current codebase.
## Scope
Audio is currently split into three runtime categories:
- `music`: looped background music
- `dialogue`: spoken dialogue audio linked to subtitles
- `sfx`: one-shot interaction and feedback sounds
The shared runtime service is `src/managers/AudioManager.ts`. User-facing volume settings live in `src/managers/stores/useSettingsStore.ts` and are forwarded to `AudioManager` by category.
## AudioManager
`AudioManager` is a singleton side-effect service. It owns browser audio elements, category volumes, pooled one-shot sounds, music playback, and stereo panning for one-shot sounds.
Supported public methods:
- `playMusic(path, volume)`: starts or updates a looped music track.
- `stopMusic()`: stops the active music track.
- `playSound(path, volume, options)`: plays a pooled one-shot sound and returns its `HTMLAudioElement`.
- `setCategoryVolume(category, volume)`: updates `music`, `sfx`, or `dialogue` volume.
- `getCategoryVolume(category)`: reads the current category volume.
- `destroy()`: stops music, clears pools, closes the audio context, and resets the singleton.
One-shot sounds are pooled by path with a maximum pool size per sound. If every element in a pool is busy, the pool grows until the limit, then recycles an existing element.
Browser autoplay restrictions are handled in `playMusic()`: if playback is blocked by the browser, the manager waits for a user `pointerdown` or `keydown`, then retries the music.
## Music
Runtime music is mounted by `src/world/GameMusic.tsx`.
Current behavior:
- `GameMusic` calls `AudioManager.getInstance().playMusic()` on mount.
- The current music path is `/sounds/musique/test.mp3`.
- The base music volume is `0.33` before category volume is applied.
- On unmount, `GameMusic` calls `stopMusic()`.
Effective music volume is:
```txt
base music volume * settings music volume
```
Use `music` only for long-running looped background tracks. Do not use `playSound()` for music, because one-shot pooling is designed for short overlapping sounds.
## Sound Effects
SFX are short one-shot sounds. They should use `AudioManager.playSound()` with the default category or with `{ category: "sfx" }`.
Example:
```ts
AudioManager.getInstance().playSound("/sounds/sfx/click.mp3", 0.8, {
category: "sfx",
pan: 0,
});
```
Useful options:
- `category`: `sfx` or `dialogue`; defaults to `sfx`.
- `pan`: stereo panning from `-1` left to `1` right.
- `playbackRate`: playback speed multiplier.
SFX volume is controlled by the settings menu through the `sfx` category volume.
## Dialogues
Runtime dialogue data lives under `public/sounds/dialogue/`.
```txt
public/
└── sounds/
└── dialogue/
├── dialogues.json
└── subtitles/
├── fr/
│ ├── narrateur.srt
│ ├── fermier.srt
│ └── electricienne.srt
└── en/
├── narrateur.srt
├── fermier.srt
└── electricienne.srt
```
The dialogue manifest shape is defined in `src/types/dialogues/dialogues.ts`.
Each dialogue entry contains:
- `id`: stable dialogue identifier
- `voice`: voice group, currently `narrateur`, `fermier`, or `electricienne`
- `audio`: runtime audio path
- `subtitleCueIndex`: cue number inside that voice/language SRT file
- `timecode`: optional global trigger time in seconds from scene start
Dialogues are played through `src/utils/dialogues/playDialogue.ts`.
Important functions:
- `playDialogueById(manifest, dialogueId)`: plays a dialogue from an already loaded manifest.
- `queueDialogueById(manifest, dialogueId)`: queues dialogue playback so multiple requests do not overlap.
- `playGameplayDialogueById(dialogueId)`: loads the gameplay manifest once and queues a dialogue by ID.
- `clearQueuedDialogues()`: resolves pending dialogue requests and clears the queue.
Dialogue audio uses `AudioManager.playSound()` with `{ category: "dialogue" }`, so it follows the dialogue volume setting.
## Dialogue And SRT Link
The subtitle model is one SRT file per voice and language, not one SRT file per dialogue.
A dialogue chooses its subtitle by combining:
1. `voice`
2. selected subtitle language from settings
3. `subtitleCueIndex`
For example, this dialogue:
```json
{
"id": "narrateur_bienvenueaaltera",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur/bienvenueaaltera.mp3",
"subtitleCueIndex": 1
}
```
loads cue `1` from:
```txt
public/sounds/dialogue/subtitles/fr/narrateur.srt
```
when the subtitle language is French, or from:
```txt
public/sounds/dialogue/subtitles/en/narrateur.srt
```
when the subtitle language is English.
If the selected language is missing, the loader falls back to French. Missing English SRT files are warnings during validation, not runtime errors.
SRT timecodes are relative to the dialogue audio file. They are not relative to the game clock and not relative to a cinematic timeline.
## Subtitle Runtime
`playDialogueById()` loads the matching subtitle cue with `loadDialogueSubtitleCue()` before playing the audio.
While audio plays:
- `timeupdate` checks `audio.currentTime`
- the active subtitle is written to `useSubtitleStore`
- `src/components/ui/Subtitles.tsx` renders the current speaker and text
- `ended` and `pause` clear the subtitle
The subtitle overlay respects settings from `useSettingsStore`, including visibility and selected language.
## Global Timecode Dialogues
`src/world/GameDialogues.tsx` loads the dialogue manifest and triggers entries that define `timecode`.
This is useful for simple global scene timing. It should not be used for dialogue that belongs to a cinematic. Cinematic-owned dialogue should be triggered by `dialogueCues` in `public/cinematics.json` instead, otherwise the same dialogue can play twice.
## Cinematic Dialogue Cues
`public/cinematics.json` can include `dialogueCues`.
Each cue contains:
- `time`: seconds relative to the cinematic start
- `dialogueId`: ID from `dialogues.json`
`src/world/GameCinematics.tsx` uses those cues to play dialogue during camera timelines. This keeps camera movement and dialogue playback synchronized without relying on global scene time.
## Editor Tooling
The `/editor` route provides three audio-related tools:
- `Dialogues`: edits `public/sounds/dialogue/dialogues.json` and previews dialogue playback.
- `SRT`: edits one SRT file at a time and validates dialogue assets.
- `Cinematics`: links dialogue IDs to cinematic timelines through `dialogueCues`.
Dev-only Vite endpoints in `vite.config.ts` support local saves:
- `POST /api/save-dialogues`
- `POST /api/save-srt`
- `GET /api/validate-dialogues`
- `POST /api/save-cinematics`
These endpoints are local development helpers. They are not production APIs.
## Validation
`GET /api/validate-dialogues` validates:
- manifest shape
- referenced dialogue audio files
- French SRT files
- referenced subtitle cue indexes
- optional English SRT files as warnings
Run validation after adding or renaming dialogue audio, changing cue indexes, or editing SRT files.
## Known Limitations
- There is no production persistence for audio manifests or SRT files.
- Dialogue branching is not implemented.
- Dialogue interruption and priority rules are minimal; playback is queue-based.
- SRT editing is text-based and does not yet provide waveform editing.
- Music currently supports one active looped track at a time.
+143 -6
View File
@@ -23,6 +23,9 @@ src/
├── components/ ├── components/
│ └── editor/ │ └── editor/
│ ├── EditorControls.tsx │ ├── EditorControls.tsx
│ ├── EditorCinematicManifestPanel.tsx
│ ├── EditorDialogueManifestPanel.tsx
│ ├── EditorSrtPanel.tsx
│ └── scene/ │ └── scene/
│ ├── EditorMap.tsx │ ├── EditorMap.tsx
│ └── EditorScene.tsx │ └── EditorScene.tsx
@@ -37,15 +40,19 @@ src/
│ └── editor/ │ └── editor/
│ └── editor.ts │ └── editor.ts
└── utils/ └── utils/
├── dialogues/
│ └── loadDialogueManifest.ts
├── editor/ ├── editor/
│ └── loadEditorScene.ts │ └── loadEditorScene.ts
── map/ ── map/
└── loadMapSceneData.ts └── loadMapSceneData.ts
└── subtitles/
└── parseSrt.ts
``` ```
## Responsibilities ## Responsibilities
`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, and player-mode toggle. `src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state.
`src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads. `src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads.
@@ -55,7 +62,13 @@ src/
`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls. `src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. `src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. The panel is organized into top-level `details` groups: `Editor`, `Cinematics`, `Dialogues`, and `SRT`.
`src/components/editor/EditorDialogueManifestPanel.tsx` renders the dialogue manifest editor. It loads `dialogues.json`, edits dialogue entries, previews selected dialogue playback, creates missing French SRT cues, and saves the manifest through a dev-server endpoint.
`src/components/editor/EditorCinematicManifestPanel.tsx` renders the cinematic manifest editor. It loads `cinematics.json`, edits camera keyframes and dialogue cues, previews selected cinematics in the editor canvas, and saves the manifest through a dev-server endpoint.
`src/components/editor/EditorSrtPanel.tsx` renders the dialogue subtitle editor inside the control panel. It loads the dialogue manifest, loads one SRT file per voice/language, validates cue structure, previews dialogue audio, and can save SRT files through a dev-server endpoint.
`src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation. `src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation.
@@ -109,13 +122,17 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
2. `useEditorSceneData` calls `loadMapSceneData()`. 2. `useEditorSceneData` calls `loadMapSceneData()`.
3. `loadMapSceneData()` loads `/map.json` and available model URLs. 3. `loadMapSceneData()` loads `/map.json` and available model URLs.
4. If `/map.json` is missing, the page displays a folder-upload flow. 4. If `/map.json` is missing, the page displays a folder-upload flow.
5. `EditorScene` renders the grid, lights, camera controls, and map nodes. 5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
6. `EditorControls` exposes transform mode, history actions, export, save, and selection info. 6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
7. `EditorControls` exposes transform mode, history actions, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
## Controls ## Controls
- Click: select a node. - Click: select a node.
- `Esc`: clear selection. - `Esc`: clear selection.
- Click empty space: clear selection.
- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection.
- Selection clear button: intentionally clear the current selection even when the lock is active.
- `T`: translate mode. - `T`: translate mode.
- `R`: rotate mode. - `R`: rotate mode.
- `S`: scale mode. - `S`: scale mode.
@@ -134,6 +151,123 @@ The editor supports two output paths:
The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It writes to `public/map.json` and enforces a maximum payload size. The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It writes to `public/map.json` and enforces a maximum payload size.
## Editor Loading Overlay
The editor uses `SceneLoadingOverlay` like the runtime scene. `EditorSceneLoadingTracker` lives in `src/pages/editor/page.tsx` and reads drei `useProgress()` inside the canvas.
The route tracks two loading phases:
- map JSON loading through `useEditorSceneData()`
- model loading through `useProgress()`
The overlay is rendered outside the canvas so it remains visible while the R3F scene mounts. The scene itself is wrapped in `Suspense` with a `null` fallback; the visual feedback is handled by the overlay instead of by the canvas fallback.
## Panel Groups
`EditorControls` uses the local `EditorPanelGroup` helper to keep the side panel navigable as tools grow.
Current group order:
1. `Editor`
2. `Cinematics`
3. `Dialogues`
4. `SRT`
Inside the `Editor` group, the section order is:
1. `Shortcuts`
2. `Transform`
3. `Selection`
4. `View`
5. `JSON`
6. `File`
The `Shortcuts` group is nested and closed by default to reduce visual noise.
## Selection Lock
Selection lock is owned by `EditorPage` through `isSelectionLocked`.
The state is passed to:
- `EditorControls`, to render the lock/unlock button
- `EditorScene`, to block `Esc` deselection when locked
- `EditorMap`, to block object selection and empty-space deselection when locked
The clear button calls `onClearSelection` directly from `EditorControls`. This is intentionally separate from scene click behavior so the user always has an explicit way to clear the selection.
## Dialogue SRT Editing
Dialogue subtitle editing is part of the `/editor` side panel.
Runtime dialogue files are grouped under `public/sounds/dialogue/`:
```txt
public/
└── sounds/
└── dialogue/
├── dialogues.json
└── subtitles/
├── fr/
│ ├── narrateur.srt
│ ├── fermier.srt
│ └── electricienne.srt
└── en/
└── ...
```
The current model is one SRT file per voice and language. A dialogue entry references the cue it needs through `subtitleCueIndex`; it does not own a dedicated SRT file.
`EditorSrtPanel` uses:
- `loadDialogueManifest()` to read `/sounds/dialogue/dialogues.json`
- `parseSrt()` to validate local textarea content and find active cues during audio preview
- `/api/save-srt` to write edited SRT files during local development
- `/api/validate-dialogues` to validate the manifest, linked audio, French SRT files, and referenced cue indexes
SRT timecodes are relative to the dialogue audio file being previewed, not to the global game timeline.
Missing English SRT files are warnings, not errors, because runtime loading falls back to French subtitles when the selected language is not available. Keep this behavior until the English translation workflow is ready.
## Dialogue Manifest Editing
`EditorDialogueManifestPanel` edits `public/sounds/dialogue/dialogues.json` in memory and persists it through `/api/save-dialogues`.
The panel supports:
- adding a dialogue entry
- deleting a dialogue entry
- editing `id`, `voice`, `audio`, `subtitleCueIndex`, and optional `timecode`
- previewing the selected dialogue through `playDialogueById()`
- creating a missing French SRT cue through `/api/save-srt`
When a dialogue is added, the editor computes the next `subtitleCueIndex` for the selected voice from the manifest. The generated SRT cue is a valid placeholder block and should be edited later in the SRT panel.
`/api/save-dialogues` is implemented in `vite.config.ts`. It validates manifest shape before writing to `public/sounds/dialogue/dialogues.json`.
## Cinematic Manifest Editing
`EditorCinematicManifestPanel` edits `public/cinematics.json` in memory and persists it through `/api/save-cinematics`.
The manifest shape is:
```ts
interface CinematicDefinition {
id: string;
timecode?: number;
cameraKeyframes: CinematicCameraKeyframe[];
dialogueCues?: CinematicDialogueCue[];
}
```
`cameraKeyframes` are relative to the cinematic start. At least two keyframes are required and keyframe times must increase.
`dialogueCues` are also relative to the cinematic start and reference dialogue IDs from `dialogues.json`. They are used by `GameCinematics` to synchronize dialogue playback with camera timelines. A dialogue synchronized this way should not also define a global `timecode` in `dialogues.json`.
The editor preview sends the selected `CinematicDefinition` to `EditorScene`, where GSAP animates the current editor camera. Orbit and fly controls are disabled during preview.
`/api/save-cinematics` is implemented in `vite.config.ts`. It validates manifest shape before writing to `public/cinematics.json`.
## Styling ## Styling
Editor styles are in `src/index.css` under the `/* Editor page */` section. Classes are prefixed with `editor-` to avoid collisions with the game UI. Editor styles are in `src/index.css` under the `/* Editor page */` section. Classes are prefixed with `editor-` to avoid collisions with the game UI.
@@ -144,3 +278,6 @@ Editor styles are in `src/index.css` under the `/* Editor page */` section. Clas
- Large `map.json` files are not virtualized, culled, or LOD-managed. - Large `map.json` files are not virtualized, culled, or LOD-managed.
- There is no snap-to-grid, duplication, material editing, or object creation workflow. - There is no snap-to-grid, duplication, material editing, or object creation workflow.
- Save to Server is a Vite dev-server helper, not a production backend API. - Save to Server is a Vite dev-server helper, not a production backend API.
- SRT Save is also a Vite dev-server helper, not a production backend API.
- Dialogue and cinematic manifest saves are Vite dev-server helpers, not production backend APIs.
- Dialogue creation still uses placeholder audio paths until real MP3 files are added.
+4 -13
View File
@@ -95,20 +95,11 @@ If any ray hits the object while the object is within `INTERACTION_RADIUS`, the
## Depth Handling ## Depth Handling
Because MediaPipe `z` is relative, the frontend captures the starting depth when the grab begins: Because MediaPipe `z` is relative and noisy, the current frontend does not use it as a direct world-depth controller for object grabbing.
```txt Instead, `GrabbableObject` computes a ray from the 2D hand center and moves the object toward a configurable hold distance in front of the active camera. That hold distance is shared with the mouse grab path and can be tuned in the debug GUI.
initialHandZ = hand.z
initialHoldDistance = hit.distance
```
While holding, the object distance from the camera is adjusted by the change in hand depth: This is less expressive than true depth-aware hand movement, but it is more stable for the current first-person prototype.
```txt
holdDistance = initialHoldDistance + (hand.z - initialHandZ) * sensitivity
```
The final hold distance is clamped between the configured grab minimum and maximum distances to avoid unstable movement.
## UI And Debug ## UI And Debug
@@ -131,7 +122,7 @@ The glove models are intentionally smaller than the raw SVG overlay so they do n
## Known Limitations ## Known Limitations
- Production usage is currently limited to repair mission steps that explicitly need hands. - Production usage is currently limited to repair mission steps that explicitly need hands.
- MediaPipe depth is relative and can be noisy. - MediaPipe depth is relative and currently not used for stable object depth control.
- The virtual hit zone is an approximation based on multiple raycasts, not a real 3D collider. - The virtual hit zone is an approximation based on multiple raycasts, not a real 3D collider.
- There is no smoothing layer for hand position or depth yet. - There is no smoothing layer for hand position or depth yet.
- The SVG hand visualization is a fallback, not the primary display when glove models load correctly. - The SVG hand visualization is a fallback, not the primary display when glove models load correctly.
+240
View File
@@ -0,0 +1,240 @@
# Interaction System Technical Notes
This document explains the shared trigger, grab, focus, and hand-grab system.
## Purpose
The app has several ways for the player to affect the 3D scene:
- press `E` on focused trigger objects
- hold the primary mouse button on grabbable objects
- close a tracked hand into a fist to grab hand-controlled objects
- release objects and optionally snap them into target positions
The implementation keeps those rules in a reusable interaction layer so gameplay features such as the repair game do not each create their own input system.
## Main Files
| File | Responsibility |
| --------------------------------------------------------- | ----------------------------------------------- |
| `src/managers/InteractionManager.ts` | Shared interaction state and imperative actions |
| `src/hooks/interaction/useInteraction.ts` | React subscription to the manager |
| `src/components/three/interaction/InteractableObject.tsx` | Distance/raycast focus detection |
| `src/components/three/interaction/TriggerObject.tsx` | Press-to-trigger wrapper |
| `src/components/three/interaction/GrabbableObject.tsx` | Physics-backed grab and hand grab wrapper |
| `src/components/ui/InteractPrompt.tsx` | HTML prompt for focused trigger interactions |
| `src/world/player/PlayerController.tsx` | Keyboard/mouse input bridge |
## Architecture
The interaction system has three layers:
1. R3F objects detect focus and register handles.
2. `InteractionManager` stores the current interaction snapshot.
3. UI and player input read the snapshot and trigger the selected action.
This is intentionally not Zustand. Interaction focus and holding state are short-lived, frame-adjacent runtime state. A small singleton plus `useSyncExternalStore` is a better fit than putting high-frequency interaction details into the durable game progression store.
## Interaction Snapshot
The snapshot type lives in:
```txt
src/types/interaction/interaction.ts
```
```ts
interface InteractionSnapshot {
focused: InteractableHandle | null;
nearby: boolean;
holding: boolean;
handHolding: boolean;
}
```
Meaning:
- `focused`: the interactable currently aimed at by the camera ray
- `nearby`: at least one interactable is within interaction radius
- `holding`: mouse/player-controller grab is active
- `handHolding`: hand-tracking grab is active
`nearby`, `holding`, and `handHolding` are also used by the hand-tracking provider to decide when webcam tracking should stay active in the debug physics scene.
## Focus Detection
Focus detection lives in:
```txt
src/components/three/interaction/InteractableObject.tsx
```
Each frame, it:
1. finds the interactable world position from its Rapier body or group transform
2. checks distance from the camera
3. marks the handle as nearby if it is inside radius
4. raycasts from the camera forward direction
5. sets the focused handle when the ray hits the object
6. clears focus if the object is no longer nearby or no longer aimed at
This gives a simple first-person interaction model: the player must be close enough and looking at the object.
## Trigger Objects
Trigger implementation:
```txt
src/components/three/interaction/TriggerObject.tsx
```
`TriggerObject` wraps children in a fixed Rapier body and exposes a trigger handle.
When triggered, it can:
- play an optional SFX through `AudioManager`
- call `onTrigger`
- spawn an optional model at an offset
Typical users:
- repair-object inspection
- repair-case open/fragment interaction
- install target
- completion target
- debug scene trigger sphere
## Grabbable Objects
Grab implementation:
```txt
src/components/three/interaction/GrabbableObject.tsx
```
`GrabbableObject` wraps children in a dynamic Rapier body and exposes a grab handle.
Mouse/controller grab flow:
1. Player focuses the object.
2. Mouse down calls `InteractionManager.pressInteract()`.
3. The object enters holding mode.
4. Each frame, velocity is pushed toward a hold target in front of the camera.
5. Mouse up calls `releaseInteract()`.
6. The object can snap to the nearest configured target.
Important tuning values live in:
```txt
src/data/interaction/grabConfig.ts
```
The debug GUI exposes hold stiffness, throw boost, and hold distance.
## Snap-To-Target
`GrabbableObject` supports:
- `snapTargets`
- `snapRadius`
- `snapDuration`
- `onSnap`
On release, the object finds the nearest target inside `snapRadius`. If a target is found, GSAP animates the Rapier body translation to that target and calls `onSnap`.
The repair game uses this to place replacement parts and broken parts into case placeholders.
## Hand-Controlled Grab
If `handControlled` is true, `GrabbableObject` also reads:
```txt
useHandTrackingSnapshot()
```
Hand grab flow:
1. Find a detected hand where `hand.isFist` is true.
2. Compute the visual center of the hand from landmark bounds.
3. Convert that screen-space point to a camera ray.
4. Raycast against the object.
5. Use a small set of offset rays around the center to make hit detection more forgiving.
6. If the object is in range and hit, enter `handHolding`.
7. Move the object toward a hold target in front of the camera while the fist remains closed.
8. When the fist opens or disappears, release and snap if possible.
This is an approximation, not a full 3D hand collider. It is a practical prototype compromise because MediaPipe gives normalized camera-space landmarks and relative depth, not stable world-space hand meshes.
## Player Input Bridge
The player controller owns raw keyboard and mouse input:
```txt
src/world/player/PlayerController.tsx
```
It calls:
- `interaction.pressInteract()` when `E` is pressed and the focused handle is a trigger
- `interaction.pressInteract()` on mouse down when the focused handle is a grab
- `interaction.releaseInteract()` on mouse up when a grab is active
Input is ignored while:
- the settings menu is open
- a cinematic is playing
Movement lock is read separately from `useRepairMovementLocked`, but that hook currently returns `false` on this branch.
## UI Prompt
The prompt lives in:
```txt
src/components/ui/InteractPrompt.tsx
```
It appears only when:
- camera mode is `player`
- a focused interaction exists
- the player is not holding an object
- the focused interaction is a trigger
The prompt does not appear for grab objects, because grabs are mouse/hand actions rather than `E` trigger actions.
## Debug Controls
Interaction debugging is split between:
- lil-gui `Interaction` folder for showing interaction spheres
- lil-gui `GrabbableObject` folder for grab tuning
- debug physics scene for live trigger/grab testing
- hand-tracking debug panel for hand grab state
Use:
```txt
http://localhost:5173/?debug
```
Then switch the scene mode to `Physics` from lil-gui.
## Why This Architecture Works
The interaction layer separates concerns:
- R3F objects know their distance/raycast hit state.
- The player controller owns input events.
- UI only subscribes to a snapshot.
- Gameplay objects receive semantic callbacks like `onTrigger`, `onSnap`, or `onPositionChange`.
This keeps the repair game focused on gameplay rules instead of low-level input plumbing.
## Known Limitations
- Only one focused handle is stored at a time.
- The focus rule is camera ray based, so side-facing interactions can feel strict without larger meshes or radii.
- Hand grab uses screen-space raycasts, not physical hand colliders.
- The manager is singleton-based, so tests must call `destroy()` or isolate state when needed.
- `nearby` is boolean, not a list exposed to UI, so the current UI cannot rank multiple nearby objects.
+365
View File
@@ -0,0 +1,365 @@
# Repair Game Technical Notes
This document explains the implementation of the reusable repair-game flow.
## Purpose
The repair game is the current core gameplay loop. It gives three missions the same interaction structure while allowing mission-specific assets, broken parts, replacement choices, prompts, and timing to live in data.
Implemented missions:
| Mission | Object | Role |
| -------- | ------------- | --------------------------------------------- |
| `bike` | E-bike | Repair a damaged cooling core |
| `pylone` | Power pylon | Restore relay/panel-like broken parts |
| `ferme` | Vertical farm | Stabilize irrigation/sensor-like broken parts |
## Main Files
| File | Responsibility |
| ---------------------------------------------- | ------------------------------------------------- |
| `src/components/three/gameplay/RepairGame.tsx` | Orchestrates the repair step machine |
| `src/data/gameplay/repairMissions.ts` | Mission-specific data |
| `src/types/gameplay/repairMission.ts` | Mission ids, step ids, guards |
| `src/managers/stores/useGameStore.ts` | Global progression and mission transitions |
| `src/world/GameStageContent.tsx` | Production placement of the three repair missions |
| `src/world/debug/TestMap.tsx` | Debug repair playground placement |
## State Machine
Repair mission steps are defined in:
```txt
src/types/gameplay/repairMission.ts
```
```txt
locked -> waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done
```
The practical flow is:
```mermaid
stateDiagram-v2
[*] --> locked
locked --> waiting: mission unlocked
waiting --> inspected: inspect mission object
inspected --> fragmented: repair-case trigger or two-fists hold
fragmented --> scanning: fragmentation timer
scanning --> repairing: scan sequence complete
repairing --> reassembling: install target validates
reassembling --> done: reassembly timer
done --> [*]: completion target calls completeMission
```
There is no dedicated finite-state-machine library. The state machine is intentionally lightweight and distributed across:
- `MissionStep` union types
- Zustand transition helpers
- conditional rendering in `RepairGame`
- callbacks passed to step components
For the current prototype, this is readable and low overhead. If mission rules become much more branched, a centralized mission orchestrator or FSM library would become more useful.
## Integration With Zustand
The durable state lives in:
```txt
src/managers/stores/useGameStore.ts
```
`RepairGame` reads:
- `mainState`
- current step for its mission
`RepairGame` writes:
- `setMissionStep(mission, nextStep)`
- `completeMission(mission)`
The important architectural choice is that reusable repair components do not call `setBikeState`, `setPyloneState`, or `setFermeState` directly. They use generic mission actions so the same component can run for all three missions.
## Data-Driven Mission Config
Mission variation lives in:
```txt
src/data/gameplay/repairMissions.ts
```
Each mission config defines:
- `id`
- `label`
- `description`
- `modelPath`
- optional `modelScale`
- `stageUiPath`
- `interactUiPath`
- `brokenUiPath`
- repair case transform
- optional scan/reassembly timings
- `requiredReplacementPartId`
- `brokenParts`
- `replacementParts`
The main benefit is that `RepairGame` stays generic. A mission can change broken nodes, replacement choices, or prompt videos without changing the orchestration component.
The tradeoff is that the config can grow complex. If one future mission needs very different rules, create a mission-specific component instead of forcing every exception into the shared config.
## Orchestration Component
`RepairGame.tsx` is a step router.
It:
1. receives a `mission` id and transform props
2. gets `config = REPAIR_MISSIONS[mission]`
3. subscribes to the active `mainState`
4. subscribes to the current mission step
5. preloads mission assets
6. mounts the component for the active step
7. stores local runtime state needed between steps
Local runtime state:
- `casePlaceholders`: placeholder transforms emitted by the repair case GLTF
- `scannedBrokenParts`: output of the scan sequence used by the repair step
Those values are local because they are transient scene/runtime details. They do not need to persist globally in Zustand.
## Step Components
### Waiting
File:
```txt
src/components/three/gameplay/RepairInspectionObject.tsx
```
The mission object is rendered with a 3D prompt video and wrapped in an interaction trigger. Pressing `E` while focused moves the mission to `inspected`.
### Inspected
Files:
```txt
src/components/three/gameplay/RepairMissionCase.tsx
src/components/three/gameplay/RepairCaseModel.tsx
src/hooks/gameplay/useRepairFragmentationInput.ts
```
The repair case appears near the mission object. The player can:
- aim at the case and press `E`
- hold both fists closed for one second when hand tracking is active
Both paths move to `fragmented`.
Important current detail: `useRepairMovementLocked()` currently returns `false`, so the movement-lock rule and indicator are present but disabled in the current branch.
### Fragmented
File:
```txt
src/components/three/models/ExplodableModel.tsx
```
The mission object is shown split apart. A timer then moves the mission to `scanning`.
The default delay comes from:
```txt
REPAIR_FRAGMENTATION_SEQUENCE_SECONDS
```
### Scanning
File:
```txt
src/components/three/gameplay/RepairScanSequence.tsx
```
The scan sequence:
- keeps the exploded model visible
- receives model parts from `ExplodableModel`
- advances an active part index over time
- renders `RepairScanVisual` on the active part
- reveals broken-part highlights when configured broken parts have been reached
- returns `RepairScannedBrokenPart[]` when done
Broken-part lookup first tries `brokenParts[].nodeName`. If no configured node matches, it falls back to the first available exploded parts. This fallback is useful while GLTF node names are still unstable, but precise `nodeName` config is safer for production.
### Repairing
File:
```txt
src/components/three/gameplay/RepairRepairingStep.tsx
```
This is the densest gameplay step.
It renders:
- install target
- placeholder markers
- grabbable replacement parts
- grabbable broken parts to store
- placement feedback
- ready-to-install prompt
Important local state:
- `placedPartIds`: replacement parts that snapped near a placeholder
- `depositedBrokenPartIds`: broken parts stored in the case
- `showBlockedInstallFeedback`: temporary visual feedback when install is attempted too early
Validation:
```txt
correct replacement part placed
AND every scanned broken part deposited
```
Only then does the install target call `onRepair()` and move to `reassembling`.
### Reassembling
File:
```txt
src/components/three/gameplay/RepairReassemblyStep.tsx
```
The exploded model animates back into assembled form and completion particles play. A timer then moves the mission to `done`.
Mission configs can override the default reassembly duration.
### Done
File:
```txt
src/components/three/gameplay/RepairCompletionStep.tsx
```
The repaired object remains visible. The player validates the completion target, then:
1. the repair case closes
2. the case plays its exit animation
3. `completeMission(mission)` advances the global game progression
## Repair Case Details
The case model implementation lives in:
```txt
src/components/three/gameplay/RepairCaseModel.tsx
```
It handles:
- GLTF loading through `useLoggedGLTF`
- clone creation through `useClonedObject`
- pop-in animation
- lid open/close animation
- open/close SFX through `AudioManager`
- proximity-based floating
- small rotation wobble
- exit animation
- placeholder discovery
Placeholder discovery is data-friendly:
```txt
placeholder_*
```
Any GLTF node whose name starts with that prefix is exported to the repair step as a placement target. This lets artists move placeholder transforms in the model file without hard-coding every placement point in TypeScript.
## Interaction Dependencies
The repair game depends on the shared interaction layer:
- `RepairInspectionObject` uses `InteractableObject`
- `RepairMissionCase` uses `TriggerObject`
- `RepairRepairingStep` uses `GrabbableObject` and `TriggerObject`
- completion uses `TriggerObject`
This keeps the repair game from owning raw keyboard or mouse listeners for every object. The player controller handles input, and interaction components decide what is focused.
## Hand Tracking Dependencies
Hand tracking participates in two places:
- `useRepairFragmentationInput` uses `useBothFistsHold`
- `GrabbableObject` can be `handControlled`
`HandTrackingProvider` enables tracking during the repair steps that are expected to use hands:
```txt
inspected
repairing
reassembling
done
```
This avoids keeping the webcam active for the whole game scene.
## Runtime Placement
Production placement lives in:
```txt
src/world/GameStageContent.tsx
```
Current positions:
```tsx
<RepairGame mission="bike" position={[8, 0, -6]} />
<RepairGame mission="pylone" position={[64, 0, -66]} />
<RepairGame mission="ferme" position={[-24, 0, 42]} />
```
Only the repair game whose `mission` matches `useGameStore().mainState` renders active content.
## Debug Placement
Debug placement lives in:
```txt
src/world/debug/TestMap.tsx
```
The debug scene mounts repair playground zones for all missions. Use `?debug`, switch to the physics scene in lil-gui, then use the game-state debug panel to activate the mission you want to test.
## Why This Is A Good Review Focus
This feature shows several important frontend/game architecture skills:
- state-driven scene composition
- data-driven feature variation
- React state for step-local runtime values
- Zustand for durable game progression
- R3F component boundaries
- Rapier object interaction
- hand tracking integration
- audio feedback
- GLTF traversal
- graceful asset fallbacks
## Known Limitations
- Movement lock is currently disabled by an early `return false` in `useRepairMovementLocked`.
- The repair-game runtime setting in the options menu is stored but not consumed by `RepairGame`.
- Broken-part scan fallback can produce incorrect matches if GLTF node names are missing.
- Mission progression is still prototype-level and not owned by a central `GameManager`.
- The same repair flow covers all missions. Very different future missions may need dedicated components.
+252
View File
@@ -0,0 +1,252 @@
# Scene Runtime And Loading
This document explains how the playable route boots the 3D world, loads the map, gates gameplay readiness, and spawns the player.
## Purpose
The playable scene has heavy asynchronous work: map JSON, GLTF models, collision meshes, octree construction, Rapier stage content, audio, dialogues, and the player controller.
The current runtime avoids spawning the player too early. That matters because the player controller needs a ready octree, and the repair game needs the production stage to be mounted before the user starts interacting with objects.
## Entry Flow
```txt
src/main.tsx
-> src/App.tsx
-> src/router.tsx
-> src/pages/page.tsx
-> HandTrackingProvider
-> Canvas
-> World
-> DebugPerf
-> GameUI
-> SceneLoadingOverlay
```
`HomePage` owns the visible loading state and passes `onLoadingStateChange` down to `World`.
The loading progress in `HomePage` is monotonic:
- if the scene is already ready, a late loading event is ignored
- progress can only increase while the scene is booting
This prevents the overlay from jumping backward when nested loaders finish in a slightly different order.
## World Composition
`src/world/World.tsx` is the main scene composer.
Always-mounted systems:
- `Environment`
- `Lighting`
- debug helpers when `?debug` is active
- optional hand-tracking glove overlays
- optional debug camera controls
Game scene systems:
- `GameMap`
- Rapier `Physics` wrapping `GameStageContent`
- `GameMusic`
- `GameDialogues`
- `GameCinematics`, currently only in `mainState === "outro"`
- `Player`
Debug physics scene systems:
- `TestMap`
- `Player`
## Loading State Owner
The world loading gate lives in:
```txt
src/hooks/world/useWorldSceneLoading.ts
```
It tracks:
- `octree`: collision octree built from collision source meshes
- `gameMapLoaded`: map data and visible map nodes settled
- `gameStageLoaded`: Rapier gameplay stage mounted
- `showGameStage`: true when the map is ready enough to mount gameplay content
- `gameplayReady`: true when map, stage, and octree are all ready
The final game-scene readiness condition is:
```ts
showGameStage && gameStageLoaded && octree !== null;
```
The debug physics scene is ready when:
```ts
octree !== null;
```
## Map Loading
Map loading starts in:
```txt
src/world/GameMap.tsx
```
`GameMap` calls:
```txt
src/utils/map/loadMapSceneData.ts
```
That utility:
1. fetches `/map.json`
2. validates it as a `MapNode[]`
3. deduplicates model names
4. checks `public/models/{name}/model.glb`
5. falls back to `public/models/{name}/model.gltf`
6. returns `{ mapNodes, models }`
If a model is missing, the map still renders a fallback cube. This keeps the scene inspectable while assets are incomplete.
## Model Settling
`GameMap` counts settled map nodes.
A node settles when:
- it has no model and renders a fallback cube
- its GLTF model instance has mounted
- a model error boundary catches a load/render error and renders fallback
This prevents `GameMapCollision` from building collision before the visible map has reached a stable state.
## Collision Loading
Collision loading lives in:
```txt
src/world/GameMapCollision.tsx
```
The current production collision source is intentionally small:
```ts
const MAP_COLLISION_NODE_NAMES = new Set(["terrain"]);
```
Only matching map nodes are loaded into the invisible collision group. Then:
```txt
src/hooks/three/useOctreeGraphNode.ts
```
builds the Three.js octree from that group and sends it back through `onOctreeReady`.
This is a performance choice. Building a player collision octree from every visible prop can overload the browser and make the scene fragile.
## Stage Loading
Production gameplay content is mounted by:
```txt
src/world/GameStageContent.tsx
```
`World` wraps it in Rapier `Physics`, but only after `GameMap` reports loaded:
```tsx
{
showGameStage ? (
<Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
<GameStageContent />
</Physics>
) : null;
}
```
`GameStageLoaded` is a tiny component that calls `handleGameStageLoaded()` after mount. It gives the loading hook a clear signal that the Rapier stage has entered the scene graph.
## Player Spawn Gate
The player is spawned only when the active camera mode is not debug and the active scene is ready.
```ts
const spawnPlayer =
cameraMode !== "debug" &&
(sceneMode === "game" ? gameplayReady : octree !== null);
```
This avoids two common bugs:
- the player starts falling or clipping before collision is ready
- gameplay starts while the map/stage is still mounting
The production player spawn uses:
```txt
PLAYER_SPAWN_POSITION_GAME
```
The debug physics scene uses:
```txt
PLAYER_SPAWN_POSITION_PHYSICS
```
## Audio And Narrative Mounting
`GameMusic`, `GameDialogues`, and `Player` mount together after `spawnPlayer` is true.
This means background music and global dialogue timecode processing do not start while the loading overlay is still preparing the scene.
`GameCinematics` is currently gated further:
```tsx
{
mainState === "outro" ? <GameCinematics /> : null;
}
```
So cinematic playback is part of the outro path today, not a global always-on system.
## Debug Modes
Debug is enabled with:
```txt
http://localhost:5173/?debug
```
`src/utils/debug/Debug.ts` provides:
- camera mode: `player` or `debug`
- scene mode: `game` or `physics`
- R3F perf toggle
- debug overlay toggle
- hand-tracking source
- hand SVG visibility
- interaction sphere visibility
Important current detail: the older boot flags such as `noMusic`, `noCinematics`, `noMap`, `noDialogues`, `noOctree`, and `noPlayer` are not part of the current `develop` runtime path.
## Why This Architecture Works
The runtime uses React composition as the scene orchestration layer:
- if JSX is mounted, the Three/Rapier object exists
- if JSX is unmounted, the object leaves the scene
- loading gates are explicit booleans instead of hidden timing assumptions
This keeps the prototype understandable while still preventing expensive systems from starting too early.
## Risks And Watch Points
- Loading progress is manually estimated, not measured from every asset byte.
- The production collision source is currently only `terrain`; extra collision needs explicit lightweight nodes.
- Rapier gameplay physics and player octree collision are separate systems and can diverge if future features assume they are the same world.
- `GameCinematics` is not globally mounted anymore; docs or tests that expect intro cinematics to auto-run should be updated before relying on that path.
- Scene readiness is stored in React state, so remounting the route restarts the loading flow.
+22
View File
@@ -0,0 +1,22 @@
# Three Debugging
Use the dedicated debug mode when you need Chrome DevTools to step into Three.js internals.
```bash
npm run dev:three-debug
```
This mode aliases `three` to `node_modules/three/src/Three.js` and disables Vite dependency pre-bundling for Three. In DevTools, open `node_modules/three/src/renderers/WebGLRenderer.js` and place a breakpoint inside:
```js
this.render = function (scene, camera) {
```
Reload the page or trigger a frame. When the breakpoint hits, inspect `scene`, `camera`, renderer state, visible objects, matrices, materials, and `this.info.render`.
If DevTools still opens a bundled file, stop the dev server, clear Vite's cached deps, and restart:
```bash
rm -rf node_modules/.vite
npm run dev:three-debug
```
+141 -83
View File
@@ -1,72 +1,85 @@
# Zustand Game State # Zustand Stores
This document explains how Zustand is used in the current project. This document explains how Zustand is used in the current project.
## Why Zustand Exists Here ## Why Zustand Exists Here
The project needs one shared source of truth for the player's progression through the experience. The project needs shared state that is durable enough to be read by multiple React and React Three Fiber systems.
The current progression is split into main states: Zustand is used for:
- game progression
- settings
- subtitle display
It is not used for high-frequency frame values. Values such as player velocity, temporary vectors, object positions during a grab, raycasts, and animation-loop data stay in refs or manager-local state.
## Store Locations
Current Zustand stores:
```txt
src/managers/stores/useGameStore.ts
src/managers/stores/useSettingsStore.ts
src/managers/stores/useSubtitleStore.ts
```
They are under `src/managers/stores/` because they are shared runtime state, not state owned by one visual component.
## Store Responsibilities
| Store | Responsibility |
| ------------------ | ----------------------------------------------------------------- |
| `useGameStore` | Durable game progression, mission steps, cinematic input lock |
| `useSettingsStore` | Menu visibility, volumes, subtitle options, repair-runtime toggle |
| `useSubtitleStore` | Currently displayed subtitle cue |
## Managers vs Stores
Managers own imperative runtime objects and side effects.
Examples:
- `AudioManager` owns audio elements, music playback, sound pools, category volumes, and optional panner nodes.
- `InteractionManager` owns transient interaction handles and input-oriented focus/holding state.
Stores own durable shared state:
- current game phase
- mission sub-step
- progression flags
- settings values
- currently displayed subtitle cue
Rule of thumb:
- manager = runtime objects, side effects, frame-adjacent imperative logic
- store = shared state that UI, world, or gameplay components need to subscribe to
## Game Store Shape
`useGameStore` exposes the main game progression.
Main states:
| Main state | Role | | Main state | Role |
| ---------- | ------------------------------- | | ---------- | ------------------------------- |
| `intro` | Onboarding and opening sequence | | `intro` | Onboarding and opening sequence |
| `bike` | E-bike repair sequence | | `bike` | E-bike repair sequence |
| `pylone` | Power grid sequence | | `pylone` | Power pylon repair sequence |
| `ferme` | Vertical farm sequence | | `ferme` | Vertical farm repair sequence |
| `outro` | Ending sequence | | `outro` | Ending sequence |
Each main state can also own smaller sub state, such as the current mission step, dialogue audio, or completion flags. Other important state:
Zustand is useful because React and React Three Fiber components can subscribe only to the state slice they need. When that slice changes, only the subscribed components re-render. - `isCinematicPlaying`
- `intro`
- `bike`
- `pylone`
- `ferme`
- `outro`
## Store Location Mission steps:
The game progression store lives here:
```txt
src/managers/stores/useGameStore.ts
```
The store is placed under `src/managers/stores/` because it belongs to the gameplay orchestration layer, not to a specific visual component.
## Managers vs Store
Managers are responsible for local runtime objects and imperative behavior.
Examples:
- `AudioManager` owns audio elements and sound pools.
- `InteractionManager` owns transient interaction handles and input-oriented behavior.
Managers can read from or write to the Zustand store when their local behavior needs to affect global gameplay progression.
The Zustand store is responsible for durable global state:
- current main state
- mission sub state
- progression flags
- dialogue/audio references
- state transitions
Rule of thumb:
- manager = runtime objects, side effects, and local imperative logic
- store = global gameplay state that UI or world components can subscribe to
## Current Shape
The store exposes:
- `mainState`: the active game phase
- `intro`: intro-specific state
- `bike`: e-bike mission state
- `pylone`: power grid mission state
- `ferme`: farm mission state
- `outro`: ending state
- actions for direct updates and progression updates
The mission steps currently use this sequence:
```ts ```ts
"locked" | "locked" |
@@ -79,6 +92,8 @@ The mission steps currently use this sequence:
"done"; "done";
``` ```
`isCinematicPlaying` is read by `PlayerController` to ignore player input while camera timelines are active.
## Reading State In Components ## Reading State In Components
Use selectors to read only what the component needs. Use selectors to read only what the component needs.
@@ -95,7 +110,7 @@ export function Example(): React.JSX.Element {
This is better than reading the whole store, because the component re-renders only when `mainState` changes. This is better than reading the whole store, because the component re-renders only when `mainState` changes.
## Updating State ## Updating Game State
Prefer explicit actions from the store. Prefer explicit actions from the store.
@@ -113,9 +128,15 @@ const setMainState = useGameStore((state) => state.setMainState);
setMainState("bike"); setMainState("bike");
``` ```
Direct setters are useful for debug panels, but production gameplay should prefer business actions such as `advanceGameState`, `completeBike`, or `completePylone`. Direct setters are useful for debug panels, but production gameplay should prefer business actions such as:
Mission gameplay that can target `bike`, `pylone`, or `ferme` should prefer the generic mission actions: - `advanceGameState`
- `completeBike`
- `completePylone`
- `completeFerme`
- `completeMission`
Mission gameplay that can target `bike`, `pylone`, or `ferme` should prefer generic mission actions:
```ts ```ts
const setMissionStep = useGameStore((state) => state.setMissionStep); const setMissionStep = useGameStore((state) => state.setMissionStep);
@@ -125,40 +146,71 @@ setMissionStep("bike", "inspected");
completeMission("bike"); completeMission("bike");
``` ```
This keeps reusable gameplay components such as repair flows from duplicating mission-specific branches like `setBikeState`, `setPyloneState`, and `setFermeState`. This keeps reusable gameplay components such as `RepairGame` from duplicating mission-specific branches like `setBikeState`, `setPyloneState`, and `setFermeState`.
## Settings Store
`useSettingsStore` owns player-facing settings and forwards audio volume changes to `AudioManager`.
State:
- `isSettingsMenuOpen`
- `musicVolume`
- `sfxVolume`
- `dialogueVolume`
- `subtitlesEnabled`
- `subtitleLanguage`
- `repairRuntime`
Audio setters clamp values between `0` and `1`, then call:
```ts
AudioManager.getInstance().setCategoryVolume(category, nextVolume);
```
This keeps UI state and browser audio state synchronized.
Current caveat: `repairRuntime` is stored and displayed in the settings menu, but the repair game does not consume it yet. Treat it as a staged architecture hook rather than an active runtime switch.
## Subtitle Store
`useSubtitleStore` is intentionally tiny.
State/actions:
- `activeSubtitle`
- `setActiveSubtitle`
- `clearActiveSubtitle`
`playDialogueById()` writes to this store while dialogue audio plays. `Subtitles` reads from it and respects `useSettingsStore().subtitlesEnabled`.
## World Integration ## World Integration
`src/world/GameStageContent.tsx` subscribes to `mainState` and mounts stage-specific content. `src/world/GameStageContent.tsx` subscribes to `mainState` and mounts the repair-game content.
For repair missions, it mounts the reusable `RepairGame` component with a mission id: Current production repair placement:
```tsx ```tsx
<RepairGame mission="bike" position={[8, 0, -6]} /> <RepairGame mission="bike" position={[8, 0, -6]} />
<RepairGame mission="pylone" position={[64, 0, -66]} />
<RepairGame mission="ferme" position={[-24, 0, 42]} />
``` ```
`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep` and `completeMission`. Shared repair ids, mission steps, and runtime guards live in `src/types/gameplay/repairMission.ts` so static mission config does not depend on the Zustand store. The production repair flow currently supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` state transitions. `RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep` and `completeMission`.
Mission-specific behavior stays in `src/data/gameplay/repairMissions.ts`: each mission can define its broken nodes, placeholder targets, scan duration, and reassembly duration without adding mission branches to `RepairGame`. Shared repair ids, mission steps, and runtime guards live in:
That means the scene can progressively move toward this pattern: ```txt
src/types/gameplay/repairMission.ts
```tsx
switch (mainState) {
case "intro":
return <IntroContent />;
case "bike":
return <BikeContent />;
case "pylone":
return <PyloneContent />;
case "ferme":
return <FarmContent />;
case "outro":
return <OutroContent />;
}
``` ```
In React Three Fiber, mounting and unmounting JSX controls what appears in the Three.js scene. When a state-specific component disappears from JSX, React removes it from the scene. Mission-specific behavior stays in:
```txt
src/data/gameplay/repairMissions.ts
```
That lets the repair flow stay reusable while each mission defines its own model, broken parts, replacement parts, prompts, and timing.
## UI Integration ## UI Integration
@@ -166,13 +218,16 @@ In React Three Fiber, mounting and unmounting JSX controls what appears in the T
Current overlays: Current overlays:
- `DebugOverlayLayout`: debug-only overlay shown with `?debug`, including the `GameStateDebugPanel` progression panel - `DebugOverlayLayout`: debug-only overlay shown with `?debug`
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states, stepping backward or forward, and resetting the store - `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states
- `Crosshair`: player aiming helper - `Crosshair`: player aiming helper
- `InteractPrompt`: interaction prompt - `InteractPrompt`: interaction prompt
- `RepairMovementLockIndicator`: player-facing indicator shown while repair steps temporarily disable movement - `RepairMovementLockIndicator`: indicator intended for repair movement lock
- `HandTrackingVisualizer`: hand tracking SVG fallback/debug visualization
- `Subtitles`: active dialogue subtitle overlay
- `GameSettingsMenu`: options menu and settings controls
`src/pages/page.tsx` should stay thin and mount only the canvas and `GameUI`. Current caveat: `useRepairMovementLocked()` returns `false` immediately on the current branch, so the movement-lock rule and indicator exist but are disabled at runtime.
## Regression Rules ## Regression Rules
@@ -182,7 +237,10 @@ Current overlays:
- Keep gameplay transitions inside store actions when possible. - Keep gameplay transitions inside store actions when possible.
- Keep debug-only controls behind `?debug`. - Keep debug-only controls behind `?debug`.
- Add new state only when a real runtime feature needs it. - Add new state only when a real runtime feature needs it.
- Keep settings side effects, such as audio category updates, inside settings actions rather than spreading them across UI components.
## Next Steps ## Next Steps
Move repair validation into mission data once each mission has distinct broken module nodes, replacement assets, and completion events. - Decide whether `repairRuntime` should be removed, implemented, or clearly labeled as experimental.
- Re-enable or remove the repair movement-lock rule depending on desired gameplay.
- Move broader mission orchestration into a clearer layer if intro, mission, dialogue, and cinematic branching grows.
+167 -6
View File
@@ -1,12 +1,18 @@
# Editor User Guide # Editor User Guide
The map editor is available at `/editor`. It is a browser-based tool for inspecting and adjusting the objects listed in `public/map.json`. The map editor is available at `/editor`. It is a browser-based tool for editing the runtime map, cinematic manifest, dialogue manifest, and SRT subtitle files without manually jumping between JSON and subtitle files.
## Purpose ## Purpose
Use the editor when you need to move, rotate, or scale existing map objects without editing JSON by hand. Use the editor when you need to:
The editor reads the same map data as the runtime scene: - move, rotate, or scale objects from `public/map.json`
- inspect the raw JSON generated by the editor
- preview and edit cinematics from `public/cinematics.json`
- create, preview, and validate dialogue entries from `public/sounds/dialogue/dialogues.json`
- edit FR/EN SRT subtitle files per voice
The map editor reads the same map data as the runtime scene:
- `public/map.json` contains the object list. - `public/map.json` contains the object list.
- `public/models/{name}/model.glb` contains the matching 3D model for each object name. `model.gltf` is still supported as a fallback during migration. - `public/models/{name}/model.glb` contains the matching 3D model for each object name. `model.gltf` is still supported as a fallback during migration.
@@ -24,7 +30,18 @@ Each entry in `public/map.json` represents one object:
| `rotation` | Object rotation as `[x, y, z]`, expressed radians | | `rotation` | Object rotation as `[x, y, z]`, expressed radians |
| `scale` | Object scale as `[x, y, z]` | | `scale` | Object scale as `[x, y, z]` |
## Editing Workflow ## Panel Layout
The right panel is split into dropdown groups:
- `Editor`: map transform tools, shortcuts, selection, view mode, JSON preview, and file actions.
- `Cinematics`: editor for `public/cinematics.json`.
- `Dialogues`: editor for `public/sounds/dialogue/dialogues.json`.
- `SRT`: editor for subtitle files in `public/sounds/dialogue/subtitles/`.
Only the `Editor` group is open by default. Open the other groups when you need audio or cinematic tooling.
## Map Editing Workflow
1. Open `/editor` in the local app. 1. Open `/editor` in the local app.
2. Click an object in the scene to select it. 2. Click an object in the scene to select it.
@@ -40,6 +57,8 @@ Each entry in `public/map.json` represents one object:
| -------------------- | -------------------------- | | -------------------- | -------------------------- |
| Select object | Click object | | Select object | Click object |
| Deselect | `Esc` or click empty space | | Deselect | `Esc` or click empty space |
| Lock selection | `Lock` button in Selection |
| Clear selection | `X` button in Selection |
| Translate mode | `T` | | Translate mode | `T` |
| Rotate mode | `R` | | Rotate mode | `R` |
| Scale mode | `S` | | Scale mode | `S` |
@@ -49,18 +68,34 @@ Each entry in `public/map.json` represents one object:
| Move up | `Space` | | Move up | `Space` |
| Move down | `Shift` | | Move down | `Shift` |
## Selection
The `Selection` section shows the selected object name and its index in `public/map.json`.
- Click an object to select it.
- Click empty space or press `Esc` to clear the selection.
- Use the `X` button to clear the selection explicitly.
- Use the `Lock` button to protect the current selection while editing.
When selection is locked:
- clicking another object does not change the selection
- clicking empty space does not clear the selection
- pressing `Esc` does not clear the selection
- the `X` button still clears the selection intentionally
## View Mode ## View Mode
The `Lock view` action switches the editor into a movement mode closer to the runtime player camera. Use it to navigate larger scenes while keeping the transform tools available. The `Lock view` action switches the editor into a movement mode closer to the runtime player camera. Use it to navigate larger scenes while keeping the transform tools available.
## JSON Inspector ## JSON Inspector
The side panel includes a raw JSON inspector: The `JSON` section shows the raw map data that will be exported or saved:
- When no object is selected, it shows the full map node list. - When no object is selected, it shows the full map node list.
- When an object is selected, it highlights the JSON lines for that object. - When an object is selected, it highlights the JSON lines for that object.
This is useful for checking numeric transform values before saving or exporting. Use it to verify exact numeric transform values before saving or exporting. The JSON inspector is read-only; transform values are changed through the gizmo in the scene.
## Saving Changes ## Saving Changes
@@ -74,6 +109,130 @@ This is useful for checking numeric transform values before saving or exporting.
The button is hidden in production builds because production persistence is not implemented. The button is hidden in production builds because production persistence is not implemented.
## Editing Dialogue Subtitles
The side panel includes two separate audio text tools:
- `Dialogues` edits the dialogue manifest, which links dialogue IDs to audio files and SRT cue indexes.
- `SRT` edits the actual subtitle text and cue timings.
The important model is: one dialogue entry points to one cue inside one SRT file. The SRT file is grouped by voice and language, not by dialogue.
### Dialogue Manifest
Use the `Dialogues` panel to edit `public/sounds/dialogue/dialogues.json` without opening the JSON file manually.
Each dialogue entry contains:
| Field | Meaning |
| ------------------ | ----------------------------------------------------------------- |
| `id` | Unique dialogue ID used by cinematics and runtime triggers |
| `voice` | Voice file group: `narrateur`, `fermier`, or `electricienne` |
| `audio` | Runtime audio path, usually under `/sounds/dialogue/` |
| `subtitleCueIndex` | Cue number inside the selected voice/language SRT file |
| `timecode` | Optional global runtime trigger time, in seconds from scene start |
Available actions:
- `Reload` reloads the manifest from disk.
- `Add` creates a local dialogue entry for the current voice and assigns the next available SRT cue index.
- `Save` writes the manifest through the local Vite dev server.
- `Preview dialogue` plays the selected dialogue and shows subtitles in the editor overlay.
- `Create FR SRT cue` creates the matching French SRT cue if it is missing.
- `Delete dialogue` removes the selected entry locally.
After using `Add`, save the manifest to keep the new dialogue entry. The generated SRT cue is written immediately to the French SRT file, but the dialogue manifest is still only local until `Save` is clicked.
New dialogue audio paths start as placeholders such as `/sounds/dialogue/new_dialogue_24.mp3`. Replace them with real MP3 paths before validating the final asset set.
Recommended workflow for a new dialogue:
1. Open `Dialogues`.
2. Click `Add`.
3. Choose the correct `voice`.
4. Replace the generated `id` with a readable stable ID.
5. Replace the placeholder `audio` path with the real MP3 path.
6. Check the generated `subtitleCueIndex`.
7. Click `Create FR SRT cue` if the cue does not exist yet.
8. Click `Save`.
9. Open `SRT`, edit the cue text and timings, then save the SRT file.
10. Run `Validate` from the SRT panel.
### SRT Editor
Use the `SRT` panel to edit one subtitle file at a time.
1. Choose a voice: `narrateur`, `fermier`, or `electricienne`.
2. Choose a language: `FR` or `EN`.
3. Edit the SRT text directly in the textarea.
4. Use the audio preview to check the selected dialogue.
5. Use `Set start`, `Set end`, `-100ms`, and `+100ms` to adjust the selected cue timing against the audio.
6. Use `Save SRT` during local development, or `Export SRT` to download the file manually.
Each SRT file belongs to one voice, not one dialogue. Cue indexes must match the `subtitleCueIndex` values referenced by the dialogue manifest.
SRT timings are relative to the dialogue audio file, not to the global game timeline and not to the cinematic timeline. For example, `00:00:01,000` means one second after that dialogue audio starts.
## Validating Dialogue Assets
Use `Validate` in the SRT panel to check the dialogue manifest and linked assets.
The validation checks:
- `public/sounds/dialogue/dialogues.json`
- referenced dialogue audio files
- French SRT files
- subtitle cue indexes referenced by the manifest
Missing English SRT files are warnings, not errors, because the runtime falls back to French subtitles. This is intentional until the English translation workflow is ready.
## Editing Cinematics
Use the `Cinematics` panel to edit `public/cinematics.json`.
Each cinematic contains:
- an `id`
- an optional global `timecode`
- two or more camera keyframes
- optional dialogue cues synchronized to the cinematic timeline
Camera keyframes define:
- `time`: seconds relative to the cinematic start
- `position`: camera position `[x, y, z]`
- `target`: point the camera looks at `[x, y, z]`
Dialogue cues define:
- `time`: seconds relative to the cinematic start
- `dialogueId`: an entry from `public/sounds/dialogue/dialogues.json`
Recommended workflow for a cinematic:
1. Open `Cinematics`.
2. Select an existing cinematic or click `Add`.
3. Set a stable `id`.
4. Add or adjust camera keyframes.
5. Keep keyframe `time` values increasing from start to end.
6. Add dialogue cues when a dialogue must start during the camera sequence.
7. Click `Preview cinematic` to test the camera path in the editor canvas.
8. Click `Save` when the manifest is correct.
Available actions:
- `Reload` reloads the cinematic manifest from disk.
- `Add` creates a new local cinematic with two camera keyframes.
- `Save` writes `public/cinematics.json` through the local Vite dev server.
- `Preview cinematic` plays the selected camera animation in the editor canvas.
- `Add keyframe` and `Remove` edit the camera path.
- `Add dialogue` and `Remove` edit dialogue cues linked to the cinematic.
- `Delete cinematic` removes the selected cinematic locally.
Cinematic dialogue cues are the preferred way to synchronize a dialogue with a cinematic. Avoid also giving the same dialogue a global `timecode`, or it can be triggered twice.
Use `dialogueCues` when the dialogue belongs to a cinematic. Use a dialogue `timecode` only for simple global scene timing outside a cinematic.
## Current Limitations ## Current Limitations
- The editor only modifies existing nodes. - The editor only modifies existing nodes.
@@ -81,3 +240,5 @@ The button is hidden in production builds because production persistence is not
- It does not edit model files or textures. - It does not edit model files or textures.
- It does not provide production persistence. - It does not provide production persistence.
- Fallback cubes indicate missing models; they are editor placeholders, not exported assets. - Fallback cubes indicate missing models; they are editor placeholders, not exported assets.
- SRT saving is a local Vite dev-server helper, not a production backend feature.
- Dialogue and cinematic saves are local Vite dev-server helpers, not production backend features.
+220 -52
View File
@@ -1,76 +1,244 @@
# Implemented Features # Implemented Features
This document lists features that are implemented in the current codebase. This document lists the user-visible and developer-facing features implemented in the current `develop` branch.
## Scene ## Application And Routes
- Fullscreen React Three Fiber scene - React 19 application bootstrapped by Vite and TypeScript
- Main map scene loaded from `public/map.json` and matching `public/models/{name}/model.glb` or `model.gltf` assets - TanStack Router route tree
- Minimal fullscreen scene loading overlay for 3D scenes, with a global progress bar used by the production map, debug physics scene, and editor scene - `/` playable 3D experience
- Debug physics test scene selectable from the debug panel, including grab/trigger tests, an animated model preview, and separate repair playground zones for `bike`, `pylone`, and `ferme` - `/editor` local content editor
- Rapier physics context available for production stage gameplay objects - `/docs` in-app documentation browser
- Ambient and directional lighting - Lazy-loaded docs pages rendered from repository Markdown files
- Environment background setup
## 3D World
- Fullscreen React Three Fiber canvas
- Production world composition in `src/world/World.tsx`
- Environment model/background through `Environment` and `SkyModel`
- Shared lighting setup
- Production map loaded from `public/map.json`
- Model resolution from `public/models/{name}/model.glb`, then `model.gltf`
- Fallback cubes when a map node has no available model
- Progressive scene loading overlay for game, debug physics scene, and editor
- Stabilized game scene loading gates for map, model, collision, octree, and gameplay stage readiness
- Game stage content mounted only after the map has loaded
- Player, music, dialogues, and gameplay-dependent systems mounted only after gameplay is ready
## Player ## Player
- Player camera mode - Player camera mode
- Pointer lock mouse look - Pointer-lock mouse look
- Movement with `ZQSD` - `ZQSD` movement
- Jumping - Jump with `Space`
- Movement lock during active repair steps, with an on-screen indicator while keeping trigger interactions available - Trigger interaction with `E`
- Octree-based collision against dedicated map collision nodes, currently scoped to `terrain` - Grab interaction with primary mouse button
- Spawn reset based on scene mode
- Input lock while the settings menu is open
- Input lock while a cinematic is playing
- Octree collision against dedicated map collision nodes, currently scoped to the `terrain` node
- Repair movement-lock hook and indicator exist, but the hook currently returns `false`, so movement is not locked during repair on the current branch
## Interactions ## Physics And Collision
- Focus detection by distance and raycast - Separate collision responsibility between player and gameplay objects
- Trigger interactions activated with `E` - Player collision uses a Three.js capsule plus octree
- Grab interactions activated with the primary mouse button - Gameplay objects use Rapier rigid bodies and colliders
- Physics-backed gameplay objects can be mounted inside stage content without replacing player octree collision - Production `GameStageContent` is mounted inside a Rapier `Physics` provider
- Interaction prompt shown for trigger interactions - Debug physics scene owns its own Rapier playground
- Map collision octree is built from explicit collision nodes instead of the full visible map
## Interaction System
- Shared `InteractionManager` singleton for focused object, nearby object, holding state, and hand-holding state
- React subscription through `useSyncExternalStore`
- Distance and camera-ray focus detection in `InteractableObject`
- Trigger interactions through `TriggerObject`
- Grab interactions through `GrabbableObject`
- Trigger prompt shown by `InteractPrompt`
- Optional trigger SFX and optional spawned model support
- Debug interaction sphere visibility through the `Interaction` lil-gui folder
- Hand-controlled grab support for grabbable objects
- Snap-to-target behavior after releasing grabbable objects
## Repair Gameplay ## Repair Gameplay
- Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states - Reusable `RepairGame` mounted for `bike`, `pylone`, and `ferme`
- Debug physics playground mounts the same reusable `RepairGame` in `Bike`, `Pylone`, and `Farm` zones so each state can be tuned with isolated positioning before moving placement into the production map - Mission progression driven by Zustand and shared `MissionStep` types
- Repair mission config shared through `src/data/gameplay/repairMissions.ts`, including per-mission broken nodes, placeholder targets, scan timing, and reassembly timing - Production repair positions:
- Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` with `.webm` prompts, repair case spawn/opening/exit, focused repair-case view, movement lock indicator during active repair, repair-case trigger interaction, case placeholder traversal, snap-to-placeholder placement, broken-part deposit feedback, `E`, two-fists hold input, exploded and inverse reassembly transitions, completion particles, per-part scan visuals, persistent red broken-part markers, centered broken-part UI videos, multiple grabbable replacement choices, correct-part install validation feedback, and mission completion - `bike` at `[8, 0, -6]`
- `pylone` at `[64, 0, -66]`
- `ferme` at `[-24, 0, 42]`
- Debug physics repair playground zones for all three missions
- Data-driven mission config in `src/data/gameplay/repairMissions.ts`
- Mission flow: `locked -> waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done`
- `.webm` 3D prompts for mission object, interaction, and broken parts
- Repair object inspection
- Repair case spawn, pop animation, proximity float, wobble, open/close lid animation, exit animation, and open/close sounds
- Repair case placeholder traversal from GLTF nodes named `placeholder_*`
- Fallback placeholder positions when a case asset has no placeholder nodes
- Fragmentation through repair-case trigger or two-fists hand gesture
- Exploded model visualization through `ExplodableModel`
- Scan visual that steps through exploded parts
- Broken-part detection by configured `nodeName`, with fallback to first scanned parts
- Persistent broken-part highlight and broken-part prompt after discovery
- Grabbable replacement part choices, including decoys
- Grabbable broken parts that must be deposited back into the case
- Snap-to-placeholder placement
- Correct-part, wrong-part, and stored-part visual feedback
- Blocked install feedback when validation is attempted too early
- Install target that validates only when the correct replacement is placed and all broken parts are stored
- Inverse reassembly animation
- Completion particles
- Completion target that closes/exits the repair case before calling `completeMission`
## Game Progression Store
- Zustand `useGameStore` for durable gameplay progression
- Main states: `intro`, `bike`, `pylone`, `ferme`, `outro`
- Per-mission repair step state
- Per-mission completion flags
- Generic mission helpers: `setMissionStep`, `completeMission`, `advanceGameState`, `rewindGameState`, `resetGame`
- `isCinematicPlaying` flag used by the player input lock
- Debug game-state panel that can jump between main states and sub-states
## Settings And UI Overlays
- `Esc` opens and closes the settings menu
- Music, SFX, and dialogue volume sliders
- Subtitle visibility toggle
- Subtitle language choice between French and English
- Repair-runtime choice between JavaScript and Python modes stored in settings
- Quit action that clears browser-accessible cookies and returns to `/`
- Crosshair overlay
- Interaction prompt
- Subtitle overlay
- Repair movement-lock indicator component, currently inactive because the lock hook returns `false`
- Debug overlay layout
- Scene loading overlay
## Audio ## Audio
- One-shot sound playback for trigger interactions - Singleton `AudioManager`
- Simple per-sound pooling through `AudioManager` - Looped music playback
- One-shot SFX/dialogue playback
- Per-path one-shot audio pools
- Category volumes for `music`, `sfx`, and `dialogue`
- Optional stereo panning for one-shot sounds
- Playback-rate option for one-shot sounds
- Browser autoplay fallback for music: retry after user `pointerdown` or `keydown`
- Game music mounted through `GameMusic`
- Current game music path: `/sounds/musique/test.mp3`
- Current base music volume: `0.33`
- Repair case open/close sounds
- Trigger-object SFX support
## Dialogue And Subtitles
- Runtime dialogue manifest in `public/sounds/dialogue/dialogues.json`
- Dialogue audio under `public/sounds/dialogue/`
- One SRT file per voice and language
- French and English subtitle folders
- Runtime SRT parsing
- Subtitle cue lookup by voice, selected language, and `subtitleCueIndex`
- French fallback when the selected language file is unavailable
- Dialogue playback through the `dialogue` audio category
- Runtime subtitle synchronization from audio `timeupdate`
- Speaker-aware subtitle overlay
- Dialogue queueing to avoid overlapping dialogue playback
- Global timecode dialogue triggering through `GameDialogues`
## Cinematics
- Runtime cinematic manifest in `public/cinematics.json`
- Cinematic manifest validation
- GSAP camera keyframe playback
- Camera position and look target interpolation
- Optional dialogue cues relative to cinematic start time
- Player input lock while a cinematic is active
- Current world integration only mounts `GameCinematics` during `mainState === "outro"`
## Hand Tracking
- Optional webcam hand tracking provider around the playable scene
- Source switch in debug GUI: local Python backend or browser-side MediaPipe
- Backend WebSocket endpoint at `ws://localhost:8000/ws`
- Backend health endpoint at `http://localhost:8000/health`
- Browser-side MediaPipe through `@mediapipe/tasks-vision`
- Lazy activation so camera/tracking is not always active
- Production activation during repair steps that need hand input
- Debug activation in physics mode while near, holding, or hand-holding interactions
- Hand snapshot context for R3F and UI consumers
- Fist detection
- Two-fists hold gesture for repair fragmentation
- Hand grab support for `GrabbableObject`
- Hand-tracking debug panel with status, source/server state, hand count, fist state, and glove model status
- SVG hand visualizer fallback
- `gant_l` and `gant_r` R3F glove overlays when tracking is active
## Debug Tooling ## Debug Tooling
- `?debug` query param enables the debug panel - `?debug` query param enables debug systems
- `lil-gui` controls for camera mode, scene mode, `R3F Perf`, `Debug Overlay`, and interaction tuning - `lil-gui` root debug folder
- Compact debug overlay for game state controls and hand tracking status - Camera mode switch between player and debug camera
- Debug game-state mission switching unlocks locked repair missions at `waiting` for faster testing - Scene mode switch between production game and physics test scene
- Debug scene helpers - R3F perf toggle
- Free debug camera - Debug overlay toggle
- `r3f-perf` overlay - Hand-tracking source switch
- Interaction sphere debug toggle
- Grabbable tuning controls for stiffness, throw boost, and hold distance
- Debug helpers: grid and axes
- Debug camera controls
- Debug game-state panel
- Debug hand-tracking panel
- Physics test scene with floor, grabbable object, trigger object, repair zones, and animated model preview
- Animated `electricienne_animated` model preview restored in the debug physics scene
## Map Editor ## Map And Content Editor
- `/editor` route for inspecting and editing `public/map.json` - `/editor` route
- Automatic loading of `public/map.json` when available - Automatic loading of `public/map.json`
- Folder upload fallback when `map.json` is missing - Folder upload fallback when map data is not available
- Rendering of available `public/models/{name}/model.glb` or `model.gltf` assets - Shared `MapNode` format with runtime map loading
- Fallback cubes for nodes whose model is missing - Render available map models
- Fallback cubes for missing models
- Object selection by click - Object selection by click
- Transform modes for translate, rotate, and scale - Transform modes: translate, rotate, scale
- Keyboard shortcuts for `T`, `R`, `S`, `Esc`, undo, and redo - Transform keyboard shortcuts: `T`, `R`, `S`
- Player-style navigation mode with `WASD`, `ZQSD`, arrow keys, `Space`, and `Shift` - Selection lock
- JSON export for downloading the edited map - Explicit selection clear
- Dev-server save endpoint for writing changes back to `public/map.json` - Undo and redo
- Player-style editor navigation with `WASD`, `ZQSD`, arrows, `Space`, and `Shift`
- JSON inspector
- JSON export
- Dev-server save endpoint for `public/map.json`
- Dialogue manifest editor
- SRT subtitle editor
- Audio preview and cue timing helpers
- French SRT cue creation helper
- Dialogue asset validation endpoint
- Cinematic manifest editor
- Cinematic camera keyframe editor
- Cinematic dialogue cue editor
- Cinematic preview in the editor canvas
- Dev-server endpoints for dialogue, SRT, and cinematic saves
## Not Implemented Yet ## In-App Documentation
- complete mission system - `/docs` documentation layout
- zone system - Markdown rendered through `react-markdown` and `remark-gfm`
- cinematic system - Technical docs for architecture, scene runtime, repair game, interaction, editor, audio, hand tracking, Zustand, Three debugging, animation, and target architecture
- dialogue system - User docs for implemented features, main feature, editor usage, and code-review preparation
- minimap and mission HUD
- full production separation between gameplay and debug scenes ## Not Implemented Or Incomplete
- production backend persistence for editor saves
- Complete production mission manager/orchestrator
- Full mission HUD or minimap
- Full zone system
- Dialogue branching
- Production persistence for editor saves
- Production backend for repair-game runtime selection
- Production save/load of player progression
- Full migration of player movement to Rapier
- Advanced hand smoothing and calibrated glove finger animation
- Snap-to-grid, object creation, object deletion, material editing, or model editing in the map editor
+9 -6
View File
@@ -12,7 +12,7 @@ The current user flow is:
2. Move close to the active repair object in the game scene. 2. Move close to the active repair object in the game scene.
3. Aim at the object and press the interaction key when prompted. 3. Aim at the object and press the interaction key when prompted.
4. The mission step moves from `waiting` to `inspected`. 4. The mission step moves from `waiting` to `inspected`.
5. The repair case appears near the mission object, the player movement controls are locked, and the case can float when the player approaches it. 5. The repair case appears near the mission object and can float when the player approaches it. A repair movement-lock rule exists in code, but it is currently disabled by the hook on `develop`.
6. Aim at the repair case and press `E`, or hold both fists closed for one second, to move from `inspected` to `fragmented`. 6. Aim at the repair case and press `E`, or hold both fists closed for one second, to move from `inspected` to `fragmented`.
7. The mission object uses an exploded-model transition, then moves to `scanning`. 7. The mission object uses an exploded-model transition, then moves to `scanning`.
8. The scan visual moves across the fragmented model one part at a time and keeps a red marker plus the `cassé.webm` prompt centered on any configured broken part once it has been found. 8. The scan visual moves across the fragmented model one part at a time and keeps a red marker plus the `cassé.webm` prompt centered on any configured broken part once it has been found.
@@ -20,22 +20,24 @@ The current user flow is:
10. Move the correct replacement part close to a placeholder. When released near a placeholder, it snaps into place with a short animation. 10. Move the correct replacement part close to a placeholder. When released near a placeholder, it snaps into place with a short animation.
11. Move each scanned broken part into a compatible placeholder so the damaged parts are stored in the case. 11. Move each scanned broken part into a compatible placeholder so the damaged parts are stored in the case.
12. Press `E` on the green install target to move to `reassembling`. Wrong parts turn the target red and cannot finish the repair. 12. Press `E` on the green install target to move to `reassembling`. Wrong parts turn the target red and cannot finish the repair.
13. The exploded object animates back into its assembled form with completion particles, then moves to `done` and restores player movement controls. 13. The exploded object animates back into its assembled form with completion particles, then moves to `done`.
14. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`. 14. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`.
## Why It Matters ## Why It Matters
This feature validates the repair loop before a full mission system exists. It tests whether repair objects, physical proximity, model selection, audio feedback, and exploded model visualization can work together in the 3D scene. This feature validates the repair loop before a full mission system exists. It tests whether repair objects, physical proximity, model selection, audio feedback, and exploded model visualization can work together in the 3D scene.
For implementation details, see `docs/technical/repair-game.md`.
## Current Behavior ## Current Behavior
In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt. In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt.
When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation, player movement is locked while the repair sequence is active, and a small HTML indicator confirms that movement is temporarily unavailable. When the player is close enough, the existing case model floats upward and rotates gently to signal interactivity. When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation. When the player is close enough, the case model floats upward and rotates gently to signal interactivity. The codebase also contains a shared repair movement-lock hook and HTML indicator, but `useRepairMovementLocked()` currently returns `false`, so movement remains available during the repair flow on the current branch.
In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes through the shared focus/raycast interaction system on the repair case, so the player must be close enough and aim at the case before pressing `E`. The hand-tracking path still uses a two-fists hold gesture and is state-based, so it does not depend on being inside a local object interaction radius. In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes through the shared focus/raycast interaction system on the repair case, so the player must be close enough and aim at the case before pressing `E`. The hand-tracking path still uses a two-fists hold gesture and is state-based, so it does not depend on being inside a local object interaction radius.
In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those placeholder positions, and releasing a part near a placeholder snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible placeholder before the final install target validates. If `brokenParts[].placeholderName` is configured, that broken part snaps only to the matching placeholder; otherwise it can use any available placeholder. If the current case asset has no placeholder nodes, the flow keeps using fallback focus positions. Replacement parts show green or red placement feedback after snapping, broken parts show stored feedback after deposit, and the install target gives a short blocked feedback if the player tries to validate too early. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. Player movement stays locked through `inspected`, `fragmented`, `scanning`, `repairing`, and `reassembling`, while trigger interactions remain available. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, player movement is available again and the repaired object remains visible with a completion target; validating closes the repair case first, then plays the case exit animation before advancing the global mission progression. In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those placeholder positions, and releasing a part near a placeholder snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible placeholder before the final install target validates. If `brokenParts[].placeholderName` is configured, that broken part snaps only to the matching placeholder; otherwise it can use any available placeholder. If the current case asset has no placeholder nodes, the flow keeps using fallback focus positions. Replacement parts show green or red placement feedback after snapping, broken parts show stored feedback after deposit, and the install target gives a short blocked feedback if the player tries to validate too early. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, the repaired object remains visible with a completion target; validating closes the repair case first, then plays the case exit animation before advancing the global mission progression.
The mission config now carries the mission-specific variations. `bike` repairs one cooling core, `pylone` scans and stores both the lamp relay and a damaged panel with slower scan/reassembly timing, and `ferme` scans and stores an irrigation pump plus humidity sensor with faster scan/reassembly timing. The mission config now carries the mission-specific variations. `bike` repairs one cooling core, `pylone` scans and stores both the lamp relay and a damaged panel with slower scan/reassembly timing, and `ferme` scans and stores an irrigation pump plus humidity sensor with faster scan/reassembly timing.
@@ -54,10 +56,10 @@ The mission config now carries the mission-specific variations. `bike` repairs o
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene. - `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
- `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part. - `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part. - `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
- `src/components/ui/RepairMovementLockIndicator.tsx` renders the HTML indicator shown while repair movement is locked. - `src/components/ui/RepairMovementLockIndicator.tsx` renders the HTML indicator intended for repair movement lock.
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` two-fists input and can optionally bind keyboard input for non-trigger flows. - `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` two-fists input and can optionally bind keyboard input for non-trigger flows.
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store. - `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
- `src/hooks/gameplay/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator. - `src/hooks/gameplay/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator, but currently returns `false`.
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture. - `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them. - `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization. - `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
@@ -106,5 +108,6 @@ python -m backend.main
- The reusable production `RepairGame` currently covers `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission`. - The reusable production `RepairGame` currently covers `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission`.
- Mission progression is wired through Zustand using `completeMission` at the end of each repair. - Mission progression is wired through Zustand using `completeMission` at the end of each repair.
- There is no central `GameManager` in this branch. - There is no central `GameManager` in this branch.
- Repair movement lock is currently disabled by `useRepairMovementLocked()`.
- Hand tracking is available for the two-fists input and grabbable repair parts; case interaction and final installation still use the shared `E` trigger path. - Hand tracking is available for the two-fists input and grabbable repair parts; case interaction and final installation still use the shared `E` trigger path.
- The repair-game content is configured statically in `src/data/gameplay/`. - The repair-game content is configured statically in `src/data/gameplay/`.
+257 -230
View File
@@ -12,7 +12,7 @@
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1", "@react-three/fiber": "^9.6.1",
"@react-three/rapier": "^2.2.0", "@react-three/rapier": "^2.2.0",
"@tanstack/react-router": "^1.168.25", "@tanstack/react-router": "^1.169.2",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lil-gui": "^0.21.0", "lil-gui": "^0.21.0",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
@@ -61,9 +61,9 @@
} }
}, },
"node_modules/@babel/compat-data": { "node_modules/@babel/compat-data": {
"version": "7.29.0", "version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -222,9 +222,9 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.29.2", "version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -294,6 +294,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.19.2.tgz",
"integrity": "sha512-AZHL1jqUF55QJkJyU1yKeh4ImX2J93bVLIezT1+o0FZqTix6O06MOaqpKoJ4MmbDCsoZmwO+qc471/SDMDm2AA==",
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -639,9 +645,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.127.0", "version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -779,16 +785,10 @@
"three": ">=0.159.0" "three": ">=0.159.0"
} }
}, },
"node_modules/@react-three/rapier/node_modules/@dimforge/rapier3d-compat": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.19.1.tgz",
"integrity": "sha512-xvFNtb/9xILxfvdFOa7NCnYUEF6cfn51R44C1xnKXtk5DpyAARqsC4sxZwiJAHRSzYT5FFe889t36iFnzb3vxg==",
"license": "Apache-2.0"
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -803,9 +803,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -820,9 +820,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -837,9 +837,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -854,9 +854,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -871,13 +871,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -888,13 +891,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -905,13 +911,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -922,13 +931,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -939,13 +951,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -956,13 +971,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -973,9 +991,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -990,9 +1008,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -1009,9 +1027,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1026,9 +1044,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1072,14 +1090,14 @@
} }
}, },
"node_modules/@tanstack/react-router": { "node_modules/@tanstack/react-router": {
"version": "1.168.25", "version": "1.169.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.25.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.169.2.tgz",
"integrity": "sha512-4U/E76dc+fYuLixjV1RLNfqrkQoexSL8MqGNpIHOodtvY3fMPGaALrvDVtBDQYBEU4z5r5fHaV6+kclWAVFP9A==", "integrity": "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/history": "1.161.6", "@tanstack/history": "1.161.6",
"@tanstack/react-store": "^0.9.3", "@tanstack/react-store": "^0.9.3",
"@tanstack/router-core": "1.168.17", "@tanstack/router-core": "1.169.2",
"isbot": "^5.1.22" "isbot": "^5.1.22"
}, },
"engines": { "engines": {
@@ -1113,18 +1131,15 @@
} }
}, },
"node_modules/@tanstack/router-core": { "node_modules/@tanstack/router-core": {
"version": "1.168.17", "version": "1.169.2",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.17.tgz", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.169.2.tgz",
"integrity": "sha512-VDq7HCqRK3sdpxoETwYoTXTaYi+OVQC197g1fdzaiZBUmhntfjn+PQc15OzTqNNhf8Menk6r6ftmuphybMKdig==", "integrity": "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/history": "1.161.6", "@tanstack/history": "1.161.6",
"cookie-es": "^3.0.0", "cookie-es": "^3.0.0",
"seroval": "^1.5.0", "seroval": "^1.5.4",
"seroval-plugins": "^1.5.0" "seroval-plugins": "^1.5.4"
},
"bin": {
"intent": "bin/intent.js"
}, },
"engines": { "engines": {
"node": ">=20.19" "node": ">=20.19"
@@ -1151,9 +1166,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1177,9 +1192,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/estree-jsx": { "node_modules/@types/estree-jsx": {
@@ -1223,9 +1238,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.12.2", "version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1273,9 +1288,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/three": { "node_modules/@types/three": {
"version": "0.184.0", "version": "0.184.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==", "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0", "@dimforge/rapier3d-compat": "~0.12.0",
@@ -1305,17 +1320,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/type-utils": "8.59.1", "@typescript-eslint/type-utils": "8.59.3",
"@typescript-eslint/utils": "8.59.1", "@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.3",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
@@ -1328,7 +1343,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.59.1", "@typescript-eslint/parser": "^8.59.3",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
@@ -1344,16 +1359,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.3",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -1369,14 +1384,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/tsconfig-utils": "^8.59.3",
"@typescript-eslint/types": "^8.59.1", "@typescript-eslint/types": "^8.59.3",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -1391,14 +1406,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.1" "@typescript-eslint/visitor-keys": "8.59.3"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1409,9 +1424,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1426,15 +1441,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.1", "@typescript-eslint/utils": "8.59.3",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
}, },
@@ -1451,9 +1466,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1465,16 +1480,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/project-service": "8.59.3",
"@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.59.3",
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.3",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@@ -1503,9 +1518,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1532,9 +1547,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.4", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -1545,16 +1560,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.1" "@typescript-eslint/typescript-estree": "8.59.3"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1569,13 +1584,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.59.1", "@typescript-eslint/types": "8.59.3",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@@ -1600,9 +1615,9 @@
} }
}, },
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/@use-gesture/core": { "node_modules/@use-gesture/core": {
@@ -1767,9 +1782,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.23", "version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
"integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -1881,9 +1896,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001791", "version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2141,9 +2156,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.344", "version": "1.5.353",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -2565,9 +2580,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "17.5.0", "version": "17.6.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
"integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2827,9 +2842,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/isbot": { "node_modules/isbot": {
"version": "5.1.39", "version": "5.1.40",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.39.tgz", "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.40.tgz",
"integrity": "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==", "integrity": "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==",
"license": "Unlicense", "license": "Unlicense",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -3096,6 +3111,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3117,6 +3135,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3138,6 +3159,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3159,6 +3183,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3264,9 +3291,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "1.11.0", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
"integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -4172,9 +4199,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4198,9 +4225,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.38", "version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -4332,9 +4359,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.12", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4494,24 +4521,24 @@
} }
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.2.5", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.5", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.2.5" "react": "^19.2.6"
} }
}, },
"node_modules/react-markdown": { "node_modules/react-markdown": {
@@ -4642,14 +4669,14 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.127.0", "@oxc-project/types": "=0.129.0",
"@rolldown/pluginutils": "1.0.0-rc.17" "@rolldown/pluginutils": "1.0.0"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@@ -4658,27 +4685,27 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-android-arm64": "1.0.0",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" "@rolldown/binding-win32-x64-msvc": "1.0.0"
} }
}, },
"node_modules/rolldown/node_modules/@rolldown/pluginutils": { "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.17", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -4699,18 +4726,18 @@
} }
}, },
"node_modules/seroval": { "node_modules/seroval": {
"version": "1.5.2", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz",
"integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==", "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/seroval-plugins": { "node_modules/seroval-plugins": {
"version": "1.5.2", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.2.tgz", "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz",
"integrity": "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==", "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -5060,16 +5087,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.59.1", "version": "8.59.3",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
"integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.59.1", "@typescript-eslint/eslint-plugin": "8.59.3",
"@typescript-eslint/parser": "8.59.1", "@typescript-eslint/parser": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.1" "@typescript-eslint/utils": "8.59.3"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -5265,16 +5292,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.10", "version": "8.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"postcss": "^8.5.10", "postcss": "^8.5.14",
"rolldown": "1.0.0-rc.17", "rolldown": "1.0.0",
"tinyglobby": "^0.2.16" "tinyglobby": "^0.2.16"
}, },
"bin": { "bin": {
@@ -5291,7 +5318,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0", "@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
@@ -5399,9 +5426,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -5422,9 +5449,9 @@
} }
}, },
"node_modules/zustand": { "node_modules/zustand": {
"version": "5.0.12", "version": "5.0.13",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.20.0" "node": ">=12.20.0"
+2 -4
View File
@@ -8,6 +8,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:three-debug": "vite --mode three-debug --host 0.0.0.0",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
@@ -21,7 +22,7 @@
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1", "@react-three/fiber": "^9.6.1",
"@react-three/rapier": "^2.2.0", "@react-three/rapier": "^2.2.0",
"@tanstack/react-router": "^1.168.25", "@tanstack/react-router": "^1.169.2",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lil-gui": "^0.21.0", "lil-gui": "^0.21.0",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
@@ -51,9 +52,6 @@
"vite": "^8.0.4" "vite": "^8.0.4"
}, },
"overrides": { "overrides": {
"@react-three/rapier": {
"@dimforge/rapier3d-compat": "0.19.1"
},
"r3f-perf": { "r3f-perf": {
"@react-three/drei": "$@react-three/drei" "@react-three/drei": "$@react-three/drei"
} }
+27
View File
@@ -0,0 +1,27 @@
{
"version": 1,
"cinematics": [
{
"id": "intro_overview",
"timecode": 0,
"dialogueCues": [
{
"time": 0,
"dialogueId": "narrateur_bienvenueaaltera"
}
],
"cameraKeyframes": [
{
"time": 0,
"position": [8, 5, 12],
"target": [0, 2, 0]
},
{
"time": 4,
"position": [12, 4, -6],
"target": [10, 1.4, -8]
}
]
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+188
View File
@@ -0,0 +1,188 @@
{
"version": 1,
"voices": [
{
"id": "narrateur",
"speaker": "Narrateur",
"subtitles": {
"fr": "/sounds/dialogue/subtitles/fr/narrateur.srt",
"en": "/sounds/dialogue/subtitles/en/narrateur.srt"
}
},
{
"id": "fermier",
"speaker": "Fermier",
"subtitles": {
"fr": "/sounds/dialogue/subtitles/fr/fermier.srt",
"en": "/sounds/dialogue/subtitles/en/fermier.srt"
}
},
{
"id": "electricienne",
"speaker": "Electricienne",
"subtitles": {
"fr": "/sounds/dialogue/subtitles/fr/electricienne.srt",
"en": "/sounds/dialogue/subtitles/en/electricienne.srt"
}
}
],
"dialogues": [
{
"id": "narrateur_bienvenueaaltera",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3",
"subtitleCueIndex": 1,
"timecode": 0
},
{
"id": "narrateur_intro_prenom",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_intro_prenom.mp3",
"subtitleCueIndex": 2
},
{
"id": "narrateur_intro_apresprenom",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_intro_apresprenom.mp3",
"subtitleCueIndex": 3
},
{
"id": "narrateur_ordreebike",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_ordreebike.mp3",
"subtitleCueIndex": 4
},
{
"id": "narrateur_ebikecasse",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_ebikecassé.mp3",
"subtitleCueIndex": 5
},
{
"id": "narrateur_galetscan",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_galetscan.mp3",
"subtitleCueIndex": 6
},
{
"id": "narrateur_ebikerepare",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_ebikeréparé.mp3",
"subtitleCueIndex": 7
},
{
"id": "narrateur_ordredemandedelaide",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_ordredemandedelaide.mp3",
"subtitleCueIndex": 8
},
{
"id": "narrateur_coupureelec",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_coupureélec.mp3",
"subtitleCueIndex": 9
},
{
"id": "narrateur_poteaueleccasse",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3",
"subtitleCueIndex": 10
},
{
"id": "narrateur_courantrepare",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_courantréparé.mp3",
"subtitleCueIndex": 11
},
{
"id": "narrateur_routeversferme",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_routeversferme.mp3",
"subtitleCueIndex": 12
},
{
"id": "narrateur_arriveferme",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_arrivéferme.mp3",
"subtitleCueIndex": 13
},
{
"id": "narrateur_fouillelecentre",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_fouillelecentre.mp3",
"subtitleCueIndex": 14
},
{
"id": "narrateur_interactiontuyauxlac",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_interactiontuyauxlac.mp3",
"subtitleCueIndex": 15
},
{
"id": "narrateur_interactionrefroidisseur",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_interactionrefroidisseur.mp3",
"subtitleCueIndex": 16
},
{
"id": "narrateur_refroidisseurcasse",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_refroidisseurcassé.mp3",
"subtitleCueIndex": 17
},
{
"id": "narrateur_createurdepluiecree",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_createurdepluiecréé.mp3",
"subtitleCueIndex": 18
},
{
"id": "narrateur_remerciement",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_remerciement.mp3",
"subtitleCueIndex": 19
},
{
"id": "narrateur_bonnechance",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_bonnechance.mp3",
"subtitleCueIndex": 20
},
{
"id": "narrateur_presentationatelier",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_présentationatelier.mp3",
"subtitleCueIndex": 21
},
{
"id": "narrateur_presentationoutils",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_présentationoutils.mp3",
"subtitleCueIndex": 22
},
{
"id": "narrateur_histoireelectricienne",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
"subtitleCueIndex": 23
},
{
"id": "fermier_coupdemain",
"voice": "fermier",
"audio": "/sounds/dialogue/fermier_coupdemain.mp3",
"subtitleCueIndex": 1
},
{
"id": "fermier_coupdemain_2",
"voice": "fermier",
"audio": "/sounds/dialogue/fermier_coupdemain_2.mp3",
"subtitleCueIndex": 2
},
{
"id": "fermier_findemission",
"voice": "fermier",
"audio": "/sounds/dialogue/fermier_findemission.mp3",
"subtitleCueIndex": 3
}
]
}
@@ -0,0 +1,11 @@
# English Subtitle Fallback
English SRT files are intentionally optional for now.
The dialogue runtime first tries the selected subtitle language, then falls back to French. Missing English files should therefore remain validation warnings, not blocking errors, until the English translation workflow is ready.
Expected future files:
- `narrateur.srt`
- `fermier.srt`
- `electricienne.srt`
@@ -0,0 +1,11 @@
1
00:00:00,000 --> 00:00:08,000
Hey!! How are you? Do you need help placing the rollers?
2
00:00:00,000 --> 00:00:08,000
Don't hesitate if you need anything else!
3
00:00:00,000 --> 00:00:08,000
See you next time!
@@ -0,0 +1,11 @@
1
00:00:00,000 --> 00:00:04,032
Wait, wait, young man! I'll give you a hand.
2
00:00:00,000 --> 00:00:03,744
I did puzzles all through my youth. Try this!
3
00:00:00,000 --> 00:00:07,104
If you need anything else, don't hesitate, my boy. I'm getting old, but my mind is still sharp, hehehe!
@@ -0,0 +1,91 @@
1
00:00:00,000 --> 00:00:02,760
Hello there, future resident of Altera! Today, you are going to discover the technician role at La Fabrik, which handles Low-Tech technologies and repairs.
2
00:00:00,000 --> 00:00:11,592
Before we start, what's your name?
3
00:00:00,000 --> 00:00:10,824
Very good! We'll begin step by step to show you how the workshop works. Then you'll start your day and see the positive impact La Fabrik has on the community and the neighborhood.
4
00:00:00,000 --> 00:00:06,072
Let's go! You need to head to the farm, we're looking to improve something! Hop on your E-Bike.
5
00:00:00,000 --> 00:00:12,720
What? Your E-Bike is broken? Well, that's not too serious, it happens! Use the two rollers on your gloves. They're real technological gems. Place one under the bike, and one above it.
6
00:00:00,000 --> 00:00:08,064
So? Pretty amazing, right? Anyway, these rollers will scan the components to find out what we need to repair and/or replace.
7
00:00:00,000 --> 00:00:04,992
Perfect! The cooler gave out, you can replace it with one of the components from your pack. Aaaand there we go! It runs like clockwork! Go on, hurry!
8
00:00:00,000 --> 00:00:04,512
Don't hesitate to ask for help if you need it, everyone is super welcoming here.
9
00:00:00,000 --> 00:00:08,880
Oh woooow!! Did you see that???? All the traffic lights, computers and lights went out!! Hurry to the Energy Center, we can't even send repaired devices back out!
10
00:00:00,000 --> 00:00:09,840
Ah! A power pole fell down! Damn! Aaah, those little moles, they cause so much trouble... But they're so cuuuute!
11
00:00:00,000 --> 00:00:07,632
Woohoo! Great! Power is back across the whole neighborhood! Well done! Now head to the farm.
12
00:00:00,000 --> 00:00:05,352
Well, thanks to you I was able to finish my emergency! Oh, you're almost at the farm!
13
00:00:00,000 --> 00:00:11,760
Okay, enough of the emotional moment haha! For the farm, as I told you, we need to change the irrigation here. During drought periods, residents complain about an issue. See what you can do.
14
00:00:00,000 --> 00:00:04,560
Okay, perfect, you're there! Search the Center to find where the problem is coming from.
15
00:00:00,000 --> 00:00:06,864
Yeees! That's it! We'd like to stop pumping water from the lake, otherwise we'll drain all its reserves. What do you suggest?
16
00:00:00,000 --> 00:00:10,944
The old cooler from your E-Bike?? Yes!! Hahaha, great idea! Combined with the old lake pipes, we'll be able to make something cool! Put all that between your pads!
17
00:00:00,000 --> 00:00:05,712
The cooler from your E-Bike is broken, but we can still make something useful out of it.
18
00:00:00,000 --> 00:00:10,032
Ma-gni-fi-cent! I can see Gilbert helped you haha, he's such a sweetheart! You did a great job! And thanks to you, the neighborhood has been improved.
19
00:00:00,000 --> 00:00:11,520
Thank you so much for what you've brought to the community. The electrician and Gilbert really enjoyed helping you and told me they're looking forward to the next village party to get to know you better.
20
00:00:00,000 --> 00:00:02,352
Good luck! I've got work to do!
21
00:00:00,000 --> 00:00:33,600
Welcome to your workshop!! So? Pretty impressive, right? Okay, quick tour of what's here: this is your workbench. In the pipes are items from neighborhood residents that broke down and are waiting to be repaired. Once repaired, you put the item in this pipe and it goes back to the right person.
22
00:00:00,000 --> 00:00:14,760
Here, this is a dashboard. You can imagine that if your fridge or oven breaks down, you won't be able to put it in the pipe haha! So here, it tells you when residents have a bulky item that broke down, or when there's a problem in the city. Uh oh... I've got an emergency, I'll have to leave you soon! So here, take your tools to repair most things: a mini 3D printer powered by electronic waste, Push-Parts gloves to disassemble objects, and a Relaunch pack!
23
00:00:00,000 --> 00:00:54,000
The electrician helped you at the Power Plant? Aaaaah, that's what I love here: everyone helps each other, nobody judges anyone, it's like a real little family. You should know the electrician has quite a special story. She was born in the north of the continent, in the city of Kalska. She grew up happily with her mother Edith, her father Jordan, and her two little brothers, Malo and Justin. A few years ago, as you know, the northern countries were, quite unexpectedly, the first ones forced to migrate. So they began their journey, country by country, city by city, village by village. On a day of walking like so many others after several months, a climate storm caught them off guard. Having split up to find food in the village, her father and one of her two brothers sadly disappeared. It's tragic. But one day, they happened upon this place during their journey. We welcomed them with open arms, and they were slowly able to rebuild their lives among us. Today, they are an integral part of the community.
@@ -0,0 +1,11 @@
1
00:00:00,000 --> 00:00:08,000
Hey !! Comment ça va ? Tu as besoin d'aide pour poser les galets ?
2
00:00:00,000 --> 00:00:08,000
N'hésite pas, si tu as besoin d'autre chose !
3
00:00:00,000 --> 00:00:08,000
À la prochaine !
@@ -0,0 +1,11 @@
1
00:00:00,000 --> 00:00:04,032
Attendez attendez jeune homme ! Je vais vous filer un coup de main.
2
00:00:00,000 --> 00:00:03,744
J'ai fait des puzzles toute ma jeunesse. Essayez donc ça !
3
00:00:00,000 --> 00:00:07,104
Si vous avez besoin d'autre chose hésitez pas mon grand. Je me fais vieux mais j'ai encore toute ma tête hehehe !
@@ -0,0 +1,91 @@
1
00:00:00,000 --> 00:00:02,760
Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech.
2
00:00:00,000 --> 00:00:11,592
Avant de commencer, comment tu t'appelles ?
3
00:00:00,000 --> 00:00:10,824
Très bien ! On va commencer pas à pas pour te montrer comment fonctionne l'atelier. Ensuite, tu commenceras ta journée et tu pourras te rendre compte de l'impact positif qu'a la Fabrik sur la communauté et le quartier.
4
00:00:00,000 --> 00:00:06,072
Allez go ! Il faudrait que tu ailles à la ferme, on cherche à améliorer quelque chose ! Monte sur ton E-Bike.
5
00:00:00,000 --> 00:00:12,720
Quoi ? Ton E-Bike est cassé ? Bon c'est pas très grave, ça arrive ! Utilise les deux galets qui sont sur tes gants. Ce sont de véritables bijoux technologiques. Poses en un en-dessous du vélo, et un au-dessus.
6
00:00:00,000 --> 00:00:08,064
Alors ? Pas magnifique ça ? Enfin bref, ces galets vont scanner les composants pour savoir ce qu'on doit réparer et / ou changer.
7
00:00:00,000 --> 00:00:04,992
Parfait ! C'est le refroidisseur qui a lâché, tu peux le remplacer avec un des composants de ton pack. Eeeet voilà ! Il fonctionne comme une horloge ! Allez fonce !
8
00:00:00,000 --> 00:00:04,512
N'hésite pas à aller demander de l'aide si tu as besoin, tout le monde est super accueillant ici.
9
00:00:00,000 --> 00:00:08,880
Oh woooow !! T'as vu ça ???? Tous les feux, ordinateurs et lumières se sont éteints !! Faut vite que t'aille au Centre de l'Énergie, on ne peut même plus renvoyer les appareils réparés !
10
00:00:00,000 --> 00:00:09,840
Ah ! C'est un poteau d'alimentation qui est tombé ! Mince ! Alalaaa, ces petites taupes, elles en font des bêtises... Mais elles sont si chouuuu !
11
00:00:00,000 --> 00:00:07,632
Wouuuuhouuu ! Super ! Le courant est revenu dans tout le quartier ! Bien joué ! Allez, fonce à la ferme.
12
00:00:00,000 --> 00:00:05,352
Booon, grâce à toi j'ai pu finir mon urgence ! Oh mais t'arrives bientôt à la ferme !
13
00:00:00,000 --> 00:00:11,760
Bon, fini le moment émotion haha ! Pour la ferme, comme je te l'ai dis, ici, il faut qu'on change l'irrigation. Durant les périodes de sécheresse, les habitants se plaignent d'un souci. Vois ce que tu peux faire.
14
00:00:00,000 --> 00:00:04,560
Ok parfait tu y es ! Fouille le Centre pour voir d'où vient le problème.
15
00:00:00,000 --> 00:00:06,864
Ouiii ! C'est ça ! On aimerait ne plus pomper l'eau dans le lac, sinon on va épuiser toutes ses réserves. Qu'est-ce que tu proposes ?
16
00:00:00,000 --> 00:00:10,944
L'ancien refroidisseur de ton E-Bike ?? Mais oui !! Hahaha, très bonne idée ! Combiné aux anciens tuyaux du lac, on va pouvoir faire quelque chose de cool ! Met tout ça entre tes pads !
17
00:00:00,000 --> 00:00:05,712
Le refroidisseur de ton E-Bike est cassé, mais on peut encore en faire quelque chose d'utile.
18
00:00:00,000 --> 00:00:10,032
Ma-gni-fique ! Je vois que Gilbert t'as aidé haha, il est adorable celui-là ! Tu as fait du super boulot ! Et grâce à toi, le quartier est amélioré.
19
00:00:00,000 --> 00:00:11,520
Merci beaucoup pour ce que tu as apporté à la communauté. L'électricienne et Gilbert ont beaucoup apprécié t'aider et m'ont dit qu'ils avaient hâte de la prochaine fête de village pour mieux apprendre à te connaître.
20
00:00:00,000 --> 00:00:02,352
Allez bonne chance ! J'ai du boulot !
21
00:00:00,000 --> 00:00:33,600
Bienvenue dans ton atelier !! Alors ? Ça claque hein ? Bon je te présente en rapide tout ce qu'il y a : ici c'est ton plan de travail. Dans les tuyaux, ce sont des objets des résidents du quartier qui sont tombés en panne qui attendent d'être réparés. Une fois réparé, tu mets l'objet dans ce tuyau et ça repart chez la bonne personne.
22
00:00:00,000 --> 00:00:14,760
Ici, c'est un tableau de bord. T'imagines bien que si ton frigo ou ton four tombe en panne, tu ne vas pas pouvoir le mettre dans le tuyau haha ! Donc ici, ça te signale quand des résidents ont un objet volumineux tombé en panne, ou quand il y a un problème dans la ville. Oh oh... j'ai une urgence, il va bientôt falloir que je te laisse ! Donc tiens, tes outils pour pouvoir réparer la plupart des choses : une mini imprimante 3D à base de déchets électroniques, des gants Pousse Pièces pour désassembler les objets, ainsi qu'un pack de Relance !
23
00:00:00,000 --> 00:00:54,000
L'électricienne t'a aidé à la Centrale ? Aaaaah c'est ça que j'adore ici, tout le monde s'entraide, personne se juge, une vraie petite famille. Sache que l'électricienne a une histoire assez particulière. Elle est née au nord du continent, dans la ville de Kalska. Elle a grandit heureuse, avec sa mère Edith, son père Jordan et ses deux petits frères Malo et Justin. Il y a quelques années de ça, comme tu le sais, c'est les pays du Nord, qui par grande surprise, ont été obligés de migrer en premier. Ils ont alors entamé leur périple, pays par pays, ville par ville, village par village. Un jour de marche comme les autres depuis plusieurs mois, une tempête climatique les a pris de court. S'étant séparés pour trouver des vivres dans le village, le père et un des deux frères sont malheureusement partis. C'est tragique. Mais un beau jour, ils sont tombés ici, par hasard dans leur périple. On les a accueillis les bras ouverts et ils ont pu se reconstruire doucement parmi nous et font partie intégrante de la communauté aujourd'hui.
+16 -13
View File
@@ -16,25 +16,28 @@ export function DocsDocument({
frContent, frContent,
}: DocsDocumentProps): React.JSX.Element { }: DocsDocumentProps): React.JSX.Element {
const { language, toggleLanguage } = useDocsLanguage(); const { language, toggleLanguage } = useDocsLanguage();
const hasAlternateContent = frContent !== content;
const translatedContent = language === "fr" ? frContent : content; const translatedContent = language === "fr" ? frContent : content;
return ( return (
<div className="docs-content"> <div className="docs-content">
<header className="docs-content__header"> <header className="docs-content__header">
<span>{title}</span> <span>{title}</span>
<button {hasAlternateContent ? (
className="docs-language-toggle" <button
type="button" className="docs-language-toggle"
onClick={toggleLanguage} type="button"
aria-label="Changer la langue de la documentation" onClick={toggleLanguage}
> aria-label="Changer la langue de la documentation"
<span className={language === "fr" ? "is-active" : undefined}> >
FR <span className={language === "fr" ? "is-active" : undefined}>
</span> FR
<span className={language === "en" ? "is-active" : undefined}> </span>
EN <span className={language === "en" ? "is-active" : undefined}>
</span> EN
</button> </span>
</button>
) : null}
</header> </header>
<article className="docs-section"> <article className="docs-section">
@@ -0,0 +1,665 @@
import { useEffect, useState } from "react";
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
import type {
CinematicCameraKeyframe,
CinematicDefinition,
CinematicDialogueCue,
CinematicManifest,
} from "@/types/cinematics/cinematics";
import type {
DialogueDefinition,
DialogueManifest,
} from "@/types/dialogues/dialogues";
import type { Vector3Tuple } from "@/types/three/three";
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
type CinematicPatch = Partial<Omit<CinematicDefinition, "timecode">> & {
timecode?: number | undefined;
};
type VectorAxis = 0 | 1 | 2;
const VECTOR_AXES: { label: "X" | "Y" | "Z"; axis: VectorAxis }[] = [
{ label: "X", axis: 0 },
{ label: "Y", axis: 1 },
{ label: "Z", axis: 2 },
];
function createCinematic(index: number): CinematicDefinition {
return {
id: `new_cinematic_${index}`,
cameraKeyframes: [
{ time: 0, position: [0, 3, 8], target: [0, 1.5, 0] },
{ time: 3, position: [6, 3, 8], target: [0, 1.5, 0] },
],
};
}
function createKeyframe(
previousKeyframe: CinematicCameraKeyframe,
): CinematicCameraKeyframe {
return {
time: previousKeyframe.time + 3,
position: [...previousKeyframe.position],
target: [...previousKeyframe.target],
};
}
function createDialogueCue(
dialogues: DialogueDefinition[],
previousCue: CinematicDialogueCue | null,
): CinematicDialogueCue {
return {
time: previousCue ? previousCue.time + 1 : 0,
dialogueId: dialogues[0]?.id ?? "",
};
}
function getManifestErrors(
manifest: CinematicManifest | null,
dialogueIds: Set<string>,
): string[] {
if (!manifest) return ["Manifeste absent."];
const errors: string[] = [];
const ids = new Set<string>();
manifest.cinematics.forEach((cinematic, cinematicIndex) => {
const label = cinematic.id || `Cinematique ${cinematicIndex + 1}`;
if (!cinematic.id.trim()) errors.push(`${label}: id obligatoire.`);
if (ids.has(cinematic.id)) errors.push(`${label}: id duplique.`);
ids.add(cinematic.id);
if (
cinematic.timecode !== undefined &&
(!Number.isFinite(cinematic.timecode) || cinematic.timecode < 0)
) {
errors.push(`${label}: timecode invalide.`);
}
if (cinematic.cameraKeyframes.length < 2) {
errors.push(`${label}: au moins deux keyframes camera sont requises.`);
}
cinematic.cameraKeyframes.forEach((keyframe, keyframeIndex) => {
const previousKeyframe = cinematic.cameraKeyframes[keyframeIndex - 1];
if (!Number.isFinite(keyframe.time) || keyframe.time < 0) {
errors.push(`${label}: keyframe ${keyframeIndex + 1} time invalide.`);
}
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
errors.push(`${label}: les temps des keyframes doivent augmenter.`);
}
});
cinematic.dialogueCues?.forEach((cue, cueIndex) => {
if (!Number.isFinite(cue.time) || cue.time < 0) {
errors.push(`${label}: dialogue cue ${cueIndex + 1} time invalide.`);
}
if (!cue.dialogueId.trim()) {
errors.push(`${label}: dialogue cue ${cueIndex + 1} id obligatoire.`);
} else if (dialogueIds.size > 0 && !dialogueIds.has(cue.dialogueId)) {
errors.push(`${label}: dialogue cue ${cueIndex + 1} dialogue inconnu.`);
}
});
});
return errors;
}
async function saveCinematicManifest(
manifest: CinematicManifest,
): Promise<void> {
const response = await fetch("/api/save-cinematics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manifest),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(body?.error ?? "Sauvegarde des cinematics impossible");
}
}
function getPatchedCinematic(
cinematic: CinematicDefinition,
patch: CinematicPatch,
): CinematicDefinition {
const nextCinematic: CinematicDefinition = {
id: patch.id ?? cinematic.id,
cameraKeyframes: patch.cameraKeyframes ?? cinematic.cameraKeyframes,
};
const dialogueCues = patch.dialogueCues ?? cinematic.dialogueCues;
if (dialogueCues) {
nextCinematic.dialogueCues = dialogueCues;
}
if ("timecode" in patch) {
if (patch.timecode !== undefined) nextCinematic.timecode = patch.timecode;
} else if (cinematic.timecode !== undefined) {
nextCinematic.timecode = cinematic.timecode;
}
return nextCinematic;
}
function updateVector(
vector: Vector3Tuple,
axis: VectorAxis,
value: number,
): Vector3Tuple {
const nextVector: Vector3Tuple = [...vector];
nextVector[axis] = value;
return nextVector;
}
interface EditorCinematicManifestPanelProps {
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
}
export function EditorCinematicManifestPanel({
onPreviewCinematic,
}: EditorCinematicManifestPanelProps): React.JSX.Element {
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
const [dialogueManifest, setDialogueManifest] =
useState<DialogueManifest | null>(null);
const [selectedCinematicId, setSelectedCinematicId] = useState("");
const [status, setStatus] = useState("Chargement des cinematics...");
const [isSaving, setIsSaving] = useState(false);
const dialogueIds = new Set(
dialogueManifest?.dialogues.map((dialogue) => dialogue.id) ?? [],
);
const errors = getManifestErrors(manifest, dialogueIds);
const selectedCinematic =
manifest?.cinematics.find(
(cinematic) => cinematic.id === selectedCinematicId,
) ??
manifest?.cinematics[0] ??
null;
async function handleLoad(): Promise<void> {
setStatus("Chargement des cinematics...");
try {
const [loadedManifest, loadedDialogueManifest] = await Promise.all([
loadCinematicManifest(),
loadDialogueManifest(),
]);
setManifest(loadedManifest);
setDialogueManifest(loadedDialogueManifest);
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
setStatus(
loadedManifest
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
: "Manifeste cinematics introuvable ou invalide.",
);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
setManifest(null);
}
}
async function handleSave(): Promise<void> {
if (!manifest) return;
if (errors.length > 0) {
setStatus("Corrige les erreurs avant de sauvegarder.");
return;
}
setIsSaving(true);
setStatus("Sauvegarde des cinematics...");
try {
await saveCinematicManifest(manifest);
setStatus("Manifeste sauvegarde dans public/cinematics.json.");
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
} finally {
setIsSaving(false);
}
}
function handleAddCinematic(): void {
if (!manifest) return;
const cinematic = createCinematic(manifest.cinematics.length + 1);
setManifest({
...manifest,
cinematics: [...manifest.cinematics, cinematic],
});
setSelectedCinematicId(cinematic.id);
setStatus("Nouvelle cinematic ajoutee localement.");
}
function handleRemoveCinematic(cinematicId: string): void {
if (!manifest) return;
const nextCinematics = manifest.cinematics.filter(
(cinematic) => cinematic.id !== cinematicId,
);
setManifest({ ...manifest, cinematics: nextCinematics });
setSelectedCinematicId(nextCinematics[0]?.id ?? "");
setStatus("Cinematic supprimee localement.");
}
function updateSelectedCinematic(
patch: CinematicPatch,
nextId = selectedCinematicId,
): void {
if (!manifest || !selectedCinematic) return;
setManifest({
...manifest,
cinematics: manifest.cinematics.map((cinematic) =>
cinematic.id === selectedCinematic.id
? getPatchedCinematic(cinematic, patch)
: cinematic,
),
});
setSelectedCinematicId(nextId);
}
function updateKeyframe(
keyframeIndex: number,
patch: Partial<CinematicCameraKeyframe>,
): void {
if (!selectedCinematic) return;
updateSelectedCinematic({
cameraKeyframes: selectedCinematic.cameraKeyframes.map(
(keyframe, index) =>
index === keyframeIndex ? { ...keyframe, ...patch } : keyframe,
),
});
}
function handleAddKeyframe(): void {
if (!selectedCinematic) return;
const previousKeyframe =
selectedCinematic.cameraKeyframes[
selectedCinematic.cameraKeyframes.length - 1
];
if (!previousKeyframe) return;
updateSelectedCinematic({
cameraKeyframes: [
...selectedCinematic.cameraKeyframes,
createKeyframe(previousKeyframe),
],
});
setStatus("Keyframe ajoutee localement.");
}
function handleRemoveKeyframe(keyframeIndex: number): void {
if (!selectedCinematic) return;
updateSelectedCinematic({
cameraKeyframes: selectedCinematic.cameraKeyframes.filter(
(_keyframe, index) => index !== keyframeIndex,
),
});
setStatus("Keyframe supprimee localement.");
}
function updateDialogueCue(
cueIndex: number,
patch: Partial<CinematicDialogueCue>,
): void {
if (!selectedCinematic) return;
const dialogueCues = selectedCinematic.dialogueCues ?? [];
updateSelectedCinematic({
dialogueCues: dialogueCues.map((cue, index) =>
index === cueIndex ? { ...cue, ...patch } : cue,
),
});
}
function handleAddDialogueCue(): void {
if (!selectedCinematic) return;
const dialogueCues = selectedCinematic.dialogueCues ?? [];
const previousCue = dialogueCues[dialogueCues.length - 1] ?? null;
updateSelectedCinematic({
dialogueCues: [
...dialogueCues,
createDialogueCue(dialogueManifest?.dialogues ?? [], previousCue),
],
});
setStatus("Dialogue cue ajoutee localement.");
}
function handleRemoveDialogueCue(cueIndex: number): void {
if (!selectedCinematic) return;
updateSelectedCinematic({
dialogueCues: (selectedCinematic.dialogueCues ?? []).filter(
(_cue, index) => index !== cueIndex,
),
});
setStatus("Dialogue cue supprimee localement.");
}
useEffect(() => {
let mounted = true;
void Promise.all([loadCinematicManifest(), loadDialogueManifest()])
.then(([loadedManifest, loadedDialogueManifest]) => {
if (!mounted) return;
setManifest(loadedManifest);
setDialogueManifest(loadedDialogueManifest);
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
setStatus(
loadedManifest
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
: "Manifeste cinematics introuvable ou invalide.",
);
})
.catch((err: unknown) => {
if (!mounted) return;
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
setManifest(null);
});
return () => {
mounted = false;
};
}, []);
return (
<section
className="editor-cinematic-manifest-section"
aria-labelledby="cinematic-manifest-heading"
>
<div className="editor-section-heading">
<h3 id="cinematic-manifest-heading">Cinematics</h3>
<span>{manifest?.cinematics.length ?? 0} items</span>
</div>
<div className="editor-cinematic-manifest-actions">
<button type="button" onClick={() => void handleLoad()}>
<RefreshCw size={14} aria-hidden="true" />
Reload
</button>
<button type="button" disabled={!manifest} onClick={handleAddCinematic}>
<Plus size={14} aria-hidden="true" />
Add
</button>
<button
type="button"
disabled={!manifest || errors.length > 0 || isSaving}
onClick={() => void handleSave()}
>
<Save size={14} aria-hidden="true" />
{isSaving ? "Saving..." : "Save"}
</button>
</div>
{manifest && (
<label className="editor-cinematic-manifest-select">
Cinematic
<select
value={selectedCinematic?.id ?? ""}
onChange={(event) => setSelectedCinematicId(event.target.value)}
>
{manifest.cinematics.map((cinematic) => (
<option key={cinematic.id} value={cinematic.id}>
{cinematic.id || "Cinematic sans id"}
</option>
))}
</select>
</label>
)}
{selectedCinematic && (
<div className="editor-cinematic-manifest-form">
<label>
ID
<input
value={selectedCinematic.id}
onChange={(event) =>
updateSelectedCinematic(
{ id: event.target.value },
event.target.value,
)
}
/>
</label>
<label>
Timecode global optionnel
<input
type="number"
min="0"
step="0.1"
value={selectedCinematic.timecode ?? ""}
placeholder="Aucun"
onChange={(event) => {
const value = event.target.value.trim();
updateSelectedCinematic({
timecode: value === "" ? undefined : Number(value),
});
}}
/>
</label>
<div className="editor-cinematic-keyframes">
<div className="editor-cinematic-keyframes-heading">
<strong>Camera keyframes</strong>
<button type="button" onClick={handleAddKeyframe}>
<Plus size={13} aria-hidden="true" />
Add keyframe
</button>
</div>
{selectedCinematic.cameraKeyframes.map(
(keyframe, keyframeIndex) => (
<div
className="editor-cinematic-keyframe"
key={`${selectedCinematic.id}-${keyframeIndex}`}
>
<div className="editor-cinematic-keyframe-heading">
<strong>Keyframe {keyframeIndex + 1}</strong>
<button
type="button"
disabled={selectedCinematic.cameraKeyframes.length <= 2}
onClick={() => handleRemoveKeyframe(keyframeIndex)}
>
<Trash2 size={13} aria-hidden="true" />
Remove
</button>
</div>
<label>
Time
<input
type="number"
min="0"
step="0.1"
value={keyframe.time}
onChange={(event) =>
updateKeyframe(keyframeIndex, {
time: Number(event.target.value),
})
}
/>
</label>
<VectorInputs
label="Position"
value={keyframe.position}
onChange={(axis, value) =>
updateKeyframe(keyframeIndex, {
position: updateVector(keyframe.position, axis, value),
})
}
/>
<VectorInputs
label="Target"
value={keyframe.target}
onChange={(axis, value) =>
updateKeyframe(keyframeIndex, {
target: updateVector(keyframe.target, axis, value),
})
}
/>
</div>
),
)}
</div>
<div className="editor-cinematic-dialogue-cues">
<div className="editor-cinematic-dialogue-cues-heading">
<strong>Dialogue cues</strong>
<button type="button" onClick={handleAddDialogueCue}>
<Plus size={13} aria-hidden="true" />
Add dialogue
</button>
</div>
{(selectedCinematic.dialogueCues ?? []).length === 0 ? (
<p>Aucun dialogue synchronise avec cette cinematic.</p>
) : (
(selectedCinematic.dialogueCues ?? []).map((cue, cueIndex) => (
<div
className="editor-cinematic-dialogue-cue"
key={`${selectedCinematic.id}-dialogue-${cueIndex}`}
>
<div className="editor-cinematic-dialogue-cue-heading">
<strong>Dialogue {cueIndex + 1}</strong>
<button
type="button"
onClick={() => handleRemoveDialogueCue(cueIndex)}
>
<Trash2 size={13} aria-hidden="true" />
Remove
</button>
</div>
<label>
Time
<input
type="number"
min="0"
step="0.1"
value={cue.time}
onChange={(event) =>
updateDialogueCue(cueIndex, {
time: Number(event.target.value),
})
}
/>
</label>
<label>
Dialogue
<select
value={cue.dialogueId}
onChange={(event) =>
updateDialogueCue(cueIndex, {
dialogueId: event.target.value,
})
}
>
{dialogueManifest?.dialogues.length ? (
dialogueManifest.dialogues.map((dialogue) => (
<option key={dialogue.id} value={dialogue.id}>
{dialogue.id}
</option>
))
) : (
<option value={cue.dialogueId}>
{cue.dialogueId || "Aucun dialogue disponible"}
</option>
)}
</select>
</label>
</div>
))
)}
</div>
<button
className="editor-cinematic-manifest-preview"
type="button"
disabled={errors.length > 0 || !onPreviewCinematic}
onClick={() => onPreviewCinematic?.(selectedCinematic)}
>
<Play size={14} aria-hidden="true" />
Preview cinematic
</button>
<button
className="editor-cinematic-manifest-delete"
type="button"
onClick={() => handleRemoveCinematic(selectedCinematic.id)}
>
<Trash2 size={14} aria-hidden="true" />
Delete cinematic
</button>
</div>
)}
<p className="editor-cinematic-manifest-status">{status}</p>
<div
className={`editor-cinematic-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
>
<strong>
{errors.length === 0
? "Manifeste local valide."
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
</strong>
{errors.length > 0 && (
<ul>
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
</section>
);
}
interface VectorInputsProps {
label: string;
value: Vector3Tuple;
onChange: (axis: VectorAxis, value: number) => void;
}
function VectorInputs({
label,
value,
onChange,
}: VectorInputsProps): React.JSX.Element {
return (
<div className="editor-cinematic-vector-inputs">
<span>{label}</span>
{VECTOR_AXES.map(({ label: axisLabel, axis }) => (
<label key={axisLabel}>
{axisLabel}
<input
type="number"
step="0.1"
value={value[axis]}
onChange={(event) => onChange(axis, Number(event.target.value))}
/>
</label>
))}
</div>
);
}
+242 -154
View File
@@ -1,6 +1,7 @@
import { import {
Box, Box,
Braces, Braces,
ChevronDown,
Download, Download,
Expand, Expand,
Keyboard, Keyboard,
@@ -11,7 +12,13 @@ import {
RotateCw, RotateCw,
Save, Save,
Undo2, Undo2,
Unlock,
X,
} from "lucide-react"; } from "lucide-react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode } from "@/types/editor/editor"; import type { MapNode, TransformMode } from "@/types/editor/editor";
interface EditorControlsProps { interface EditorControlsProps {
@@ -21,6 +28,9 @@ interface EditorControlsProps {
mapNodes: MapNode[]; mapNodes: MapNode[];
nodesCount: number; nodesCount: number;
selectedNodeName: string | null; selectedNodeName: string | null;
isSelectionLocked: boolean;
onSelectionLockToggle: () => void;
onClearSelection: () => void;
undoCount: number; undoCount: number;
redoCount: number; redoCount: number;
onUndo: () => void; onUndo: () => void;
@@ -28,6 +38,7 @@ interface EditorControlsProps {
onExportJson: () => void; onExportJson: () => void;
onSaveToServer?: (() => void | Promise<void>) | undefined; onSaveToServer?: (() => void | Promise<void>) | undefined;
onPlayerMode?: (() => void) | undefined; onPlayerMode?: (() => void) | undefined;
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
isPlayerMode?: boolean; isPlayerMode?: boolean;
} }
@@ -45,6 +56,33 @@ const EDITOR_SHORTCUTS = [
["WASD", "Move when locked"], ["WASD", "Move when locked"],
] as const; ] as const;
interface EditorPanelGroupProps {
title: string;
summary?: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
function EditorPanelGroup({
title,
summary,
defaultOpen = false,
children,
}: EditorPanelGroupProps): React.JSX.Element {
return (
<details className="editor-panel-group" open={defaultOpen}>
<summary className="editor-panel-group-summary">
<span>{title}</span>
<span className="editor-panel-group-meta">
{summary ? <span>{summary}</span> : null}
<ChevronDown size={15} aria-hidden="true" />
</span>
</summary>
<div className="editor-panel-group-content">{children}</div>
</details>
);
}
export function EditorControls({ export function EditorControls({
transformMode, transformMode,
onTransformModeChange, onTransformModeChange,
@@ -52,6 +90,9 @@ export function EditorControls({
mapNodes, mapNodes,
nodesCount, nodesCount,
selectedNodeName, selectedNodeName,
isSelectionLocked,
onSelectionLockToggle,
onClearSelection,
undoCount, undoCount,
redoCount, redoCount,
onUndo, onUndo,
@@ -59,6 +100,7 @@ export function EditorControls({
onExportJson, onExportJson,
onSaveToServer, onSaveToServer,
onPlayerMode, onPlayerMode,
onPreviewCinematic,
isPlayerMode, isPlayerMode,
}: EditorControlsProps): React.JSX.Element { }: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view"; const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
@@ -73,169 +115,215 @@ export function EditorControls({
<p>Select an object, choose a transform mode, then drag the gizmo.</p> <p>Select an object, choose a transform mode, then drag the gizmo.</p>
</header> </header>
<section <EditorPanelGroup title="Editor" summary="Map tools" defaultOpen>
className="editor-control-section" <EditorPanelGroup title="Shortcuts" summary="Keys">
aria-labelledby="transform-heading" <section
> className="editor-control-section"
<div className="editor-section-heading"> aria-labelledby="shortcuts-heading"
<h3 id="transform-heading">Transform</h3>
<span>T / R / S</span>
</div>
<div className="editor-transform-buttons">
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
<button
key={mode}
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
onClick={() => onTransformModeChange(mode)}
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<kbd>{shortcut}</kbd>
</button>
))}
</div>
<div className="editor-history-buttons">
<button
className="editor-history-button"
onClick={onUndo}
disabled={undoCount === 0}
> >
<Undo2 size={15} aria-hidden="true" /> <div className="editor-section-heading">
Undo <h3 id="shortcuts-heading">Shortcuts</h3>
<span>{undoCount}</span> <Keyboard size={15} aria-hidden="true" />
</button> </div>
<button
className="editor-history-button"
onClick={onRedo}
disabled={redoCount === 0}
>
<Redo2 size={15} aria-hidden="true" />
Redo
<span>{redoCount}</span>
</button>
</div>
</section>
<section <dl className="editor-shortcuts-list">
className="editor-control-section" {EDITOR_SHORTCUTS.map(([keys, description]) => (
aria-labelledby="file-heading" <div key={keys}>
> <dt>{keys}</dt>
<div className="editor-section-heading"> <dd>{description}</dd>
<h3 id="file-heading">File</h3> </div>
</div> ))}
</dl>
</section>
</EditorPanelGroup>
<button <section
className="editor-action-button editor-action-button-primary" className="editor-control-section"
onClick={onExportJson} aria-labelledby="transform-heading"
> >
<Download size={16} aria-hidden="true" /> <div className="editor-section-heading">
Export JSON <h3 id="transform-heading">Transform</h3>
</button> <span>T / R / S</span>
{onSaveToServer && (
<button className="editor-action-button" onClick={onSaveToServer}>
<Save size={16} aria-hidden="true" />
Save to server
</button>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
>
<div className="editor-section-heading">
<h3 id="view-heading">View</h3>
</div>
{onPlayerMode && (
<button
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
aria-pressed={isPlayerMode}
>
<Lock size={16} aria-hidden="true" />
{viewModeLabel}
</button>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="selection-heading"
>
<div className="editor-section-heading">
<h3 id="selection-heading">Selection</h3>
<span>{nodesCount} nodes</span>
</div>
{selectedNodeIndex !== null ? (
<div className="editor-selected-info">
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
</span>
</div>
</div> </div>
) : (
<div className="editor-no-selection"> <div className="editor-transform-buttons">
<MousePointer2 size={17} aria-hidden="true" /> {TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
No object selected <button
key={mode}
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
onClick={() => onTransformModeChange(mode)}
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<kbd>{shortcut}</kbd>
</button>
))}
</div> </div>
)}
</section>
<section <div className="editor-history-buttons">
className="editor-control-section" <button
aria-labelledby="shortcuts-heading" className="editor-history-button"
> onClick={onUndo}
<div className="editor-section-heading"> disabled={undoCount === 0}
<h3 id="shortcuts-heading">Shortcuts</h3>
<Keyboard size={15} aria-hidden="true" />
</div>
<dl className="editor-shortcuts-list">
{EDITOR_SHORTCUTS.map(([keys, description]) => (
<div key={keys}>
<dt>{keys}</dt>
<dd>{description}</dd>
</div>
))}
</dl>
</section>
<section className="editor-json-section" aria-labelledby="json-heading">
<div className="editor-section-heading">
<h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span>
</div>
<pre className="editor-json-view" aria-label={jsonPreview.label}>
{jsonPreview.lines.map((line) => (
<code
key={line.number}
className={line.isSelected ? "is-selected" : undefined}
> >
<span>{line.number}</span> <Undo2 size={15} aria-hidden="true" />
{line.content || " "} Undo
</code> <span>{undoCount}</span>
))} </button>
</pre> <button
className="editor-history-button"
onClick={onRedo}
disabled={redoCount === 0}
>
<Redo2 size={15} aria-hidden="true" />
Redo
<span>{redoCount}</span>
</button>
</div>
</section>
<div className="editor-json-hint"> <section
<Braces size={14} aria-hidden="true" /> className="editor-control-section"
{selectedNodeIndex === null aria-labelledby="selection-heading"
? "Raw map JSON" >
: `Selected node ${selectedNodeIndex + 1} raw lines`} <div className="editor-section-heading">
</div> <h3 id="selection-heading">Selection</h3>
</section> <span>{nodesCount} nodes</span>
</div>
{selectedNodeIndex !== null ? (
<div className="editor-selected-info">
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
</span>
</div>
<div className="editor-selected-actions">
<button
type="button"
onClick={onSelectionLockToggle}
aria-pressed={isSelectionLocked}
aria-label={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
title={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
>
{isSelectionLocked ? (
<Lock size={14} aria-hidden="true" />
) : (
<Unlock size={14} aria-hidden="true" />
)}
</button>
<button
type="button"
onClick={onClearSelection}
aria-label="Clear selection"
title="Clear selection"
>
<X size={14} aria-hidden="true" />
</button>
</div>
</div>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
</div>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
>
<div className="editor-section-heading">
<h3 id="view-heading">View</h3>
</div>
{onPlayerMode && (
<button
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
aria-pressed={isPlayerMode}
>
<Lock size={16} aria-hidden="true" />
{viewModeLabel}
</button>
)}
</section>
<section
className="editor-json-section"
aria-labelledby="json-heading"
>
<div className="editor-section-heading">
<h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span>
</div>
<pre className="editor-json-view" aria-label={jsonPreview.label}>
{jsonPreview.lines.map((line) => (
<code
key={line.number}
className={line.isSelected ? "is-selected" : undefined}
>
<span>{line.number}</span>
{line.content || " "}
</code>
))}
</pre>
<div className="editor-json-hint">
<Braces size={14} aria-hidden="true" />
{selectedNodeIndex === null
? "Raw map JSON"
: `Selected node ${selectedNodeIndex + 1} raw lines`}
</div>
</section>
<section
className="editor-control-section"
aria-labelledby="file-heading"
>
<div className="editor-section-heading">
<h3 id="file-heading">File</h3>
</div>
<button
className="editor-action-button editor-action-button-primary"
onClick={onExportJson}
>
<Download size={16} aria-hidden="true" />
Export JSON
</button>
{onSaveToServer && (
<button className="editor-action-button" onClick={onSaveToServer}>
<Save size={16} aria-hidden="true" />
Save to server
</button>
)}
</section>
</EditorPanelGroup>
<EditorPanelGroup title="Cinematics" summary="Timeline">
<EditorCinematicManifestPanel
onPreviewCinematic={onPreviewCinematic}
/>
</EditorPanelGroup>
<EditorPanelGroup title="Dialogues" summary="Manifest">
<EditorDialogueManifestPanel />
</EditorPanelGroup>
<EditorPanelGroup title="SRT" summary="Subtitles">
<EditorSrtPanel />
</EditorPanelGroup>
</aside> </aside>
</> </>
); );
@@ -0,0 +1,554 @@
import { useEffect, useRef, useState } from "react";
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
import type {
DialogueDefinition,
DialogueManifest,
DialogueSpeaker,
DialogueVoiceId,
} from "@/types/dialogues/dialogues";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import { parseSrt } from "@/utils/subtitles/parseSrt";
const DEFAULT_VOICE: DialogueVoiceId = "narrateur";
type DialoguePatch = Partial<Omit<DialogueDefinition, "timecode">> & {
timecode?: number | undefined;
};
function createDialogue(
index: number,
manifest: DialogueManifest,
voice: DialogueVoiceId,
): DialogueDefinition {
return {
id: `new_dialogue_${index}`,
voice,
audio: `/sounds/dialogue/new_dialogue_${index}.mp3`,
subtitleCueIndex: getNextCueIndex(manifest, voice),
};
}
function getNextCueIndex(
manifest: DialogueManifest,
voice: DialogueVoiceId,
): number {
const cueIndexes = manifest.dialogues
.filter((dialogue) => dialogue.voice === voice)
.map((dialogue) => dialogue.subtitleCueIndex);
return Math.max(0, ...cueIndexes) + 1;
}
function getVoiceSpeaker(
manifest: DialogueManifest,
voice: DialogueVoiceId,
): DialogueSpeaker {
return (
manifest.voices.find((item) => item.id === voice)?.speaker ?? "Narrateur"
);
}
function getFrenchSrtPath(voice: DialogueVoiceId): string {
return `/sounds/dialogue/subtitles/fr/${voice}.srt`;
}
function createSrtCueBlock(cueIndex: number, speaker: DialogueSpeaker): string {
return `${cueIndex}\n00:00:00,000 --> 00:00:02,000\n${speaker}: Nouveau sous-titre ${cueIndex} a definir`;
}
function appendSrtCueIfMissing(
content: string,
cueIndex: number,
speaker: DialogueSpeaker,
): string {
const cues = parseSrt(content);
if (cues.some((cue) => cue.index === cueIndex)) return content;
const trimmedContent = content.trim();
const cueBlock = createSrtCueBlock(cueIndex, speaker);
return trimmedContent
? `${trimmedContent}\n\n${cueBlock}\n`
: `${cueBlock}\n`;
}
async function saveSrtFile(
voice: DialogueVoiceId,
content: string,
): Promise<void> {
const response = await fetch("/api/save-srt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ voice, language: "fr", content }),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(body?.error ?? "Sauvegarde SRT impossible");
}
}
async function createFrenchSrtCue(
manifest: DialogueManifest,
dialogue: DialogueDefinition,
): Promise<void> {
const srtPath = getFrenchSrtPath(dialogue.voice);
const response = await fetch(srtPath);
const content = response.ok ? await response.text() : "";
const nextContent = appendSrtCueIfMissing(
content,
dialogue.subtitleCueIndex,
getVoiceSpeaker(manifest, dialogue.voice),
);
await saveSrtFile(dialogue.voice, nextContent);
}
function getManifestErrors(manifest: DialogueManifest | null): string[] {
if (!manifest) return ["Manifeste absent."];
const errors: string[] = [];
const ids = new Set<string>();
manifest.dialogues.forEach((dialogue, index) => {
const label = dialogue.id || `Dialogue ${index + 1}`;
if (!dialogue.id.trim()) errors.push(`${label}: id obligatoire.`);
if (ids.has(dialogue.id)) errors.push(`${label}: id duplique.`);
ids.add(dialogue.id);
if (!dialogue.audio.startsWith("/sounds/dialogue/")) {
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`);
}
if (!Number.isInteger(dialogue.subtitleCueIndex)) {
errors.push(`${label}: cue SRT invalide.`);
}
if (
dialogue.timecode !== undefined &&
(!Number.isFinite(dialogue.timecode) || dialogue.timecode < 0)
) {
errors.push(`${label}: timecode invalide.`);
}
});
return errors;
}
async function saveDialogueManifest(manifest: DialogueManifest): Promise<void> {
const response = await fetch("/api/save-dialogues", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manifest),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(body?.error ?? "Sauvegarde du manifeste impossible");
}
}
function getPatchedDialogue(
dialogue: DialogueDefinition,
patch: DialoguePatch,
): DialogueDefinition {
const nextDialogue: DialogueDefinition = {
id: patch.id ?? dialogue.id,
voice: patch.voice ?? dialogue.voice,
audio: patch.audio ?? dialogue.audio,
subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex,
};
if ("timecode" in patch) {
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
} else if (dialogue.timecode !== undefined) {
nextDialogue.timecode = dialogue.timecode;
}
return nextDialogue;
}
export function EditorDialogueManifestPanel(): React.JSX.Element {
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
const [selectedDialogueId, setSelectedDialogueId] = useState("");
const [status, setStatus] = useState("Chargement du manifeste...");
const [isSaving, setIsSaving] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isCreatingSrtCue, setIsCreatingSrtCue] = useState(false);
const errors = getManifestErrors(manifest);
const selectedDialogue =
manifest?.dialogues.find(
(dialogue) => dialogue.id === selectedDialogueId,
) ??
manifest?.dialogues[0] ??
null;
const voices = manifest?.voices ?? [];
async function handleLoad(): Promise<void> {
setStatus("Chargement du manifeste...");
try {
const loadedManifest = await loadDialogueManifest();
setManifest(loadedManifest);
setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? "");
setStatus(
loadedManifest
? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.`
: "Manifeste introuvable ou invalide.",
);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
setManifest(null);
}
}
async function handleSave(): Promise<void> {
if (!manifest) return;
if (errors.length > 0) {
setStatus("Corrige les erreurs avant de sauvegarder.");
return;
}
setIsSaving(true);
setStatus("Sauvegarde du manifeste...");
try {
await saveDialogueManifest(manifest);
setStatus(
"Manifeste sauvegarde dans public/sounds/dialogue/dialogues.json.",
);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
} finally {
setIsSaving(false);
}
}
async function handleAddDialogue(): Promise<void> {
if (!manifest) return;
const voice = selectedDialogue?.voice ?? DEFAULT_VOICE;
const dialogue = createDialogue(
manifest.dialogues.length + 1,
manifest,
voice,
);
const nextManifest = {
...manifest,
dialogues: [...manifest.dialogues, dialogue],
};
setManifest(nextManifest);
setSelectedDialogueId(dialogue.id);
setIsCreatingSrtCue(true);
setStatus("Nouveau dialogue ajoute localement. Creation de la cue FR...");
try {
await createFrenchSrtCue(nextManifest, dialogue);
setStatus(
`Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`,
);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(
`Dialogue ajoute localement, mais cue FR non creee: ${message}`,
);
} finally {
setIsCreatingSrtCue(false);
}
}
function handleRemoveDialogue(dialogueId: string): void {
if (!manifest) return;
const nextDialogues = manifest.dialogues.filter(
(dialogue) => dialogue.id !== dialogueId,
);
setManifest({ ...manifest, dialogues: nextDialogues });
setSelectedDialogueId(nextDialogues[0]?.id ?? "");
setStatus("Dialogue supprime localement.");
}
function updateSelectedDialogue(
patch: DialoguePatch,
nextId = selectedDialogueId,
): void {
if (!manifest || !selectedDialogue) return;
setManifest({
...manifest,
dialogues: manifest.dialogues.map((dialogue) =>
dialogue.id === selectedDialogue.id
? getPatchedDialogue(dialogue, patch)
: dialogue,
),
});
setSelectedDialogueId(nextId);
}
async function handlePreviewDialogue(): Promise<void> {
if (!manifest || !selectedDialogue) return;
if (errors.length > 0) {
setStatus("Corrige les erreurs avant de lancer la preview.");
return;
}
previewAudioRef.current?.pause();
previewAudioRef.current = null;
setIsPreviewing(true);
setStatus(`Preview dialogue: ${selectedDialogue.id}`);
try {
const audio = await playDialogueById(manifest, selectedDialogue.id);
previewAudioRef.current = audio;
if (!audio) {
setStatus("Dialogue introuvable pour la preview.");
return;
}
const handleFinish = (): void => {
audio.removeEventListener("ended", handleFinish);
audio.removeEventListener("pause", handleFinish);
if (previewAudioRef.current === audio) previewAudioRef.current = null;
setIsPreviewing(false);
};
audio.addEventListener("ended", handleFinish);
audio.addEventListener("pause", handleFinish);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
setIsPreviewing(false);
}
}
async function handleCreateFrenchSrtCue(): Promise<void> {
if (!manifest || !selectedDialogue) return;
setIsCreatingSrtCue(true);
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`);
try {
await createFrenchSrtCue(manifest, selectedDialogue);
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
} finally {
setIsCreatingSrtCue(false);
}
}
useEffect(() => {
let mounted = true;
void loadDialogueManifest()
.then((loadedManifest) => {
if (!mounted) return;
setManifest(loadedManifest);
setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? "");
setStatus(
loadedManifest
? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.`
: "Manifeste introuvable ou invalide.",
);
})
.catch((err: unknown) => {
if (!mounted) return;
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(message);
setManifest(null);
});
return () => {
mounted = false;
previewAudioRef.current?.pause();
previewAudioRef.current = null;
};
}, []);
return (
<section
className="editor-dialogue-manifest-section"
aria-labelledby="dialogue-manifest-heading"
>
<div className="editor-section-heading">
<h3 id="dialogue-manifest-heading">Dialogues</h3>
<span>{manifest?.dialogues.length ?? 0} items</span>
</div>
<div className="editor-dialogue-manifest-actions">
<button type="button" onClick={() => void handleLoad()}>
<RefreshCw size={14} aria-hidden="true" />
Reload
</button>
<button
type="button"
disabled={!manifest || isCreatingSrtCue}
onClick={() => void handleAddDialogue()}
>
<Plus size={14} aria-hidden="true" />
{isCreatingSrtCue ? "Adding..." : "Add"}
</button>
<button
type="button"
disabled={!manifest || errors.length > 0 || isSaving}
onClick={() => void handleSave()}
>
<Save size={14} aria-hidden="true" />
{isSaving ? "Saving..." : "Save"}
</button>
</div>
{manifest && (
<label className="editor-dialogue-manifest-select">
Dialogue
<select
value={selectedDialogue?.id ?? ""}
onChange={(event) => setSelectedDialogueId(event.target.value)}
>
{manifest.dialogues.map((dialogue) => (
<option key={dialogue.id} value={dialogue.id}>
{dialogue.id || "Dialogue sans id"}
</option>
))}
</select>
</label>
)}
{selectedDialogue && (
<div className="editor-dialogue-manifest-form">
<label>
ID
<input
value={selectedDialogue.id}
onChange={(event) =>
updateSelectedDialogue(
{ id: event.target.value },
event.target.value,
)
}
/>
</label>
<label>
Voix
<select
value={selectedDialogue.voice}
onChange={(event) =>
updateSelectedDialogue({
voice: event.target.value as DialogueVoiceId,
})
}
>
{voices.map((voice) => (
<option key={voice.id} value={voice.id}>
{voice.speaker}
</option>
))}
</select>
</label>
<label>
Audio
<input
value={selectedDialogue.audio}
onChange={(event) =>
updateSelectedDialogue({ audio: event.target.value })
}
/>
</label>
<label>
Cue SRT
<input
type="number"
min="1"
step="1"
value={selectedDialogue.subtitleCueIndex}
onChange={(event) =>
updateSelectedDialogue({
subtitleCueIndex: Math.max(1, Number(event.target.value)),
})
}
/>
</label>
<label>
Timecode global optionnel
<input
type="number"
min="0"
step="0.1"
value={selectedDialogue.timecode ?? ""}
placeholder="Aucun"
onChange={(event) => {
const value = event.target.value.trim();
updateSelectedDialogue({
timecode: value === "" ? undefined : Number(value),
});
}}
/>
</label>
<button
className="editor-dialogue-manifest-srt-cue"
type="button"
disabled={isCreatingSrtCue}
onClick={() => void handleCreateFrenchSrtCue()}
>
<Plus size={14} aria-hidden="true" />
{isCreatingSrtCue ? "Creating..." : "Create FR SRT cue"}
</button>
<button
className="editor-dialogue-manifest-preview"
type="button"
disabled={errors.length > 0 || isPreviewing}
onClick={() => void handlePreviewDialogue()}
>
<Play size={14} aria-hidden="true" />
{isPreviewing ? "Playing..." : "Preview dialogue"}
</button>
<button
className="editor-dialogue-manifest-delete"
type="button"
onClick={() => handleRemoveDialogue(selectedDialogue.id)}
>
<Trash2 size={14} aria-hidden="true" />
Delete dialogue
</button>
</div>
)}
<p className="editor-dialogue-manifest-status">{status}</p>
<div
className={`editor-dialogue-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
>
<strong>
{errors.length === 0
? "Manifeste local valide."
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
</strong>
{errors.length > 0 && (
<ul>
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
</section>
);
}
+743
View File
@@ -0,0 +1,743 @@
import { useEffect, useRef, useState } from "react";
import { Download, RefreshCw, Save } from "lucide-react";
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
import type {
DialogueDefinition,
DialogueManifest,
DialogueSpeaker,
DialogueVoiceId,
} from "@/types/dialogues/dialogues";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { parseSrt } from "@/utils/subtitles/parseSrt";
interface SrtVoiceOption {
id: DialogueVoiceId;
label: DialogueSpeaker;
}
interface SrtDiagnostic {
cueCount: number;
expectedCueCount: number;
errors: string[];
}
interface TextRange {
start: number;
end: number;
}
interface DialogueValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
type CueTimeEdge = "start" | "end";
const CUE_NUDGE_SECONDS = 0.1;
const SRT_VOICES: SrtVoiceOption[] = [
{ id: "narrateur", label: "Narrateur" },
{ id: "fermier", label: "Fermier" },
{ id: "electricienne", label: "Electricienne" },
];
const DEFAULT_SRT_VOICE: SrtVoiceOption = {
id: "narrateur",
label: "Narrateur",
};
const SRT_LANGUAGES: SubtitleLanguage[] = ["fr", "en"];
const SRT_TIME_LINE_PATTERN =
/^\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}$/;
function getSrtPath(
voice: DialogueVoiceId,
language: SubtitleLanguage,
): string {
return `/sounds/dialogue/subtitles/${language}/${voice}.srt`;
}
function createSrtTemplate(
speaker: DialogueSpeaker,
expectedCueIndexes: number[],
): string {
const cueIndexes = expectedCueIndexes.length > 0 ? expectedCueIndexes : [1];
return `${cueIndexes
.map((cueIndex, index) => {
const startTime = index * 3;
const endTime = startTime + 2;
return `${cueIndex}\n${formatSrtTime(startTime)} --> ${formatSrtTime(endTime)}\n${speaker}: Sous-titre ${cueIndex} a definir`;
})
.join("\n\n")}\n`;
}
function formatSrtTime(totalSeconds: number): string {
const safeSeconds = Math.max(0, totalSeconds);
const totalMilliseconds = Math.round(safeSeconds * 1000);
const milliseconds = totalMilliseconds % 1000;
const totalWholeSeconds = Math.floor(totalMilliseconds / 1000);
const hours = Math.floor(totalWholeSeconds / 3600);
const minutes = Math.floor((totalWholeSeconds % 3600) / 60);
const seconds = totalWholeSeconds % 60;
return `${padTime(hours)}:${padTime(minutes)}:${padTime(seconds)},${padMilliseconds(milliseconds)}`;
}
function formatPreviewTime(totalSeconds: number): string {
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
}
function parseSrtTime(value: string): number | null {
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
if (!match) return null;
const [, hours, minutes, seconds, milliseconds] = match;
if (!hours || !minutes || !seconds || !milliseconds) return null;
return (
Number(hours) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(milliseconds) / 1000
);
}
function padTime(value: number): string {
return value.toString().padStart(2, "0");
}
function padMilliseconds(value: number): string {
return value.toString().padStart(3, "0");
}
function getSrtDiagnostic(
content: string,
expectedCueIndexes: number[],
): SrtDiagnostic {
const normalizedContent = content.replace(/^\uFEFF/, "").replace(/\r/g, "");
const blocks = normalizedContent
.trim()
.split(/\n{2,}/)
.filter(Boolean);
const cues = parseSrt(content);
const errors: string[] = [];
const indexes = new Set<number>();
if (blocks.length === 0) {
errors.push("Le fichier SRT est vide.");
}
blocks.forEach((block, blockIndex) => {
const lines = block
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const displayIndex = blockIndex + 1;
const cueIndex = Number(lines[0]);
if (lines.length < 3) {
errors.push(
`Bloc ${displayIndex}: il manque un index, un timecode ou un texte.`,
);
return;
}
if (!Number.isInteger(cueIndex)) {
errors.push(`Bloc ${displayIndex}: l'index doit etre un nombre entier.`);
} else if (indexes.has(cueIndex)) {
errors.push(`Bloc ${displayIndex}: l'index ${cueIndex} est duplique.`);
} else {
indexes.add(cueIndex);
}
if (!SRT_TIME_LINE_PATTERN.test(lines[1] ?? "")) {
errors.push(
`Bloc ${displayIndex}: le timecode doit utiliser HH:MM:SS,mmm --> HH:MM:SS,mmm.`,
);
}
});
if (blocks.length > 0 && cues.length !== blocks.length) {
errors.push(
"Un ou plusieurs blocs ont une duree invalide ou un timecode illisible.",
);
}
const cueIndexes = new Set(cues.map((cue) => cue.index));
const missingCueIndexes = expectedCueIndexes.filter(
(cueIndex) => !cueIndexes.has(cueIndex),
);
if (missingCueIndexes.length > 0) {
errors.push(
`Cues attendues par le manifeste manquantes: ${missingCueIndexes.join(", ")}.`,
);
}
return {
cueCount: cues.length,
expectedCueCount: expectedCueIndexes.length,
errors,
};
}
function getExpectedCueIndexes(
manifest: DialogueManifest | null,
voice: DialogueVoiceId,
): number[] {
return getExpectedDialogues(manifest, voice)
.map((dialogue) => dialogue.subtitleCueIndex)
.filter(
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
)
.sort((a, b) => a - b);
}
function getExpectedDialogues(
manifest: DialogueManifest | null,
voice: DialogueVoiceId,
): DialogueDefinition[] {
if (!manifest) return [];
return [...manifest.dialogues]
.filter((dialogue) => dialogue.voice === voice)
.sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex);
}
function findCueBlockRange(
content: string,
cueIndex: number,
): TextRange | null {
const normalizedContent = content.replace(/\r/g, "");
const cuePattern = new RegExp(`(^|\\n)${cueIndex}\\n`, "m");
const match = normalizedContent.match(cuePattern);
if (!match || match.index === undefined) return null;
const start = match.index + (match[1] ? 1 : 0);
const nextBlockIndex = normalizedContent.indexOf("\n\n", start);
const end = nextBlockIndex === -1 ? normalizedContent.length : nextBlockIndex;
return { start, end };
}
function updateCueTimecode(
content: string,
cueIndex: number,
edge: CueTimeEdge,
time: number,
): string | null {
const range = findCueBlockRange(content, cueIndex);
if (!range) return null;
const block = content.slice(range.start, range.end);
const lines = block.split("\n");
const timecodeLine = lines[1];
if (!timecodeLine) return null;
const [start, end] = timecodeLine.split(" --> ");
if (!start || !end) return null;
lines[1] =
edge === "start"
? `${formatSrtTime(time)} --> ${end}`
: `${start} --> ${formatSrtTime(time)}`;
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
}
function nudgeCueTimecode(
content: string,
cueIndex: number,
delta: number,
): string | null {
const range = findCueBlockRange(content, cueIndex);
if (!range) return null;
const block = content.slice(range.start, range.end);
const lines = block.split("\n");
const timecodeLine = lines[1];
if (!timecodeLine) return null;
const [start, end] = timecodeLine.split(" --> ");
if (!start || !end) return null;
const startTime = parseSrtTime(start);
const endTime = parseSrtTime(end);
if (startTime === null || endTime === null) return null;
const nextStartTime = Math.max(0, startTime + delta);
const nextEndTime = Math.max(nextStartTime + 0.001, endTime + delta);
lines[1] = `${formatSrtTime(nextStartTime)} --> ${formatSrtTime(nextEndTime)}`;
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
}
function downloadSrtFile(
voice: DialogueVoiceId,
language: SubtitleLanguage,
content: string,
): void {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${voice}.${language}.srt`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function saveSrtFile(
voice: DialogueVoiceId,
language: SubtitleLanguage,
content: string,
): Promise<void> {
const response = await fetch("/api/save-srt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ voice, language, content }),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(body?.error ?? "Sauvegarde SRT impossible");
}
}
async function validateDialogueAssets(): Promise<DialogueValidationResult> {
const response = await fetch("/api/validate-dialogues");
const body = (await response.json().catch(() => null)) as
| Partial<DialogueValidationResult>
| { error?: string }
| null;
if (!body) {
throw new Error("Validation dialogues impossible");
}
if (
"valid" in body &&
typeof body.valid === "boolean" &&
Array.isArray(body.errors) &&
Array.isArray(body.warnings)
) {
return {
valid: body.valid,
errors: body.errors.filter((item) => typeof item === "string"),
warnings: body.warnings.filter((item) => typeof item === "string"),
};
}
throw new Error(
"error" in body && body.error
? body.error
: "Validation dialogues impossible",
);
}
export function EditorSrtPanel(): React.JSX.Element {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [voice, setVoice] = useState<DialogueVoiceId>("narrateur");
const [language, setLanguage] = useState<SubtitleLanguage>("fr");
const [content, setContent] = useState("");
const [status, setStatus] = useState("Chargement du SRT...");
const [isSaving, setIsSaving] = useState(false);
const [isValidatingDialogues, setIsValidatingDialogues] = useState(false);
const [dialogueValidationResult, setDialogueValidationResult] =
useState<DialogueValidationResult | null>(null);
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [selectedDialogueId, setSelectedDialogueId] = useState("");
const selectedVoice =
SRT_VOICES.find((item) => item.id === voice) ?? DEFAULT_SRT_VOICE;
const expectedDialogues = getExpectedDialogues(manifest, voice);
const expectedCueIndexes = getExpectedCueIndexes(manifest, voice);
const parsedCues = parseSrt(content);
const activeCue =
parsedCues.find(
(cue) =>
audioCurrentTime >= cue.startTime && audioCurrentTime < cue.endTime,
) ?? null;
const diagnostic = getSrtDiagnostic(content, expectedCueIndexes);
const isSrtValid = diagnostic.errors.length === 0;
const dialogueValidationClass = dialogueValidationResult
? dialogueValidationResult.valid
? "is-valid"
: "is-invalid"
: "is-idle";
const srtTemplate = createSrtTemplate(
selectedVoice.label,
expectedCueIndexes,
);
const selectedDialogue =
expectedDialogues.find((dialogue) => dialogue.id === selectedDialogueId) ??
expectedDialogues[0] ??
null;
async function handleSave(): Promise<void> {
if (!isSrtValid) {
setStatus("Corrige les erreurs SRT avant de sauvegarder.");
return;
}
setIsSaving(true);
setStatus("Sauvegarde du SRT...");
try {
await saveSrtFile(voice, language, content);
setStatus(`Sauvegarde dans ${getSrtPath(voice, language)}`);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(`${message}. Utilise Export SRT si le serveur dev est absent.`);
} finally {
setIsSaving(false);
}
}
async function handleValidateDialogues(): Promise<void> {
setIsValidatingDialogues(true);
setDialogueValidationResult(null);
try {
const result = await validateDialogueAssets();
setDialogueValidationResult(result);
setStatus(
result.valid
? "Validation dialogues terminee."
: "Validation dialogues terminee avec erreurs.",
);
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
setStatus(`${message}. Verifie que le serveur Vite est lance.`);
} finally {
setIsValidatingDialogues(false);
}
}
function handleJumpToCue(cueIndex: number): void {
const range = findCueBlockRange(content, cueIndex);
if (!range || !textareaRef.current) {
setStatus(`Cue ${cueIndex} introuvable dans le SRT.`);
return;
}
textareaRef.current.focus();
textareaRef.current.setSelectionRange(range.start, range.end);
setStatus(`Cue ${cueIndex} selectionnee dans le SRT.`);
}
function handleSetCueTime(cueIndex: number, edge: CueTimeEdge): void {
const updatedContent = updateCueTimecode(
content,
cueIndex,
edge,
audioCurrentTime,
);
if (!updatedContent) {
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
return;
}
setContent(updatedContent);
setStatus(
`Cue ${cueIndex}: ${edge === "start" ? "debut" : "fin"} place a ${formatSrtTime(audioCurrentTime)}.`,
);
}
function handleNudgeCue(cueIndex: number, delta: number): void {
const updatedContent = nudgeCueTimecode(content, cueIndex, delta);
if (!updatedContent) {
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
return;
}
setContent(updatedContent);
setStatus(
`Cue ${cueIndex} decalee de ${delta > 0 ? "+" : ""}${delta.toFixed(1)}s.`,
);
}
useEffect(() => {
let mounted = true;
void loadDialogueManifest()
.then((loadedManifest) => {
if (mounted) setManifest(loadedManifest);
})
.catch(() => {
if (mounted) setManifest(null);
});
return () => {
mounted = false;
};
}, []);
useEffect(() => {
let mounted = true;
const srtPath = getSrtPath(voice, language);
void fetch(srtPath)
.then(async (response) => {
if (!mounted) return;
if (!response.ok) {
setContent(srtTemplate);
setStatus("Fichier absent, template local cree");
return;
}
setContent(await response.text());
setStatus(`Charge depuis ${srtPath}`);
})
.catch(() => {
if (!mounted) return;
setContent(srtTemplate);
setStatus("Erreur de chargement, template local cree");
});
return () => {
mounted = false;
};
}, [language, selectedVoice.label, srtTemplate, voice]);
return (
<section className="editor-srt-section" aria-labelledby="srt-heading">
<div className="editor-section-heading">
<h3 id="srt-heading">SRT</h3>
<span>{language.toUpperCase()}</span>
</div>
<div className="editor-srt-controls">
<label>
Voix
<select
value={voice}
onChange={(event) =>
setVoice(event.target.value as DialogueVoiceId)
}
>
{SRT_VOICES.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label>
Langue
<select
value={language}
onChange={(event) =>
setLanguage(event.target.value as SubtitleLanguage)
}
>
{SRT_LANGUAGES.map((item) => (
<option key={item} value={item}>
{item.toUpperCase()}
</option>
))}
</select>
</label>
</div>
<div className="editor-srt-preview">
<label>
Dialogue audio
<select
value={selectedDialogue?.id ?? ""}
onChange={(event) => setSelectedDialogueId(event.target.value)}
disabled={expectedDialogues.length === 0}
>
{expectedDialogues.length === 0 && (
<option value="">Aucun dialogue</option>
)}
{expectedDialogues.map((dialogue) => (
<option key={dialogue.id} value={dialogue.id}>
Cue {dialogue.subtitleCueIndex} - {dialogue.id}
</option>
))}
</select>
</label>
{selectedDialogue && (
<div className="editor-srt-audio-card">
<span>Cue {selectedDialogue.subtitleCueIndex}</span>
<strong>{selectedDialogue.id}</strong>
<audio
key={selectedDialogue.audio}
controls
src={selectedDialogue.audio}
onLoadedMetadata={() => setAudioCurrentTime(0)}
onTimeUpdate={(event) =>
setAudioCurrentTime(event.currentTarget.currentTime)
}
/>
<div className="editor-srt-active-cue">
<span>Temps audio: {formatPreviewTime(audioCurrentTime)}</span>
{activeCue ? (
<p>
<strong>Cue {activeCue.index}</strong> {activeCue.text}
</p>
) : (
<p>Aucune cue active a ce moment.</p>
)}
</div>
<div className="editor-srt-time-actions">
<button
type="button"
onClick={() =>
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start")
}
>
Set start
</button>
<button
type="button"
onClick={() =>
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end")
}
>
Set end
</button>
<button
type="button"
onClick={() =>
handleNudgeCue(
selectedDialogue.subtitleCueIndex,
-CUE_NUDGE_SECONDS,
)
}
>
-100ms
</button>
<button
type="button"
onClick={() =>
handleNudgeCue(
selectedDialogue.subtitleCueIndex,
CUE_NUDGE_SECONDS,
)
}
>
+100ms
</button>
</div>
<button
className="editor-srt-jump-button"
type="button"
onClick={() => handleJumpToCue(selectedDialogue.subtitleCueIndex)}
>
Aller a la cue {selectedDialogue.subtitleCueIndex}
</button>
</div>
)}
</div>
<textarea
ref={textareaRef}
className="editor-srt-textarea"
value={content}
spellCheck={false}
onChange={(event) => setContent(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
aria-label="SRT content"
/>
<div className="editor-srt-actions">
<button
className="editor-action-button"
type="button"
onClick={() => setContent(srtTemplate)}
>
<RefreshCw size={15} aria-hidden="true" />
Template
</button>
<button
className="editor-action-button editor-action-button-primary"
type="button"
disabled={isSaving || !isSrtValid}
onClick={() => void handleSave()}
>
<Save size={15} aria-hidden="true" />
{isSaving ? "Saving..." : "Save SRT"}
</button>
<button
className="editor-action-button"
type="button"
onClick={() => downloadSrtFile(voice, language, content)}
>
<Download size={15} aria-hidden="true" />
Export SRT
</button>
</div>
<p className="editor-srt-status">{status}</p>
<div className={`editor-dialogue-validation ${dialogueValidationClass}`}>
<div className="editor-dialogue-validation__heading">
<div>
<strong>Manifeste dialogues</strong>
<span>Audio, SRT FR et cues references</span>
</div>
<button
type="button"
disabled={isValidatingDialogues}
onClick={() => void handleValidateDialogues()}
>
<RefreshCw size={14} aria-hidden="true" />
{isValidatingDialogues ? "Validation..." : "Validate"}
</button>
</div>
{dialogueValidationResult && (
<div className="editor-dialogue-validation__result">
<p>
{dialogueValidationResult.valid
? "Manifeste valide."
: `${dialogueValidationResult.errors.length} erreur${dialogueValidationResult.errors.length > 1 ? "s" : ""} detectee${dialogueValidationResult.errors.length > 1 ? "s" : ""}.`}
{dialogueValidationResult.warnings.length > 0 &&
` ${dialogueValidationResult.warnings.length} warning${dialogueValidationResult.warnings.length > 1 ? "s" : ""}.`}
</p>
{dialogueValidationResult.errors.length > 0 && (
<ul className="editor-dialogue-validation__errors">
{dialogueValidationResult.errors.map((error, index) => (
<li key={`${error}-${index}`}>{error}</li>
))}
</ul>
)}
{dialogueValidationResult.warnings.length > 0 && (
<ul className="editor-dialogue-validation__warnings">
{dialogueValidationResult.warnings.map((warning, index) => (
<li key={`${warning}-${index}`}>{warning}</li>
))}
</ul>
)}
</div>
)}
</div>
<div
className={`editor-srt-diagnostic ${isSrtValid ? "is-valid" : "is-invalid"}`}
>
<strong>
{isSrtValid
? `${diagnostic.cueCount} cue${diagnostic.cueCount > 1 ? "s" : ""} valide${diagnostic.cueCount > 1 ? "s" : ""} / ${diagnostic.expectedCueCount} attendue${diagnostic.expectedCueCount > 1 ? "s" : ""}`
: `${diagnostic.errors.length} erreur${diagnostic.errors.length > 1 ? "s" : ""} SRT`}
</strong>
{!isSrtValid && (
<ul>
{diagnostic.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
</section>
);
}
+14 -2
View File
@@ -11,6 +11,7 @@ interface EditorMapProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
transformMode: TransformMode; transformMode: TransformMode;
@@ -28,6 +29,7 @@ interface EditorNodeCommonProps {
isHovered: boolean; isHovered: boolean;
objectsMapRef: EditorNodeObjectRef; objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
} }
@@ -108,11 +110,13 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers( function createEditorNodePointerHandlers(
index: number, index: number,
onSelectNode: (index: number | null) => void, onSelectNode: (index: number | null) => void,
isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void, onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers { ): EditorNodePointerHandlers {
return { return {
onClick: (event) => { onClick: (event) => {
event.stopPropagation(); event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(index); onSelectNode(index);
}, },
onPointerEnter: (event) => { onPointerEnter: (event) => {
@@ -130,6 +134,7 @@ export function EditorMap({
sceneData, sceneData,
selectedNodeIndex, selectedNodeIndex,
onSelectNode, onSelectNode,
isSelectionLocked,
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
transformMode, transformMode,
@@ -192,8 +197,9 @@ export function EditorMap({
<axesHelper args={[10]} /> <axesHelper args={[10]} />
<group <group
onClick={(e: ThreeEvent<MouseEvent>) => { onClick={(event: ThreeEvent<MouseEvent>) => {
e.stopPropagation(); event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(null); onSelectNode(null);
}} }}
> >
@@ -211,6 +217,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index} isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
); );
@@ -224,6 +231,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index} isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
); );
@@ -251,6 +259,7 @@ function EditorModelNode({
isHovered, isHovered,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps & { }: EditorNodeCommonProps & {
modelUrl: string; modelUrl: string;
@@ -269,6 +278,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
); );
useRegisteredEditorNode(groupRef, index, node, objectsMapRef); useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
@@ -343,12 +353,14 @@ function EditorFallbackNode({
isHovered, isHovered,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps) { }: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null); const meshRef = useRef<THREE.Mesh>(null);
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
); );
useRegisteredEditorNode(meshRef, index, node, objectsMapRef); useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
+108 -4
View File
@@ -1,13 +1,23 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { OrbitControls } from "@react-three/drei"; import { OrbitControls } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import gsap from "gsap";
import * as THREE from "three";
import { EditorMap } from "@/components/editor/scene/EditorMap"; import { EditorMap } from "@/components/editor/scene/EditorMap";
import { FlyController } from "@/controls/editor/FlyController"; import { FlyController } from "@/controls/editor/FlyController";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor"; import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
export interface EditorCinematicPreviewRequest {
id: string;
cinematic: CinematicDefinition;
}
interface EditorSceneProps { interface EditorSceneProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
transformMode: TransformMode; transformMode: TransformMode;
@@ -18,12 +28,15 @@ interface EditorSceneProps {
onUndo: () => void; onUndo: () => void;
onRedo: () => void; onRedo: () => void;
isPlayerMode?: boolean; isPlayerMode?: boolean;
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
onCinematicPreviewComplete?: (() => void) | undefined;
} }
export function EditorScene({ export function EditorScene({
sceneData, sceneData,
selectedNodeIndex, selectedNodeIndex,
onSelectNode, onSelectNode,
isSelectionLocked,
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
transformMode, transformMode,
@@ -34,7 +47,11 @@ export function EditorScene({
onUndo, onUndo,
onRedo, onRedo,
isPlayerMode = false, isPlayerMode = false,
cinematicPreviewRequest = null,
onCinematicPreviewComplete,
}: EditorSceneProps): React.JSX.Element { }: EditorSceneProps): React.JSX.Element {
const isCinematicPreviewing = cinematicPreviewRequest !== null;
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
@@ -53,7 +70,7 @@ export function EditorScene({
if (selectedNodeIndex !== null) { if (selectedNodeIndex !== null) {
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case "escape": case "escape":
onSelectNode(null); if (!isSelectionLocked) onSelectNode(null);
break; break;
case "t": case "t":
onTransformModeChange("translate"); onTransformModeChange("translate");
@@ -70,14 +87,27 @@ export function EditorScene({
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]); }, [
isSelectionLocked,
selectedNodeIndex,
onSelectNode,
onTransformModeChange,
onUndo,
onRedo,
]);
return ( return (
<> <>
<EditorCinematicPreviewPlayer
request={cinematicPreviewRequest}
onComplete={onCinematicPreviewComplete}
/>
{isPlayerMode ? ( {isPlayerMode ? (
<FlyController disabled={false} /> <FlyController disabled={isCinematicPreviewing} />
) : ( ) : (
<OrbitControls <OrbitControls
enabled={!isCinematicPreviewing}
enableDamping enableDamping
dampingFactor={0.05} dampingFactor={0.05}
mouseButtons={{ mouseButtons={{
@@ -92,6 +122,7 @@ export function EditorScene({
sceneData={sceneData} sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
transformMode={transformMode} transformMode={transformMode}
@@ -106,3 +137,76 @@ export function EditorScene({
</> </>
); );
} }
interface EditorCinematicPreviewPlayerProps {
request: EditorCinematicPreviewRequest | null;
onComplete?: (() => void) | undefined;
}
function EditorCinematicPreviewPlayer({
request,
onComplete,
}: EditorCinematicPreviewPlayerProps): null {
const camera = useThree((state) => state.camera);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
useEffect(() => {
timelineRef.current?.kill();
timelineRef.current = null;
if (!request) return undefined;
const firstKeyframe = request.cinematic.cameraKeyframes[0];
if (!firstKeyframe) return undefined;
const target = new THREE.Vector3(...firstKeyframe.target);
camera.position.set(...firstKeyframe.position);
camera.lookAt(target);
const timeline = gsap.timeline({
onUpdate: () => camera.lookAt(target),
onComplete: () => {
timelineRef.current = null;
onComplete?.();
},
});
request.cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
const previousKeyframe = request.cinematic.cameraKeyframes[index];
if (!previousKeyframe) return;
const duration = keyframe.time - previousKeyframe.time;
timeline.to(
camera.position,
{
x: keyframe.position[0],
y: keyframe.position[1],
z: keyframe.position[2],
duration,
ease: "power2.inOut",
},
previousKeyframe.time,
);
timeline.to(
target,
{
x: keyframe.target[0],
y: keyframe.target[1],
z: keyframe.target[2],
duration,
ease: "power2.inOut",
},
previousKeyframe.time,
);
});
timelineRef.current = timeline;
return () => {
timeline.kill();
if (timelineRef.current === timeline) timelineRef.current = null;
};
}, [camera, onComplete, request]);
return null;
}
@@ -81,7 +81,9 @@ export function TriggerObject({
bodyRef={rbRef} bodyRef={rbRef}
onPress={() => { onPress={() => {
if (soundPath) { if (soundPath) {
AudioManager.getInstance().playSound(soundPath, soundVolume); AudioManager.getInstance().playSound(soundPath, soundVolume, {
category: "sfx",
});
} }
onTrigger?.(); onTrigger?.();
+16 -16
View File
@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useAnimations } from "@react-three/drei"; import { useAnimations } from "@react-three/drei";
import type { AnimationAction } from "three"; import type { AnimationAction } from "three";
import * as THREE from "three";
import { import {
AnimatedModelContext, AnimatedModelContext,
type AnimatedModelContextValue, type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel"; } from "@/components/three/models/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps } from "@/types/three/three";
export interface AnimatedModelConfig extends ModelTransformProps { export interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string; modelPath: string;
@@ -37,15 +36,13 @@ export function AnimatedModel({
onAnimationEnd, onAnimationEnd,
children, children,
}: AnimatedModelProps): React.JSX.Element { }: AnimatedModelProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene, animations } = useLoggedGLTF(modelPath, { const { scene, animations } = useLoggedGLTF(modelPath, {
scope: "AnimatedModel", scope: "AnimatedModel",
position, position,
rotation, rotation,
scale, scale,
}); });
const model = useMemo(() => scene.clone(true), [scene]); const { actions, names, mixer } = useAnimations(animations, scene);
const { actions, names, mixer } = useAnimations(animations, groupRef);
const [currentAnim, setCurrentAnim] = useState(defaultAnimation); const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
const isReady = names.length > 0; const isReady = names.length > 0;
@@ -146,19 +143,22 @@ export function AnimatedModel({
names, names,
}; };
const parsedScale = useEffect(() => {
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale; scene.position.set(...position);
scene.rotation.set(rotation[0], rotation[1], rotation[2]);
const parsedScale =
typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]);
scene.scale.set(
parsedScale[0] ?? 1,
parsedScale[1] ?? 1,
parsedScale[2] ?? 1,
);
}, [scene, position, rotation, scale]);
return ( return (
<AnimatedModelContext.Provider value={contextValue}> <AnimatedModelContext.Provider value={contextValue}>
<group <primitive object={scene} />
ref={groupRef}
position={position}
rotation={rotation}
scale={parsedScale}
>
<primitive object={model} />
</group>
{children} {children}
</AnimatedModelContext.Provider> </AnimatedModelContext.Provider>
); );
+203
View File
@@ -0,0 +1,203 @@
import { useEffect } from "react";
import { X } from "lucide-react";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type {
RepairRuntime,
SubtitleLanguage,
} from "@/managers/stores/useSettingsStore";
function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
function clearCookies(): void {
document.cookie.split(";").forEach((cookie) => {
const cookieName = cookie.split("=")[0]?.trim();
if (!cookieName) return;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
});
}
interface VolumeSliderProps {
id: string;
label: string;
value: number;
onChange: (value: number) => void;
}
function VolumeSlider({
id,
label,
value,
onChange,
}: VolumeSliderProps): React.JSX.Element {
return (
<label className="game-settings-menu__slider" htmlFor={id}>
<span>
{label}
<strong>{formatPercent(value)}</strong>
</span>
<input
id={id}
type="range"
min="0"
max="1"
step="0.01"
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
export function GameSettingsMenu(): React.JSX.Element | null {
const {
isSettingsMenuOpen,
musicVolume,
sfxVolume,
dialogueVolume,
subtitlesEnabled,
subtitleLanguage,
repairRuntime,
setMusicVolume,
setSfxVolume,
setDialogueVolume,
setSettingsMenuOpen,
setSubtitlesEnabled,
setSubtitleLanguage,
setRepairRuntime,
} = useSettingsStore();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (!isSettingsMenuOpen) document.exitPointerLock();
setSettingsMenuOpen(!isSettingsMenuOpen);
return;
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
};
}, [isSettingsMenuOpen, setSettingsMenuOpen]);
if (!isSettingsMenuOpen) return null;
const handleQuit = (): void => {
clearCookies();
window.location.assign("/");
};
return (
<div className="game-settings-menu" role="dialog" aria-modal="true">
<div className="game-settings-menu__panel">
<header className="game-settings-menu__header">
<div>
<span>Pause</span>
<h2>Options</h2>
</div>
<button
className="game-settings-menu__close"
type="button"
onClick={() => setSettingsMenuOpen(false)}
aria-label="Fermer le menu"
>
<X size={20} aria-hidden="true" />
</button>
</header>
<section
className="game-settings-menu__section"
aria-labelledby="audio-settings-heading"
>
<h3 id="audio-settings-heading">Audio</h3>
<VolumeSlider
id="music-volume"
label="Musique"
value={musicVolume}
onChange={setMusicVolume}
/>
<VolumeSlider
id="sfx-volume"
label="Sound effects"
value={sfxVolume}
onChange={setSfxVolume}
/>
<VolumeSlider
id="dialogue-volume"
label="Dialogue"
value={dialogueVolume}
onChange={setDialogueVolume}
/>
</section>
<section
className="game-settings-menu__section"
aria-labelledby="subtitle-settings-heading"
>
<h3 id="subtitle-settings-heading">Sous-titres</h3>
<label className="game-settings-menu__checkbox">
<input
type="checkbox"
checked={subtitlesEnabled}
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
/>
Afficher sous-titres
</label>
<div
className="game-settings-menu__choice-group"
aria-label="Langue des sous-titres"
>
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
<button
key={language}
type="button"
className={subtitleLanguage === language ? "active" : undefined}
onClick={() => setSubtitleLanguage(language)}
aria-pressed={subtitleLanguage === language}
>
{language === "fr" ? "Francais" : "English"}
</button>
))}
</div>
</section>
<section
className="game-settings-menu__section"
aria-labelledby="repair-settings-heading"
>
<h3 id="repair-settings-heading">Repair game</h3>
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
<button
key={runtime}
type="button"
className={repairRuntime === runtime ? "active" : undefined}
onClick={() => setRepairRuntime(runtime)}
aria-pressed={repairRuntime === runtime}
>
{runtime === "js"
? "Repair game en JS (local)"
: "Repair game en Python (server)"}
</button>
))}
</div>
</section>
<button
className="game-settings-menu__quit"
type="button"
onClick={handleQuit}
>
Quitter
</button>
</div>
</div>
);
}
+4
View File
@@ -1,8 +1,10 @@
import { Crosshair } from "@/components/ui/Crosshair"; import { Crosshair } from "@/components/ui/Crosshair";
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout"; import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator"; import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles";
export function GameUI(): React.JSX.Element { export function GameUI(): React.JSX.Element {
return ( return (
@@ -12,6 +14,8 @@ export function GameUI(): React.JSX.Element {
<RepairMovementLockIndicator /> <RepairMovementLockIndicator />
<InteractPrompt /> <InteractPrompt />
<HandTrackingVisualizer /> <HandTrackingVisualizer />
<Subtitles />
<GameSettingsMenu />
</> </>
); );
} }
+37
View File
@@ -0,0 +1,37 @@
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
export type SubtitleSpeaker = DialogueSpeaker;
interface SubtitlesProps {
speaker?: SubtitleSpeaker | null;
text?: string | null;
}
export function Subtitles({
speaker = null,
text = null,
}: SubtitlesProps): React.JSX.Element | null {
const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled);
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const subtitleSpeaker = speaker ?? activeSubtitle?.speaker ?? null;
const content = (text ?? activeSubtitle?.text)?.trim();
if (!subtitlesEnabled || !content) return null;
return (
<div className="subtitles" aria-live="polite">
<p>
{subtitleSpeaker ? (
<span
className={`subtitles__speaker subtitles__speaker--${subtitleSpeaker.toLowerCase()}`}
>
{subtitleSpeaker}:
</span>
) : null}
{content}
</p>
</div>
);
}
+51 -10
View File
@@ -26,29 +26,59 @@ export const docGroups: DocGroup[] = [
subtitle: "Runtime structure", subtitle: "Runtime structure",
meta: "02", meta: "02",
}, },
{
path: "/docs/scene-runtime",
title: "Scene Runtime",
subtitle: "Loading and spawn gates",
meta: "03",
},
{
path: "/docs/repair-game",
title: "Repair Game",
subtitle: "Gameplay implementation",
meta: "04",
},
{
path: "/docs/interaction",
title: "Interaction System",
subtitle: "Trigger, grab, hand input",
meta: "05",
},
{ {
path: "/docs/target-architecture", path: "/docs/target-architecture",
title: "Target Architecture", title: "Target Architecture",
subtitle: "Next direction", subtitle: "Next direction",
meta: "03", meta: "06",
}, },
{ {
path: "/docs/technical-editor", path: "/docs/technical-editor",
title: "Editor Technical Notes", title: "Editor Technical Notes",
subtitle: "Implementation details", subtitle: "Implementation details",
meta: "04", meta: "07",
},
{
path: "/docs/audio",
title: "Audio Technical Notes",
subtitle: "Music, dialogue, SRT, and SFX",
meta: "08",
}, },
{ {
path: "/docs/hand-tracking", path: "/docs/hand-tracking",
title: "Hand Tracking Technical Notes", title: "Hand Tracking Technical Notes",
subtitle: "Webcam interaction pipeline", subtitle: "Webcam interaction pipeline",
meta: "05", meta: "09",
}, },
{ {
path: "/docs/zustand", path: "/docs/zustand",
title: "Zustand Game State", title: "Zustand Stores",
subtitle: "Progression store", subtitle: "Game, settings, subtitles",
meta: "06", meta: "10",
},
{
path: "/docs/three-debugging",
title: "Three Debugging",
subtitle: "Step into Three.js internals",
meta: "11",
}, },
], ],
}, },
@@ -59,25 +89,36 @@ export const docGroups: DocGroup[] = [
path: "/docs/features", path: "/docs/features",
title: "Features", title: "Features",
subtitle: "Implemented scope", subtitle: "Implemented scope",
meta: "07", meta: "12",
}, },
{ {
path: "/docs/main-feature", path: "/docs/main-feature",
title: "Main Feature", title: "Main Feature",
subtitle: "Repair-game prototype", subtitle: "Repair-game prototype",
meta: "08", meta: "13",
}, },
{ {
path: "/docs/editor", path: "/docs/editor",
title: "Editor User Guide", title: "Editor User Guide",
subtitle: "Editing workflow", subtitle: "Editing workflow",
meta: "09", meta: "14",
}, },
{ {
path: "/docs/animation", path: "/docs/animation",
title: "Animation & 3D Model System", title: "Animation & 3D Model System",
subtitle: "Components and usage", subtitle: "Components and usage",
meta: "010", meta: "15",
},
],
},
{
label: "Review",
sections: [
{
path: "/docs/code-review",
title: "Code Review Prep",
subtitle: "Presentation support",
meta: "16",
}, },
], ],
}, },
-545
View File
@@ -1,545 +0,0 @@
export const readmeFr = `# La-Fabrik
Une expérience web 3D interactive pour La Fabrik Durable, un service low-tech de réparation et de transformation situé à Altera, une ville post-capitaliste reconstruite en 2039. Les joueurs incarnent un technicien fraîchement intégré et vivent une journée de service : réparer un vélo électrique, remettre en état un réseau d'énergie et améliorer le système d'irrigation d'une ferme verticale.
Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans installation côté utilisateur.
## Stack technique
### Build et langage
| Package |
| -------------------------------------------------- |
| [TypeScript](https://www.typescriptlang.org/docs/) |
| [React](https://react.dev/learn) |
| [Vite](https://vite.dev/guide/) |
| [ESLint](https://eslint.org/docs/latest/) |
| [Prettier](https://prettier.io/docs/) |
### Moteur 3D
| Package |
| ----------------------------------------------------------------------------------------- |
| [Three.js](https://threejs.org/docs/) |
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
| [@react-three/drei](https://pmndrs.github.io/drei) |
| [@react-three/rapier](https://rapier.rs/docs/) |
| [GSAP](https://gsap.com/docs/v3/Installation/) |
### Performance et effets
| Package |
| --------------------------------------------------------------------------- |
| [r3f-perf](https://github.com/utsuboco/r3f-perf) |
| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) |
## Structure du projet
\`\`\`
la-fabrik/
├── public/
│ ├── models/
│ │ ├── map/ # Carte de base, chargée au démarrage
│ │ ├── workshop/
│ │ ├── powerGrid/
│ │ └── farm/
│ ├── textures/
│ └── sounds/
└── src/
├── world/ # Composition du monde 3D persistant
│ ├── World.tsx # Composition de la scène active
│ ├── GameMap.tsx # Chargement de carte et collision octree
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
│ ├── Environment.tsx # Arrière-plan et modèle de ciel
│ ├── GameMusic.tsx # Cycle de vie de la musique de jeu
│ ├── debug/ # Scène de test debug
│ └── player/ # Contrôleur joueur et caméra
├── components/
│ ├── three/ # Composants R3F par domaine
│ └── ui/ # Overlays HTML hors Canvas
├── managers/ # Logique, état et orchestration
├── hooks/ # Hooks React autour des managers
├── data/ # Configuration statique
├── shaders/ # Shaders GLSL
└── utils/ # Utilitaires partagés et debug
\`\`\`
## Démarrage
\`\`\`bash
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
cd La-Fabrik
npm install
npm run dev
\`\`\`
- application : \`http://localhost:5173\`
- mode debug : \`http://localhost:5173?debug\`
## Licence
Voir le fichier [LICENSE](./LICENSE).
`;
export const architectureFr = `# Architecture actuelle
Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
## Structure runtime
- \`src/App.tsx\` monte le \`RouterProvider\`, qui pilote l'affichage des vues de l'application.
- \`src/pages/page.tsx\` monte le \`Canvas\`, le \`World\` 3D, l'overlay de performance debug et les overlays HTML.
- \`src/world/World.tsx\` compose la scène active avec :
- l'environnement et l'éclairage
- les helpers debug et le mode caméra debug
- soit la carte principale, soit la scène de test physique debug
- le rig joueur quand le mode caméra actif est \`player\`
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
- \`src/world/GameStageContent.tsx\` est enveloppé dans le contexte Rapier \`Physics\` dans la scène de jeu de production afin que les objets gameplay de stage puissent utiliser la physique sans migrer la carte ou le joueur vers Rapier. Il monte maintenant des instances réutilisables de \`RepairGame\` pour les états de mission \`bike\`, \`pylone\` et \`ferme\`.
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique, avec les objets existants de grab, trigger et preview de modèle, plus des zones playground de réparation séparées \`Bike\`, \`Pylone\` et \`Farm\`.
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut, le verrouillage de déplacement pendant les étapes repair et les inputs d'interaction.
## Frontières physiques
Le projet utilise actuellement deux couches de collision avec des responsabilités séparées :
- \`GameMap\` construit une octree utilisée par le contrôleur joueur pour les collisions avec la carte.
- \`GameStageContent\` est enveloppé dans Rapier \`Physics\` pour les objets gameplay comme les triggers de réparation, les mallettes, les objets saisissables et les futurs objets spécifiques aux missions.
- \`TestMap\` possède son propre playground Rapier \`Physics\` afin de peaufiner le gameplay de réparation par state de mission sans dépendre du placement de la carte de production.
Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il n'existe pas de plan de migration volontaire. Cela évite de mélanger les règles de déplacement joueur avec la physique d'objets avant que les systèmes gameplay en aient besoin.
## Modèle d'interaction
- \`src/managers/InteractionManager.ts\` est la source d'état actuelle des interactions.
- \`src/components/three/interaction/InteractableObject.tsx\` gère la détection de focus par distance et raycasting.
- \`src/components/three/interaction/TriggerObject.tsx\` implémente les interactions de type trigger.
- \`src/components/three/interaction/GrabbableObject.tsx\` implémente les interactions saisir / relâcher.
- \`src/hooks/interaction/useInteraction.ts\` expose un snapshot d'interaction à l'UI React.
- \`src/components/ui/InteractPrompt.tsx\` affiche le prompt \`E\` pour les interactions trigger.
## Audio
- \`src/managers/AudioManager.ts\` fournit actuellement une lecture de sons one-shot avec pool.
- Les interactions trigger peuvent lancer directement un son via \`AudioManager\`.
## Système debug
- Le mode debug est activé avec \`?debug\`.
- \`src/utils/debug/Debug.ts\` possède l'instance \`lil-gui\` et les contrôles debug.
- \`src/hooks/debug/useCameraMode.ts\` et \`src/hooks/debug/useSceneMode.ts\` s'abonnent à l'état debug.
- \`src/components/debug/DebugPerf.tsx\` monte \`r3f-perf\` en lazy uniquement en mode debug.
- \`src/components/ui/debug/DebugOverlayLayout.tsx\` monte l'overlay HTML debug compact quand il est activé depuis \`lil-gui\`.
- \`src/components/ui/debug/GameStateDebugPanel.tsx\` expose l'état de jeu courant, le changement de main/sub-state, les contrôles previous/next step et le reset.
- \`src/components/ui/debug/HandTrackingDebugPanel.tsx\` affiche le statut hand tracking, l'usage, le modèle de gant chargé, le nombre de mains et l'état fist pendant l'activation du hand tracking.
- \`src/components/three/handTracking/HandTrackingGlove.tsx\` place les modèles riggés \`gant_l\` et \`gant_r\` sur les mains détectées dans la scène physics debug.
- \`src/components/debug/scene/DebugHelpers.tsx\` monte les helpers debug.
- \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug.
- Les contrôles globaux \`lil-gui\` incluent camera mode, scene mode, \`R3F Perf\` et \`Debug Overlay\`; les contrôles d'interaction vivent dans le dossier \`Interaction\`.
## Domaines de composants 3D
- \`src/components/three/models/\` contient les helpers de modèles réutilisables comme \`ExplodableModel\`.
- \`src/components/three/interaction/\` contient les wrappers d'interaction réutilisables comme \`InteractableObject\`, \`TriggerObject\` et \`GrabbableObject\`.
- \`src/components/three/handTracking/\` contient les modèles debug R3F liés au hand tracking, comme les gants.
- \`src/components/three/gameplay/\` contient les composants de gameplay de réparation : le flow de production réutilisable \`RepairGame\`, la mallette, les étapes de réparation et les prompts.
- \`src/components/three/world/\` contient les objets world/environnement réutilisables comme \`SkyModel\`.
## Limites actuelles
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
- L'état de mission existe dans Zustand, mais les zones, cinématiques, dialogues et le flow complet de réparation ne sont pas implémentés.
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
`;
export const targetArchitectureFr = `# Architecture cible
Ce document décrit l'architecture visée à moyen terme pour le projet.
## Relation avec le code actuel
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
- Ce document décrit une direction d'architecture, pas un comportement implémenté.
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
## Objectifs
- Garder \`App.tsx\` petit et centré sur l'orchestration.
- Séparer le code de production du monde des chemins runtime uniquement debug.
- Garder une source de vérité claire par responsabilité.
- Faire grandir les systèmes gameplay progressivement, sans préconstruire une architecture vide.
## Couches prévues
### Couche App
- \`App.tsx\` monte la scène canvas et les overlays HTML de premier niveau.
- Il doit rester fin et éviter la logique gameplay.
### Couche World
- \`src/world/\` doit contenir la composition de scène de production et les objets de scène de production.
- Responsabilités attendues :
- composition du monde
- carte, environnement, éclairage
- contrôleur joueur
- ancres d'interaction de production
- post-processing de production si nécessaire
### Couche Debug
- Les scènes et outils uniquement debug doivent être isolés du chemin de production.
- Responsabilités attendues :
- \`lil-gui\`
- overlay de performance
- helpers de scène
- caméra libre et contrôles de calibration
- scènes temporaires de test utilisées pendant le développement
### Couche UI
- \`src/components/ui/\` doit contenir les overlays HTML visibles par le joueur.
- Exemples futurs :
- crosshair
- flow de chargement
- HUD de mission
- overlays narratifs
### Couche Gameplay
- À mesure que le projet grandit, l'état gameplay peut évoluer vers une couche d'orchestration plus claire.
- Sujets probables :
- missions
- zones
- cinématiques
- dialogues
- audio
- interactions
## Règles
- Préférer du code direct et fonctionnel plutôt qu'un échafaudage spéculatif.
- Les types partagés doivent rester proches de leur domaine jusqu'à avoir plusieurs vrais consommateurs.
- Éviter de créer de nouveaux managers ou services sans besoin runtime actif.
- Les chemins runtime uniquement debug doivent être clairement marqués et faciles à retirer plus tard.
`;
export const zustandFr = `# État de jeu Zustand
Ce document explique comment Zustand est utilisé dans le projet actuel.
## Pourquoi Zustand existe ici
Le projet a besoin d'une source de vérité partagée pour suivre la progression du joueur dans l'expérience.
La progression actuelle est découpée en main states :
| Main state | Rôle |
| --- | --- |
| \`intro\` | Onboarding et séquence d'ouverture |
| \`bike\` | Séquence de réparation du vélo électrique |
| \`pylone\` | Séquence du réseau électrique |
| \`ferme\` | Séquence de la ferme verticale |
| \`outro\` | Séquence de fin |
Chaque main state peut aussi posséder un sous-état plus fin, comme l'étape de mission courante, l'audio de dialogue ou des flags de complétion.
Zustand est utile parce que les composants React et React Three Fiber peuvent s'abonner uniquement à la partie de state dont ils ont besoin. Quand cette partie change, seuls les composants abonnés se mettent à jour.
## Emplacement du store
Le store de progression du jeu vit ici :
\`\`\`txt
src/managers/stores/useGameStore.ts
\`\`\`
Le store est placé dans \`src/managers/stores/\` parce qu'il appartient à la couche d'orchestration gameplay, pas à un composant visuel précis.
## Managers vs Store
Les managers sont responsables des objets runtime locaux et des comportements impératifs.
Exemples :
- \`AudioManager\` possède les éléments audio et les pools de sons.
- \`InteractionManager\` possède les handles d'interaction transitoires et la logique orientée input.
Un manager peut lire ou mettre à jour le store Zustand quand son comportement local doit impacter la progression globale du jeu.
Le store Zustand est responsable de l'état global durable :
- main state courant
- sous-état de mission
- flags de progression
- références de dialogue/audio
- transitions de state
Règle simple :
- manager = objets runtime, effets de bord et logique impérative locale
- store = état gameplay global auquel l'UI ou le world peuvent s'abonner
## Forme actuelle
Le store expose :
- \`mainState\` : phase active du jeu
- \`intro\` : état spécifique à l'intro
- \`bike\` : état de la mission vélo
- \`pylone\` : état de la mission réseau électrique
- \`ferme\` : état de la mission ferme
- \`outro\` : état de fin
- des actions de mise à jour directe et des actions de progression
Les étapes de mission utilisent actuellement cette séquence :
\`\`\`ts
"locked" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "reassembling" | "done"
\`\`\`
## Lire le state dans un composant
Utilise des selectors pour lire uniquement ce dont le composant a besoin.
\`\`\`tsx
import { useGameStore } from "@/managers/stores/useGameStore";
export function Example(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
return <p>State courant : {mainState}</p>;
}
\`\`\`
C'est mieux que de lire tout le store, car le composant se re-render uniquement quand \`mainState\` change.
## Mettre à jour le state
Préfère les actions explicites du store.
\`\`\`ts
const advanceGameState = useGameStore((state) => state.advanceGameState);
advanceGameState();
\`\`\`
Pour le développement et le debug, des setters directs existent aussi :
\`\`\`ts
const setMainState = useGameStore((state) => state.setMainState);
setMainState("bike");
\`\`\`
Les setters directs sont pratiques pour les panneaux debug, mais le gameplay de production devrait préférer les actions métier comme \`advanceGameState\`, \`completeBike\` ou \`completePylone\`.
Le gameplay de mission qui peut cibler \`bike\`, \`pylone\` ou \`ferme\` doit préférer les actions génériques de mission :
\`\`\`ts
const setMissionStep = useGameStore((state) => state.setMissionStep);
const completeMission = useGameStore((state) => state.completeMission);
setMissionStep("bike", "inspected");
completeMission("bike");
\`\`\`
Cela évite aux composants gameplay réutilisables, comme les flows de réparation, de dupliquer des branches spécifiques à chaque mission avec \`setBikeState\`, \`setPyloneState\` et \`setFermeState\`.
## Intégration avec le World
\`src/world/GameStageContent.tsx\` s'abonne à \`mainState\` et monte le contenu spécifique au state courant.
Pour les missions de réparation, il monte le composant réutilisable \`RepairGame\` avec un id de mission :
\`\`\`tsx
<RepairGame mission="bike" position={[8, 0, -6]} />
\`\`\`
\`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\` et \`completeMission\`. Les ids de mission, étapes de mission et guards partagés vivent dans \`src/types/gameplay/repairMission.ts\`, ce qui évite à la configuration statique des missions de dépendre du store Zustand. Le flow de réparation de production supporte actuellement les transitions \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`.
La scène peut donc évoluer progressivement vers ce pattern :
\`\`\`tsx
switch (mainState) {
case "intro":
return <IntroContent />;
case "bike":
return <BikeContent />;
case "pylone":
return <PyloneContent />;
case "ferme":
return <FarmContent />;
case "outro":
return <OutroContent />;
}
\`\`\`
Dans React Three Fiber, monter ou démonter du JSX contrôle ce qui apparaît dans la scène Three.js. Quand un composant lié à un state disparaît du JSX, React le retire de la scène.
## Intégration UI
\`src/components/ui/GameUI.tsx\` regroupe les overlays HTML utilisés par la route jouable.
Overlays actuels :
- \`DebugOverlayLayout\` : layout compact des panels debug HTML visible avec \`?debug\`
- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store
- \`Crosshair\` : aide de visée joueur
- \`InteractPrompt\` : prompt d'interaction
- \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement
\`src/pages/page.tsx\` doit rester fin et monter seulement le canvas et \`GameUI\`.
## Règles anti-régression
- Ne pas stocker les valeurs mises à jour à chaque frame dans Zustand.
- Utiliser \`useRef\` pour les valeurs mutables fréquentes comme la vélocité joueur, les vecteurs temporaires ou les données de boucle d'animation.
- Utiliser des selectors au lieu de lire tout le store dans les composants.
- Garder les transitions gameplay dans les actions du store quand possible.
- Garder les contrôles debug derrière \`?debug\`.
- Ajouter du state uniquement quand une vraie fonctionnalité runtime en a besoin.
## Prochaines étapes
Déplacer la validation de réparation dans les données de mission lorsque chaque mission aura ses propres nodes de modules cassés, assets de remplacement et événements de complétion.
`;
export const featuresFr = `# Fonctionnalités implémentées
Ce document liste les fonctionnalités présentes dans le code actuel.
## Scène
- Scène React Three Fiber plein écran
- Carte principale chargée depuis \`public/models/{name}/model.glb\`, avec fallback vers \`model.gltf\`
- Scène de test physique debug sélectionnable depuis le panneau debug, avec tests grab/trigger, preview de modèle animé et zones playground de réparation séparées pour \`bike\`, \`pylone\` et \`ferme\`
- Contexte physique Rapier disponible pour les objets gameplay de stage en production
- Éclairage ambiant et directionnel
- Configuration de l'environnement de fond
## Joueur
- Mode caméra joueur
- Orientation souris avec pointer lock
- Déplacement avec \`ZQSD\`
- Saut
- Verrouillage du déplacement pendant les étapes repair actives, avec indicateur à l'écran tout en gardant les interactions trigger disponibles
- Collision basée sur une octree contre la carte chargée
## Interactions
- Détection de focus par distance et raycast
- Interactions trigger activées avec \`E\`
- Interactions grab activées avec le bouton principal de la souris
- Les objets gameplay avec physique peuvent être montés dans le contenu de stage sans remplacer la collision octree du joueur
- Prompt d'interaction affiché pour les interactions trigger
## Gameplay de réparation
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
- Le playground physics debug monte le même \`RepairGame\` réutilisable dans des zones \`Bike\`, \`Pylone\` et \`Farm\`, afin de peaufiner chaque state avec un placement isolé avant déplacement vers la carte de production
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`, avec nodes cassés, placeholders cibles, timing de scan et timing de réassemblage propres à chaque mission
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, indicateur de verrouillage de déplacement pendant la réparation active, interaction trigger sur la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, feedback de dépôt des pièces cassées, touche \`E\`, hold deux poings, transition de modèle explosé, réassemblage inverse avec particules, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, feedback de validation de la bonne pièce et complétion de mission
## Audio
- Lecture de sons one-shot pour les interactions trigger
- Pool simple par son via \`AudioManager\`
## Outils debug
- Le paramètre \`?debug\` active le panneau debug
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction
- Overlay debug compact pour les contrôles de game state et le statut hand tracking
- Le changement de mission dans le panneau game-state debug déverrouille les missions repair encore \`locked\` à \`waiting\` pour accélérer les tests
- Helpers de scène debug
- Caméra libre debug
- Overlay \`r3f-perf\`
## Pas encore implémenté
- système de missions complet
- système de zones
- système de cinématiques
- système de dialogues
- flow de chargement
- minimap et HUD de mission
- séparation complète production / debug pour les scènes gameplay
`;
export const editorFr = `# Éditeur de carte
L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
## Ce qui est édité
L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
Chaque node décrit un objet de la scène :
- "name" : nom du dossier modèle dans "/public/models/{name}/model.glb", avec fallback vers "model.gltf"
- "type" : catégorie de l'objet
- "position" : "[x, y, z]"
- "rotation" : "[x, y, z]"
- "scale" : "[x, y, z]"
Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
## Workflow de base
1. Ouvrir "/editor".
2. Sélectionner un objet dans la vue 3D.
3. Choisir un mode de transformation : translation, rotation ou scale.
4. Déplacer la gizmo de transformation.
5. Utiliser undo ou redo si nécessaire.
6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
## Contrôles
| Action | Input |
| --- | --- |
| Sélectionner un objet | Clic sur l'objet |
| Désélectionner | "Esc" ou clic dans le vide |
| Mode translation | "T" |
| Mode rotation | "R" |
| Mode scale | "S" |
| Undo | "Ctrl+Z" |
| Redo | "Ctrl+Y" |
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
| Monter / descendre | "Space", "Shift" |
## Actions fichier
### Export JSON
"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
### Save to server
"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
## Inspecteur JSON
Le panneau latéral affiche le JSON brut de la carte :
- sans sélection, il affiche toute la liste des nodes
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
## Limites actuelles
- L'éditeur modifie uniquement les nodes existants.
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
- La sauvegarde production n'est pas implémentée.
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
`;
+1 -1
View File
@@ -52,7 +52,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description: description:
"Repair the damaged cooling module before relaunching the bike", "Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf", modelPath: "/models/ebike/model.gltf",
modelScale: 0.25, modelScale: 0.50,
stageUiPath: "/assets/UI/ebike.webm", stageUiPath: "/assets/UI/ebike.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH, interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH,
+1 -1
View File
@@ -11,5 +11,5 @@ export const PLAYER_MAX_DELTA = 0.05;
export const PLAYER_ACCELERATION_MULTIPLIER = 9; export const PLAYER_ACCELERATION_MULTIPLIER = 9;
export const PLAYER_XZ_DAMPING_FACTOR = 8; export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 100, 0]; export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0]; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
+2
View File
@@ -17,6 +17,8 @@ export function useOctreeGraphNode(
}, [rebuildKey]); }, [rebuildKey]);
useEffect(() => { useEffect(() => {
if (!enabled) return;
const graphNode = graphNodeRef.current; const graphNode = graphNodeRef.current;
if (!enabled || octreeBuilt.current || !graphNode) return; if (!enabled || octreeBuilt.current || !graphNode) return;
octreeBuilt.current = true; octreeBuilt.current = true;
+16 -1
View File
@@ -10,7 +10,9 @@ interface UseWorldSceneLoadingOptions {
interface UseWorldSceneLoadingResult { interface UseWorldSceneLoadingResult {
octree: Octree | null; octree: Octree | null;
gameplayReady: boolean;
showGameStage: boolean; showGameStage: boolean;
handleGameStageLoaded: () => void;
handleGameMapLoaded: () => void; handleGameMapLoaded: () => void;
handleOctreeReady: (octree: Octree) => void; handleOctreeReady: (octree: Octree) => void;
} }
@@ -21,15 +23,26 @@ export function useWorldSceneLoading({
}: UseWorldSceneLoadingOptions): UseWorldSceneLoadingResult { }: UseWorldSceneLoadingOptions): UseWorldSceneLoadingResult {
const [octree, setOctree] = useState<Octree | null>(null); const [octree, setOctree] = useState<Octree | null>(null);
const [gameMapLoaded, setGameMapLoaded] = useState(false); const [gameMapLoaded, setGameMapLoaded] = useState(false);
const [gameStageLoaded, setGameStageLoaded] = useState(false);
const showGameStage = sceneMode === "game" && gameMapLoaded; const showGameStage = sceneMode === "game" && gameMapLoaded;
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
const sceneReady = const sceneReady =
(sceneMode === "game" && gameMapLoaded) || (sceneMode === "game" && gameplayReady) ||
(sceneMode === "physics" && octree !== null); (sceneMode === "physics" && octree !== null);
const handleGameMapLoaded = useCallback(() => { const handleGameMapLoaded = useCallback(() => {
setGameMapLoaded(true); setGameMapLoaded(true);
}, []); }, []);
const handleGameStageLoaded = useCallback(() => {
setGameStageLoaded(true);
onLoadingStateChange?.({
currentStep: "Initialisation gameplay",
progress: 0.96,
status: "loading",
});
}, [onLoadingStateChange]);
const handleOctreeReady = useCallback( const handleOctreeReady = useCallback(
(nextOctree: Octree) => { (nextOctree: Octree) => {
setOctree(nextOctree); setOctree(nextOctree);
@@ -74,7 +87,9 @@ export function useWorldSceneLoading({
return { return {
octree, octree,
gameplayReady,
showGameStage, showGameStage,
handleGameStageLoaded,
handleGameMapLoaded, handleGameMapLoaded,
handleOctreeReady, handleOctreeReady,
}; };
+950 -1
View File
@@ -498,6 +498,194 @@ canvas {
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35); text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
} }
/* Subtitles */
.subtitles {
position: fixed;
left: 50%;
bottom: 7vh;
z-index: 15;
width: min(780px, calc(100vw - 32px));
transform: translateX(-50%);
pointer-events: none;
}
.subtitles p {
margin: 0;
padding: 12px 16px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.82);
color: #ffffff;
font-size: clamp(1rem, 2vw, 1.25rem);
font-weight: 650;
line-height: 1.45;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
}
.subtitles__speaker {
margin-right: 0.35em;
font-weight: 800;
}
.subtitles__speaker--narrateur {
color: #7dd3fc;
}
.subtitles__speaker--fermier {
color: #86efac;
}
.subtitles__speaker--electricienne {
color: #f9a8d4;
}
/* In-game settings menu */
.game-settings-menu {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 20px;
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
pointer-events: auto;
backdrop-filter: blur(10px);
}
.game-settings-menu__panel {
width: min(460px, 100%);
max-height: calc(100vh - 40px);
overflow-y: auto;
padding: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 24px;
background: rgba(8, 8, 8, 0.94);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
}
.game-settings-menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 4px 4px 16px;
}
.game-settings-menu__header span {
color: #8f8f8f;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.game-settings-menu__header h2 {
margin: 0.25rem 0 0;
font-size: 1.8rem;
letter-spacing: -0.06em;
}
.game-settings-menu__close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: #111111;
color: #ffffff;
cursor: pointer;
}
.game-settings-menu__section {
display: grid;
gap: 12px;
padding: 16px 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.game-settings-menu__section h3 {
margin: 0;
color: #d7d7d7;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.game-settings-menu__slider {
display: grid;
gap: 8px;
}
.game-settings-menu__slider span,
.game-settings-menu__checkbox {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #f2f2f2;
font-size: 0.9rem;
font-weight: 650;
}
.game-settings-menu__slider strong {
color: #8f8f8f;
font-size: 0.78rem;
}
.game-settings-menu__slider input[type="range"] {
width: 100%;
accent-color: #ffffff;
}
.game-settings-menu__checkbox {
justify-content: flex-start;
cursor: pointer;
}
.game-settings-menu__checkbox input {
width: 18px;
height: 18px;
accent-color: #ffffff;
}
.game-settings-menu__choice-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.game-settings-menu__choice-group--stacked {
grid-template-columns: 1fr;
}
.game-settings-menu__choice-group button,
.game-settings-menu__quit {
width: 100%;
padding: 11px 12px;
border: 1px solid #242424;
border-radius: 14px;
background: #101010;
color: #f2f2f2;
cursor: pointer;
font-size: 0.88rem;
font-weight: 680;
}
.game-settings-menu__choice-group button.active {
border-color: #ffffff;
background: #ffffff;
color: #050505;
}
.game-settings-menu__quit {
margin-top: 8px;
border-color: rgba(248, 113, 113, 0.35);
color: #fecaca;
}
/* Debug overlay panels */ /* Debug overlay panels */
.debug-overlay-layout { .debug-overlay-layout {
position: fixed; position: fixed;
@@ -954,6 +1142,61 @@ canvas {
line-height: 1.45; line-height: 1.45;
} }
.editor-panel-group {
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.editor-panel-group-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 12px;
color: #ffffff;
cursor: pointer;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
list-style: none;
text-transform: uppercase;
user-select: none;
}
.editor-panel-group-summary::-webkit-details-marker {
display: none;
}
.editor-panel-group-summary:hover {
color: #f2f2f2;
}
.editor-panel-group-meta {
display: inline-flex;
align-items: center;
gap: 8px;
color: #777777;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0;
text-transform: none;
}
.editor-panel-group-meta svg {
transition: transform 160ms ease;
}
.editor-panel-group[open] .editor-panel-group-meta svg {
transform: rotate(180deg);
}
.editor-panel-group-content > .editor-control-section:first-child,
.editor-panel-group-content > .editor-json-section:first-child,
.editor-panel-group-content > .editor-cinematic-manifest-section:first-child,
.editor-panel-group-content > .editor-dialogue-manifest-section:first-child,
.editor-panel-group-content > .editor-srt-section:first-child {
border-top: 0;
}
.editor-control-section { .editor-control-section {
padding: 14px 12px; padding: 14px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09); border-top: 1px solid rgba(255, 255, 255, 0.09);
@@ -1105,6 +1348,12 @@ canvas {
transform: translateY(-1px); transform: translateY(-1px);
} }
.editor-action-button:disabled {
cursor: not-allowed;
opacity: 0.45;
transform: none;
}
.editor-action-button-primary, .editor-action-button-primary,
.editor-player-button.active { .editor-player-button.active {
background: #ffffff; background: #ffffff;
@@ -1119,7 +1368,8 @@ canvas {
} }
.editor-selected-info { .editor-selected-info {
display: flex; display: grid;
grid-template-columns: 17px 1fr auto;
align-items: center; align-items: center;
gap: 11px; gap: 11px;
background: #ffffff; background: #ffffff;
@@ -1129,6 +1379,38 @@ canvas {
color: #050505; color: #050505;
} }
.editor-selected-actions {
display: inline-flex;
gap: 6px;
}
.editor-selected-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 27px;
height: 27px;
padding: 0;
color: #050505;
background: rgba(0, 0, 0, 0.06);
border: 0;
border-radius: 9px;
cursor: pointer;
transition:
background 160ms ease,
transform 160ms ease;
}
.editor-selected-actions button:hover {
background: rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
}
.editor-selected-actions button[aria-pressed="true"] {
color: #ffffff;
background: #050505;
}
.editor-selected-info strong, .editor-selected-info strong,
.editor-selected-info span { .editor-selected-info span {
display: block; display: block;
@@ -1268,6 +1550,673 @@ canvas {
font-size: 0.74rem; font-size: 0.74rem;
} }
/* Editor SRT panel */
.editor-srt-section {
display: grid;
gap: 10px;
padding: 14px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.editor-srt-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.editor-srt-controls label {
display: grid;
gap: 5px;
color: #8d8d8d;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-srt-controls select {
width: 100%;
padding: 9px 10px;
border: 1px solid #242424;
border-radius: 12px;
background: #101010;
color: #f2f2f2;
}
.editor-srt-textarea {
width: 100%;
min-height: 260px;
resize: vertical;
box-sizing: border-box;
padding: 12px;
border: 1px solid #1f1f1f;
border-radius: 16px;
background: #050505;
color: #d7d7d7;
font-family: "SFMono-Regular", "Courier New", monospace;
font-size: 0.72rem;
line-height: 1.55;
}
.editor-srt-textarea:focus,
.editor-srt-controls select:focus,
.editor-srt-preview select:focus {
border-color: #ffffff;
outline: none;
}
.editor-srt-preview {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #1f1f1f;
border-radius: 16px;
background: #070707;
}
.editor-srt-preview label {
display: grid;
gap: 5px;
color: #8d8d8d;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-srt-preview select {
width: 100%;
padding: 9px 10px;
border: 1px solid #242424;
border-radius: 12px;
background: #101010;
color: #f2f2f2;
}
.editor-srt-audio-card {
display: grid;
gap: 6px;
color: #f2f2f2;
}
.editor-srt-audio-card span {
color: #8d8d8d;
font-size: 0.72rem;
}
.editor-srt-audio-card strong {
font-size: 0.78rem;
line-height: 1.3;
word-break: break-word;
}
.editor-srt-audio-card audio {
width: 100%;
height: 34px;
}
.editor-srt-active-cue {
display: grid;
gap: 5px;
padding: 8px 10px;
border: 1px solid #242424;
border-radius: 12px;
background: #101010;
}
.editor-srt-active-cue span {
color: #8d8d8d;
font-size: 0.7rem;
}
.editor-srt-active-cue p {
margin: 0;
color: #d7d7d7;
font-size: 0.74rem;
line-height: 1.4;
}
.editor-srt-active-cue strong {
margin-right: 4px;
color: #ffffff;
}
.editor-srt-time-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.editor-srt-time-actions button {
padding: 8px 10px;
border: 1px solid rgba(125, 211, 252, 0.24);
border-radius: 12px;
background: rgba(125, 211, 252, 0.08);
color: #bae6fd;
cursor: pointer;
font-size: 0.74rem;
font-weight: 800;
}
.editor-srt-time-actions button:hover {
border-color: #7dd3fc;
background: rgba(125, 211, 252, 0.14);
}
.editor-srt-jump-button {
width: 100%;
padding: 8px 10px;
border: 1px solid #2f2f2f;
border-radius: 12px;
background: #151515;
color: #f2f2f2;
cursor: pointer;
font-size: 0.76rem;
font-weight: 700;
}
.editor-srt-jump-button:hover {
border-color: #ffffff;
background: #202020;
}
.editor-srt-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.editor-srt-actions .editor-action-button + .editor-action-button {
margin-top: 0;
}
.editor-srt-status {
margin: 0;
color: #8d8d8d;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-dialogue-validation {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #242424;
border-radius: 14px;
background: #101010;
}
.editor-dialogue-validation.is-valid {
border-color: rgba(134, 239, 172, 0.32);
}
.editor-dialogue-validation.is-invalid {
border-color: rgba(248, 113, 113, 0.38);
}
.editor-dialogue-validation__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.editor-dialogue-validation__heading div {
display: grid;
gap: 2px;
}
.editor-dialogue-validation__heading strong {
color: #f2f2f2;
font-size: 0.76rem;
font-weight: 800;
}
.editor-dialogue-validation__heading span {
color: #8d8d8d;
font-size: 0.68rem;
line-height: 1.35;
}
.editor-dialogue-validation__heading button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 92px;
padding: 8px 9px;
border: 1px solid #2f2f2f;
border-radius: 12px;
background: #151515;
color: #f2f2f2;
cursor: pointer;
font-size: 0.72rem;
font-weight: 800;
}
.editor-dialogue-validation__heading button:hover {
border-color: #ffffff;
background: #202020;
}
.editor-dialogue-validation__heading button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.editor-dialogue-validation__result {
display: grid;
gap: 6px;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-dialogue-validation__result p {
margin: 0;
color: #d7d7d7;
}
.editor-dialogue-validation__errors,
.editor-dialogue-validation__warnings {
display: grid;
gap: 4px;
margin: 0;
padding-left: 16px;
}
.editor-dialogue-validation__errors {
color: #fca5a5;
}
.editor-dialogue-validation__warnings {
color: #fde68a;
}
.editor-srt-diagnostic {
display: grid;
gap: 6px;
padding: 9px 10px;
border: 1px solid #242424;
border-radius: 12px;
background: #101010;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-srt-diagnostic.is-valid {
border-color: rgba(134, 239, 172, 0.32);
color: #86efac;
}
.editor-srt-diagnostic.is-invalid {
border-color: rgba(248, 113, 113, 0.38);
color: #fca5a5;
}
.editor-srt-diagnostic strong {
font-size: 0.74rem;
}
.editor-srt-diagnostic ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 16px;
color: #fca5a5;
}
/* Editor dialogue manifest panel */
.editor-dialogue-manifest-section {
display: grid;
gap: 10px;
padding: 14px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.editor-dialogue-manifest-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.editor-dialogue-manifest-actions button,
.editor-dialogue-manifest-srt-cue,
.editor-dialogue-manifest-preview,
.editor-dialogue-manifest-delete {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 9px;
border: 1px solid #2f2f2f;
border-radius: 12px;
background: #151515;
color: #f2f2f2;
cursor: pointer;
font-size: 0.72rem;
font-weight: 800;
}
.editor-dialogue-manifest-actions button:hover,
.editor-dialogue-manifest-srt-cue:hover,
.editor-dialogue-manifest-preview:hover,
.editor-dialogue-manifest-delete:hover {
border-color: #ffffff;
background: #202020;
}
.editor-dialogue-manifest-actions button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.editor-dialogue-manifest-srt-cue:disabled,
.editor-dialogue-manifest-preview:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.editor-dialogue-manifest-select,
.editor-dialogue-manifest-form label {
display: grid;
gap: 5px;
color: #8d8d8d;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-dialogue-manifest-select select,
.editor-dialogue-manifest-form input,
.editor-dialogue-manifest-form select {
width: 100%;
box-sizing: border-box;
padding: 9px 10px;
border: 1px solid #242424;
border-radius: 12px;
background: #101010;
color: #f2f2f2;
}
.editor-dialogue-manifest-select select:focus,
.editor-dialogue-manifest-form input:focus,
.editor-dialogue-manifest-form select:focus {
border-color: #ffffff;
outline: none;
}
.editor-dialogue-manifest-form {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #1f1f1f;
border-radius: 16px;
background: #070707;
}
.editor-dialogue-manifest-delete {
border-color: rgba(248, 113, 113, 0.32);
color: #fca5a5;
}
.editor-dialogue-manifest-preview {
border-color: rgba(125, 211, 252, 0.24);
color: #bae6fd;
}
.editor-dialogue-manifest-srt-cue {
border-color: rgba(134, 239, 172, 0.24);
color: #bbf7d0;
}
.editor-dialogue-manifest-status {
margin: 0;
color: #8d8d8d;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-dialogue-manifest-diagnostic {
display: grid;
gap: 6px;
padding: 9px 10px;
border: 1px solid #242424;
border-radius: 12px;
background: #101010;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-dialogue-manifest-diagnostic.is-valid {
border-color: rgba(134, 239, 172, 0.32);
color: #86efac;
}
.editor-dialogue-manifest-diagnostic.is-invalid {
border-color: rgba(248, 113, 113, 0.38);
color: #fca5a5;
}
.editor-dialogue-manifest-diagnostic ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 16px;
}
/* Editor cinematic manifest panel */
.editor-cinematic-manifest-section {
display: grid;
gap: 10px;
padding: 14px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.editor-cinematic-manifest-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.editor-cinematic-manifest-actions button,
.editor-cinematic-manifest-preview,
.editor-cinematic-manifest-delete,
.editor-cinematic-keyframes-heading button,
.editor-cinematic-keyframe-heading button,
.editor-cinematic-dialogue-cues-heading button,
.editor-cinematic-dialogue-cue-heading button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 9px;
border: 1px solid #2f2f2f;
border-radius: 12px;
background: #151515;
color: #f2f2f2;
cursor: pointer;
font-size: 0.72rem;
font-weight: 800;
}
.editor-cinematic-manifest-actions button:hover,
.editor-cinematic-manifest-preview:hover,
.editor-cinematic-manifest-delete:hover,
.editor-cinematic-keyframes-heading button:hover,
.editor-cinematic-keyframe-heading button:hover,
.editor-cinematic-dialogue-cues-heading button:hover,
.editor-cinematic-dialogue-cue-heading button:hover {
border-color: #ffffff;
background: #202020;
}
.editor-cinematic-manifest-actions button:disabled,
.editor-cinematic-manifest-preview:disabled,
.editor-cinematic-keyframe-heading button:disabled,
.editor-cinematic-dialogue-cue-heading button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.editor-cinematic-manifest-select,
.editor-cinematic-manifest-form label,
.editor-cinematic-vector-inputs label,
.editor-cinematic-dialogue-cue label {
display: grid;
gap: 5px;
color: #8d8d8d;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-cinematic-manifest-select select,
.editor-cinematic-manifest-form input,
.editor-cinematic-manifest-form select,
.editor-cinematic-vector-inputs input {
width: 100%;
box-sizing: border-box;
padding: 9px 10px;
border: 1px solid #242424;
border-radius: 12px;
background: #101010;
color: #f2f2f2;
}
.editor-cinematic-manifest-select select:focus,
.editor-cinematic-manifest-form input:focus,
.editor-cinematic-manifest-form select:focus,
.editor-cinematic-vector-inputs input:focus {
border-color: #ffffff;
outline: none;
}
.editor-cinematic-manifest-form,
.editor-cinematic-keyframes,
.editor-cinematic-keyframe,
.editor-cinematic-dialogue-cues,
.editor-cinematic-dialogue-cue {
display: grid;
gap: 8px;
}
.editor-cinematic-manifest-form {
padding: 10px;
border: 1px solid #1f1f1f;
border-radius: 16px;
background: #070707;
}
.editor-cinematic-keyframes,
.editor-cinematic-dialogue-cues {
padding: 10px;
border: 1px solid #242424;
border-radius: 14px;
background: #101010;
}
.editor-cinematic-keyframes-heading,
.editor-cinematic-keyframe-heading,
.editor-cinematic-dialogue-cues-heading,
.editor-cinematic-dialogue-cue-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.editor-cinematic-keyframes-heading strong,
.editor-cinematic-keyframe-heading strong,
.editor-cinematic-dialogue-cues-heading strong,
.editor-cinematic-dialogue-cue-heading strong {
color: #f2f2f2;
font-size: 0.76rem;
font-weight: 800;
}
.editor-cinematic-keyframe,
.editor-cinematic-dialogue-cue {
padding: 9px;
border: 1px solid #1f1f1f;
border-radius: 12px;
background: #070707;
}
.editor-cinematic-vector-inputs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.editor-cinematic-vector-inputs span {
grid-column: 1 / -1;
color: #8d8d8d;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-cinematic-manifest-delete {
border-color: rgba(248, 113, 113, 0.32);
color: #fca5a5;
}
.editor-cinematic-manifest-preview {
border-color: rgba(125, 211, 252, 0.24);
color: #bae6fd;
}
.editor-cinematic-keyframe-heading button,
.editor-cinematic-dialogue-cue-heading button {
padding: 6px 8px;
color: #fca5a5;
}
.editor-cinematic-dialogue-cues p {
margin: 0;
color: #8d8d8d;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-cinematic-manifest-status {
margin: 0;
color: #8d8d8d;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-cinematic-manifest-diagnostic {
display: grid;
gap: 6px;
padding: 9px 10px;
border: 1px solid #242424;
border-radius: 12px;
background: #101010;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-cinematic-manifest-diagnostic.is-valid {
border-color: rgba(134, 239, 172, 0.32);
color: #86efac;
}
.editor-cinematic-manifest-diagnostic.is-invalid {
border-color: rgba(248, 113, 113, 0.38);
color: #fca5a5;
}
.editor-cinematic-manifest-diagnostic ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 16px;
}
/* Editor responsive layout */ /* Editor responsive layout */
@media (max-width: 768px) { @media (max-width: 768px) {
.editor-error h2 { .editor-error h2 {
+129 -4
View File
@@ -1,17 +1,53 @@
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
export type AudioCategory = "music" | "sfx" | "dialogue";
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
interface AudioContextWindow extends Window {
webkitAudioContext?: typeof AudioContext;
}
const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
music: 1,
sfx: 1,
dialogue: 1,
};
interface PlaySoundOptions { interface PlaySoundOptions {
category?: OneShotAudioCategory;
pan?: number;
playbackRate?: number; playbackRate?: number;
} }
interface StereoNodes {
source: MediaElementAudioSourceNode;
panner: StereoPannerNode;
}
interface OneShotAudioState {
category: OneShotAudioCategory;
volume: number;
}
export class AudioManager { export class AudioManager {
private static _instance: AudioManager | null = null; private static _instance: AudioManager | null = null;
private readonly _audioPools = new Map<string, HTMLAudioElement[]>(); private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
private readonly _stereoNodes = new WeakMap<HTMLAudioElement, StereoNodes>();
private readonly _oneShotStates = new WeakMap<
HTMLAudioElement,
OneShotAudioState
>();
private readonly _categoryVolumes: Record<AudioCategory, number> = {
...DEFAULT_CATEGORY_VOLUMES,
};
private _audioContext: AudioContext | null = null;
private _music: HTMLAudioElement | null = null; private _music: HTMLAudioElement | null = null;
private _musicPath: string | null = null; private _musicPath: string | null = null;
private _musicVolume = 1;
private _musicUnlockHandler: (() => void) | null = null; private _musicUnlockHandler: (() => void) | null = null;
private static readonly MAX_POOL_SIZE_PER_SOUND = 6; private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
private static readonly DEFAULT_SOUND_CATEGORY: OneShotAudioCategory = "sfx";
private static readonly IGNORED_PLAYBACK_ERRORS = new Set([ private static readonly IGNORED_PLAYBACK_ERRORS = new Set([
"AbortError", "AbortError",
"NotAllowedError", "NotAllowedError",
@@ -27,11 +63,38 @@ export class AudioManager {
private constructor() {} private constructor() {}
playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void { setCategoryVolume(category: AudioCategory, volume: number): void {
this._categoryVolumes[category] = AudioManager._clampVolume(volume);
if (category === "music" && this._music) {
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
return;
}
this._updateOneShotVolumes(category);
}
getCategoryVolume(category: AudioCategory): number {
return this._categoryVolumes[category];
}
playSound(
path: string,
volume = 1,
options: PlaySoundOptions = {},
): HTMLAudioElement {
const audio = this._acquireAudio(path); const audio = this._acquireAudio(path);
audio.volume = Math.max(0, Math.min(1, volume)); const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY;
const baseVolume = AudioManager._clampVolume(volume);
this._oneShotStates.set(audio, { category, volume: baseVolume });
audio.volume = this._getEffectiveVolume(category, baseVolume);
audio.playbackRate = options.playbackRate ?? 1; audio.playbackRate = options.playbackRate ?? 1;
audio.currentTime = 0; audio.currentTime = 0;
this._setStereoPan(audio, options.pan ?? 0);
if (this._audioContext?.state === "suspended") {
void this._audioContext.resume();
}
void audio.play().catch((error: unknown) => { void audio.play().catch((error: unknown) => {
if ( if (
@@ -43,14 +106,19 @@ export class AudioManager {
logger.error("AudioManager", "Failed to play sound", { logger.error("AudioManager", "Failed to play sound", {
path, path,
category,
error: AudioManager._toLogValue(error), error: AudioManager._toLogValue(error),
}); });
}); });
return audio;
} }
playMusic(path: string, volume = 1): void { playMusic(path: string, volume = 1): void {
this._musicVolume = AudioManager._clampVolume(volume);
if (this._musicPath === path && this._music) { if (this._musicPath === path && this._music) {
this._music.volume = Math.max(0, Math.min(1, volume)); this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
if (!this._music.paused) return; if (!this._music.paused) return;
} else { } else {
this.stopMusic(); this.stopMusic();
@@ -59,7 +127,7 @@ export class AudioManager {
this._musicPath = path; this._musicPath = path;
} }
this._music.volume = Math.max(0, Math.min(1, volume)); this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
void this._music.play().catch((error: unknown) => { void this._music.play().catch((error: unknown) => {
if ( if (
@@ -93,6 +161,8 @@ export class AudioManager {
}); });
}); });
this._audioPools.clear(); this._audioPools.clear();
void this._audioContext?.close();
this._audioContext = null;
AudioManager._instance = null; AudioManager._instance = null;
} }
@@ -159,6 +229,61 @@ export class AudioManager {
this._musicUnlockHandler = null; this._musicUnlockHandler = null;
} }
private _setStereoPan(audio: HTMLAudioElement, pan: number): void {
const audioContext = this._getAudioContext();
if (!audioContext || !("createStereoPanner" in audioContext)) return;
let nodes = this._stereoNodes.get(audio);
if (!nodes) {
nodes = {
source: audioContext.createMediaElementSource(audio),
panner: audioContext.createStereoPanner(),
};
nodes.source.connect(nodes.panner).connect(audioContext.destination);
this._stereoNodes.set(audio, nodes);
}
nodes.panner.pan.value = AudioManager._clampPan(pan);
}
private _getAudioContext(): AudioContext | null {
if (this._audioContext) return this._audioContext;
const AudioContextConstructor =
window.AudioContext ??
(window as AudioContextWindow).webkitAudioContext ??
null;
if (!AudioContextConstructor) return null;
this._audioContext = new AudioContextConstructor();
return this._audioContext;
}
private _getEffectiveVolume(category: AudioCategory, volume: number): number {
return AudioManager._clampVolume(volume) * this._categoryVolumes[category];
}
private _updateOneShotVolumes(category: AudioCategory): void {
if (category === "music") return;
this._audioPools.forEach((pool) => {
pool.forEach((audio) => {
const state = this._oneShotStates.get(audio);
if (!state || state.category !== category) return;
audio.volume = this._getEffectiveVolume(category, state.volume);
});
});
}
private static _clampPan(pan: number): number {
return Math.max(-1, Math.min(1, pan));
}
private static _clampVolume(volume: number): number {
return Math.max(0, Math.min(1, volume));
}
private static _toLogValue(error: unknown): Error | DOMException | string { private static _toLogValue(error: unknown): Error | DOMException | string {
if (error instanceof Error || error instanceof DOMException) { if (error instanceof Error || error instanceof DOMException) {
return error; return error;
+4
View File
@@ -21,6 +21,7 @@ interface MissionState {
interface GameState { interface GameState {
mainState: MainGameState; mainState: MainGameState;
isCinematicPlaying: boolean;
intro: IntroState; intro: IntroState;
bike: MissionState & { bike: MissionState & {
isRepaired: boolean; isRepaired: boolean;
@@ -39,6 +40,7 @@ interface GameState {
interface GameActions { interface GameActions {
setMainState: (mainState: MainGameState) => void; setMainState: (mainState: MainGameState) => void;
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
setIntroState: (intro: Partial<IntroState>) => void; setIntroState: (intro: Partial<IntroState>) => void;
setBikeState: (bike: Partial<GameState["bike"]>) => void; setBikeState: (bike: Partial<GameState["bike"]>) => void;
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void; setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
@@ -222,6 +224,7 @@ function startOutroState(state: GameState): GameStateUpdate {
function createInitialGameState(): GameState { function createInitialGameState(): GameState {
return { return {
mainState: "intro", mainState: "intro",
isCinematicPlaying: false,
intro: { intro: {
dialogueAudio: null, dialogueAudio: null,
hasCompleted: false, hasCompleted: false,
@@ -252,6 +255,7 @@ function createInitialGameState(): GameState {
export const useGameStore = create<GameStore>()((set) => ({ export const useGameStore = create<GameStore>()((set) => ({
...createInitialGameState(), ...createInitialGameState(),
setMainState: (mainState) => set({ mainState }), setMainState: (mainState) => set({ mainState }),
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
setIntroState: (intro) => setIntroState: (intro) =>
set((state) => ({ intro: { ...state.intro, ...intro } })), set((state) => ({ intro: { ...state.intro, ...intro } })),
setBikeState: (bike) => setBikeState: (bike) =>
+87
View File
@@ -0,0 +1,87 @@
import { create } from "zustand";
import { AudioManager } from "@/managers/AudioManager";
import type { AudioCategory } from "@/managers/AudioManager";
export type SubtitleLanguage = "fr" | "en";
export type RepairRuntime = "js" | "python";
interface SettingsState {
isSettingsMenuOpen: boolean;
musicVolume: number;
sfxVolume: number;
dialogueVolume: number;
subtitlesEnabled: boolean;
subtitleLanguage: SubtitleLanguage;
repairRuntime: RepairRuntime;
}
interface SettingsActions {
setSettingsMenuOpen: (open: boolean) => void;
setMusicVolume: (volume: number) => void;
setSfxVolume: (volume: number) => void;
setDialogueVolume: (volume: number) => void;
setSubtitlesEnabled: (enabled: boolean) => void;
setSubtitleLanguage: (language: SubtitleLanguage) => void;
setRepairRuntime: (runtime: RepairRuntime) => void;
resetSettings: () => void;
}
type SettingsStore = SettingsState & SettingsActions;
const DEFAULT_SETTINGS: SettingsState = {
isSettingsMenuOpen: false,
musicVolume: 1,
sfxVolume: 1,
dialogueVolume: 1,
subtitlesEnabled: true,
subtitleLanguage: "fr",
repairRuntime: "js",
};
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, volume));
}
function setAudioCategoryVolume(
category: AudioCategory,
volume: number,
): number {
const nextVolume = clampVolume(volume);
AudioManager.getInstance().setCategoryVolume(category, nextVolume);
return nextVolume;
}
function applyDefaultAudioSettings(): void {
AudioManager.getInstance().setCategoryVolume(
"music",
DEFAULT_SETTINGS.musicVolume,
);
AudioManager.getInstance().setCategoryVolume(
"sfx",
DEFAULT_SETTINGS.sfxVolume,
);
AudioManager.getInstance().setCategoryVolume(
"dialogue",
DEFAULT_SETTINGS.dialogueVolume,
);
}
applyDefaultAudioSettings();
export const useSettingsStore = create<SettingsStore>()((set) => ({
...DEFAULT_SETTINGS,
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
setMusicVolume: (volume) =>
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
setSfxVolume: (volume) =>
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
setDialogueVolume: (volume) =>
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
setRepairRuntime: (repairRuntime) => set({ repairRuntime }),
resetSettings: () => {
applyDefaultAudioSettings();
set(DEFAULT_SETTINGS);
},
}));
+24
View File
@@ -0,0 +1,24 @@
import { create } from "zustand";
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
interface ActiveSubtitle {
speaker: DialogueSpeaker;
text: string;
}
interface SubtitleState {
activeSubtitle: ActiveSubtitle | null;
}
interface SubtitleActions {
setActiveSubtitle: (subtitle: ActiveSubtitle | null) => void;
clearActiveSubtitle: () => void;
}
type SubtitleStore = SubtitleState & SubtitleActions;
export const useSubtitleStore = create<SubtitleStore>()((set) => ({
activeSubtitle: null,
setActiveSubtitle: (activeSubtitle) => set({ activeSubtitle }),
clearActiveSubtitle: () => set({ activeSubtitle: null }),
}));
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={animation} content={animation}
frContent={animation} frContent={animation}
meta="08" meta="15"
title="Animation & 3D Model System" title="Animation & 3D Model System"
/> />
); );
+2 -3
View File
@@ -1,14 +1,13 @@
import architecture from "../../../../docs/technical/architecture.md?raw"; import architecture from "../../../../docs/technical/architecture.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument"; import { DocsDocument } from "@/components/docs/DocsDocument";
import { architectureFr } from "@/data/docs/docsTranslations";
export function DocsArchitecturePage(): React.JSX.Element { export function DocsArchitecturePage(): React.JSX.Element {
return ( return (
<DocsDocument <DocsDocument
content={architecture} content={architecture}
frContent={architectureFr} frContent={architecture}
meta="02" meta="02"
title="Architecture actuelle" title="Current Architecture"
/> />
); );
} }
+13
View File
@@ -0,0 +1,13 @@
import audio from "../../../../docs/technical/audio.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsAudioPage(): React.JSX.Element {
return (
<DocsDocument
content={audio}
frContent={audio}
meta="08"
title="Audio Technical Notes"
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import codeReview from "../../../../docs/code-review-preparation.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsCodeReviewPage(): React.JSX.Element {
return (
<DocsDocument
content={codeReview}
frContent={codeReview}
meta="16"
title="Code Review Prep"
/>
);
}
+2 -3
View File
@@ -1,13 +1,12 @@
import editor from "../../../../docs/user/editor.md?raw"; import editor from "../../../../docs/user/editor.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument"; import { DocsDocument } from "@/components/docs/DocsDocument";
import { editorFr } from "@/data/docs/docsTranslations";
export function DocsEditorPage(): React.JSX.Element { export function DocsEditorPage(): React.JSX.Element {
return ( return (
<DocsDocument <DocsDocument
content={editor} content={editor}
frContent={editorFr} frContent={editor}
meta="09" meta="14"
title="Editor User Guide" title="Editor User Guide"
/> />
); );
+2 -3
View File
@@ -1,13 +1,12 @@
import features from "../../../../docs/user/features.md?raw"; import features from "../../../../docs/user/features.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument"; import { DocsDocument } from "@/components/docs/DocsDocument";
import { featuresFr } from "@/data/docs/docsTranslations";
export function DocsFeaturesPage(): React.JSX.Element { export function DocsFeaturesPage(): React.JSX.Element {
return ( return (
<DocsDocument <DocsDocument
content={features} content={features}
frContent={featuresFr} frContent={features}
meta="06" meta="12"
title="Features" title="Features"
/> />
); );
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsHandTrackingPage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={handTracking} content={handTracking}
frContent={handTracking} frContent={handTracking}
meta="05" meta="09"
title="Hand Tracking Technical Notes" title="Hand Tracking Technical Notes"
/> />
); );
+13
View File
@@ -0,0 +1,13 @@
import interaction from "../../../../docs/technical/interaction.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsInteractionPage(): React.JSX.Element {
return (
<DocsDocument
content={interaction}
frContent={interaction}
meta="05"
title="Interaction System"
/>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsMainFeaturePage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={mainFeature} content={mainFeature}
frContent={mainFeature} frContent={mainFeature}
meta="07" meta="13"
title="Main Feature" title="Main Feature"
/> />
); );
+1 -2
View File
@@ -1,12 +1,11 @@
import readme from "../../../README.md?raw"; import readme from "../../../README.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument"; import { DocsDocument } from "@/components/docs/DocsDocument";
import { readmeFr } from "@/data/docs/docsTranslations";
export function DocsReadmePage(): React.JSX.Element { export function DocsReadmePage(): React.JSX.Element {
return ( return (
<DocsDocument <DocsDocument
content={readme} content={readme}
frContent={readmeFr} frContent={readme}
meta="01" meta="01"
title="README" title="README"
/> />
+13
View File
@@ -0,0 +1,13 @@
import repairGame from "../../../../docs/technical/repair-game.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsRepairGamePage(): React.JSX.Element {
return (
<DocsDocument
content={repairGame}
frContent={repairGame}
meta="04"
title="Repair Game"
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import sceneRuntime from "../../../../docs/technical/scene-runtime.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsSceneRuntimePage(): React.JSX.Element {
return (
<DocsDocument
content={sceneRuntime}
frContent={sceneRuntime}
meta="03"
title="Scene Runtime"
/>
);
}
+3 -4
View File
@@ -1,14 +1,13 @@
import targetArchitecture from "../../../../docs/technical/target-architecture.md?raw"; import targetArchitecture from "../../../../docs/technical/target-architecture.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument"; import { DocsDocument } from "@/components/docs/DocsDocument";
import { targetArchitectureFr } from "@/data/docs/docsTranslations";
export function DocsTargetArchitecturePage(): React.JSX.Element { export function DocsTargetArchitecturePage(): React.JSX.Element {
return ( return (
<DocsDocument <DocsDocument
content={targetArchitecture} content={targetArchitecture}
frContent={targetArchitectureFr} frContent={targetArchitecture}
meta="03" meta="06"
title="Architecture cible" title="Target Architecture"
/> />
); );
} }
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsTechnicalEditorPage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={technicalEditor} content={technicalEditor}
frContent={technicalEditor} frContent={technicalEditor}
meta="04" meta="07"
title="Editor Technical Notes" title="Editor Technical Notes"
/> />
); );
+13
View File
@@ -0,0 +1,13 @@
import threeDebugging from "../../../../docs/technical/three-debugging.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsThreeDebuggingPage(): React.JSX.Element {
return (
<DocsDocument
content={threeDebugging}
frContent={threeDebugging}
meta="11"
title="Three Debugging"
/>
);
}
+3 -4
View File
@@ -1,14 +1,13 @@
import zustand from "../../../../docs/technical/zustand.md?raw"; import zustand from "../../../../docs/technical/zustand.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument"; import { DocsDocument } from "@/components/docs/DocsDocument";
import { zustandFr } from "@/data/docs/docsTranslations";
export function DocsZustandPage(): React.JSX.Element { export function DocsZustandPage(): React.JSX.Element {
return ( return (
<DocsDocument <DocsDocument
content={zustand} content={zustand}
frContent={zustandFr} frContent={zustand}
meta="05" meta="10"
title="Zustand Game State" title="Zustand Stores"
/> />
); );
} }
+36
View File
@@ -3,8 +3,11 @@ import { Canvas } from "@react-three/fiber";
import { useProgress } from "@react-three/drei"; import { useProgress } from "@react-three/drei";
import { EditorControls } from "@/components/editor/EditorControls"; import { EditorControls } from "@/components/editor/EditorControls";
import { EditorScene } from "@/components/editor/scene/EditorScene"; import { EditorScene } from "@/components/editor/scene/EditorScene";
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { Subtitles } from "@/components/ui/Subtitles";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory"; import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData"; import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor"; import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
import { import {
@@ -64,6 +67,7 @@ export function EditorPage(): React.JSX.Element {
const [transformMode, setTransformMode] = const [transformMode, setTransformMode] =
useState<TransformMode>("translate"); useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false); const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>( const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
{ {
...INITIAL_SCENE_LOADING_STATE, ...INITIAL_SCENE_LOADING_STATE,
@@ -93,6 +97,8 @@ export function EditorPage(): React.JSX.Element {
status: "loading" as const, status: "loading" as const,
} }
: sceneLoadingState; : sceneLoadingState;
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
useState<EditorCinematicPreviewRequest | null>(null);
const { const {
undoCount, undoCount,
@@ -107,6 +113,14 @@ export function EditorPage(): React.JSX.Element {
setSelectedNodeIndex(index); setSelectedNodeIndex(index);
}, []); }, []);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
}, []);
const handleSelectionLockToggle = useCallback(() => {
setIsSelectionLocked((locked) => !locked);
}, []);
const handleHoverNode = useCallback((index: number | null) => { const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index); setHoveredNodeIndex(index);
}, []); }, []);
@@ -153,6 +167,20 @@ export function EditorPage(): React.JSX.Element {
setIsPlayerMode((prev) => !prev); setIsPlayerMode((prev) => !prev);
}, []); }, []);
const handlePreviewCinematic = useCallback(
(cinematic: CinematicDefinition) => {
setCinematicPreviewRequest({
id: window.crypto.randomUUID(),
cinematic,
});
},
[],
);
const handleCinematicPreviewComplete = useCallback(() => {
setCinematicPreviewRequest(null);
}, []);
const handleNodeTransform = useCallback( const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => { (nodeIndex: number, updatedNode: MapNode) => {
setSceneData((prev) => { setSceneData((prev) => {
@@ -227,6 +255,7 @@ export function EditorPage(): React.JSX.Element {
sceneData={sceneData!} sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode} onSelectNode={handleSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode} onHoverNode={handleHoverNode}
transformMode={transformMode} transformMode={transformMode}
@@ -237,6 +266,8 @@ export function EditorPage(): React.JSX.Element {
onUndo={handleUndo} onUndo={handleUndo}
onRedo={handleRedo} onRedo={handleRedo}
isPlayerMode={isPlayerMode} isPlayerMode={isPlayerMode}
cinematicPreviewRequest={cinematicPreviewRequest}
onCinematicPreviewComplete={handleCinematicPreviewComplete}
/> />
</Suspense> </Suspense>
</Canvas> </Canvas>
@@ -255,6 +286,9 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null ? sceneData.mapNodes[selectedNodeIndex].name || null
: null : null
} }
isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection}
undoCount={undoCount} undoCount={undoCount}
redoCount={redoCount} redoCount={redoCount}
onUndo={handleUndo} onUndo={handleUndo}
@@ -262,9 +296,11 @@ export function EditorPage(): React.JSX.Element {
onExportJson={handleExportJson} onExportJson={handleExportJson}
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined} onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
onPlayerMode={handlePlayerMode} onPlayerMode={handlePlayerMode}
onPreviewCinematic={handlePreviewCinematic}
isPlayerMode={isPlayerMode} isPlayerMode={isPlayerMode}
/> />
)} )}
<Subtitles />
</div> </div>
); );
} }
+4 -4
View File
@@ -18,13 +18,13 @@ export function HomePage(): React.JSX.Element {
const handleSceneLoadingStateChange = useCallback( const handleSceneLoadingStateChange = useCallback(
(nextState: SceneLoadingState) => { (nextState: SceneLoadingState) => {
setSceneLoadingState((currentState) => { setSceneLoadingState((currentState) => {
const shouldRestartProgress = currentState.status === "ready"; if (currentState.status === "ready" && nextState.status === "loading") {
return currentState;
}
return { return {
...nextState, ...nextState,
progress: shouldRestartProgress progress: Math.max(currentState.progress, nextState.progress),
? nextState.progress
: Math.max(currentState.progress, nextState.progress),
}; };
}); });
}, },
+12
View File
@@ -8,15 +8,21 @@ import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page"; import { EditorPage } from "@/pages/editor/page";
import { import {
DocsAnimationRoute, DocsAnimationRoute,
DocsAudioRoute,
DocsArchitectureRoute, DocsArchitectureRoute,
DocsCodeReviewRoute,
DocsEditorRoute, DocsEditorRoute,
DocsFeaturesRoute, DocsFeaturesRoute,
DocsHandTrackingRoute, DocsHandTrackingRoute,
DocsInteractionRoute,
DocsLayoutRoute, DocsLayoutRoute,
DocsMainFeatureRoute, DocsMainFeatureRoute,
DocsReadmeRoute, DocsReadmeRoute,
DocsRepairGameRoute,
DocsSceneRuntimeRoute,
DocsTargetArchitectureRoute, DocsTargetArchitectureRoute,
DocsTechnicalEditorRoute, DocsTechnicalEditorRoute,
DocsThreeDebuggingRoute,
DocsZustandRoute, DocsZustandRoute,
} from "@/routes/DocsRoute"; } from "@/routes/DocsRoute";
@@ -45,14 +51,20 @@ const docsRoute = createRoute({
const docsChildRoutes = [ const docsChildRoutes = [
{ path: "/", component: DocsReadmeRoute }, { path: "/", component: DocsReadmeRoute },
{ path: "architecture", component: DocsArchitectureRoute }, { path: "architecture", component: DocsArchitectureRoute },
{ path: "scene-runtime", component: DocsSceneRuntimeRoute },
{ path: "repair-game", component: DocsRepairGameRoute },
{ path: "interaction", component: DocsInteractionRoute },
{ path: "target-architecture", component: DocsTargetArchitectureRoute }, { path: "target-architecture", component: DocsTargetArchitectureRoute },
{ path: "technical-editor", component: DocsTechnicalEditorRoute }, { path: "technical-editor", component: DocsTechnicalEditorRoute },
{ path: "audio", component: DocsAudioRoute },
{ path: "hand-tracking", component: DocsHandTrackingRoute }, { path: "hand-tracking", component: DocsHandTrackingRoute },
{ path: "zustand", component: DocsZustandRoute }, { path: "zustand", component: DocsZustandRoute },
{ path: "three-debugging", component: DocsThreeDebuggingRoute },
{ path: "features", component: DocsFeaturesRoute }, { path: "features", component: DocsFeaturesRoute },
{ path: "main-feature", component: DocsMainFeatureRoute }, { path: "main-feature", component: DocsMainFeatureRoute },
{ path: "editor", component: DocsEditorRoute }, { path: "editor", component: DocsEditorRoute },
{ path: "animation", component: DocsAnimationRoute }, { path: "animation", component: DocsAnimationRoute },
{ path: "code-review", component: DocsCodeReviewRoute },
].map(({ path, component }) => ].map(({ path, component }) =>
createRoute({ createRoute({
getParentRoute: () => docsRoute, getParentRoute: () => docsRoute,
+32
View File
@@ -43,10 +43,26 @@ const LazyDocsTargetArchitecturePage = lazyNamed(
() => import("@/pages/docs/target-architecture/page"), () => import("@/pages/docs/target-architecture/page"),
"DocsTargetArchitecturePage", "DocsTargetArchitecturePage",
); );
const LazyDocsSceneRuntimePage = lazyNamed(
() => import("@/pages/docs/scene-runtime/page"),
"DocsSceneRuntimePage",
);
const LazyDocsRepairGamePage = lazyNamed(
() => import("@/pages/docs/repair-game/page"),
"DocsRepairGamePage",
);
const LazyDocsInteractionPage = lazyNamed(
() => import("@/pages/docs/interaction/page"),
"DocsInteractionPage",
);
const LazyDocsTechnicalEditorPage = lazyNamed( const LazyDocsTechnicalEditorPage = lazyNamed(
() => import("@/pages/docs/technical-editor/page"), () => import("@/pages/docs/technical-editor/page"),
"DocsTechnicalEditorPage", "DocsTechnicalEditorPage",
); );
const LazyDocsAudioPage = lazyNamed(
() => import("@/pages/docs/audio/page"),
"DocsAudioPage",
);
const LazyDocsHandTrackingPage = lazyNamed( const LazyDocsHandTrackingPage = lazyNamed(
() => import("@/pages/docs/hand-tracking/page"), () => import("@/pages/docs/hand-tracking/page"),
"DocsHandTrackingPage", "DocsHandTrackingPage",
@@ -55,6 +71,10 @@ const LazyDocsZustandPage = lazyNamed(
() => import("@/pages/docs/zustand/page"), () => import("@/pages/docs/zustand/page"),
"DocsZustandPage", "DocsZustandPage",
); );
const LazyDocsThreeDebuggingPage = lazyNamed(
() => import("@/pages/docs/three-debugging/page"),
"DocsThreeDebuggingPage",
);
const LazyDocsFeaturesPage = lazyNamed( const LazyDocsFeaturesPage = lazyNamed(
() => import("@/pages/docs/features/page"), () => import("@/pages/docs/features/page"),
"DocsFeaturesPage", "DocsFeaturesPage",
@@ -71,19 +91,31 @@ const LazyDocsAnimationPage = lazyNamed(
() => import("@/pages/docs/animation/page"), () => import("@/pages/docs/animation/page"),
"DocsAnimationPage", "DocsAnimationPage",
); );
const LazyDocsCodeReviewPage = lazyNamed(
() => import("@/pages/docs/code-review/page"),
"DocsCodeReviewPage",
);
export const DocsLayoutRoute = createDocsRoute(LazyDocsLayout); export const DocsLayoutRoute = createDocsRoute(LazyDocsLayout);
export const DocsReadmeRoute = createDocsRoute(LazyDocsReadmePage); export const DocsReadmeRoute = createDocsRoute(LazyDocsReadmePage);
export const DocsArchitectureRoute = createDocsRoute(LazyDocsArchitecturePage); export const DocsArchitectureRoute = createDocsRoute(LazyDocsArchitecturePage);
export const DocsSceneRuntimeRoute = createDocsRoute(LazyDocsSceneRuntimePage);
export const DocsRepairGameRoute = createDocsRoute(LazyDocsRepairGamePage);
export const DocsInteractionRoute = createDocsRoute(LazyDocsInteractionPage);
export const DocsTargetArchitectureRoute = createDocsRoute( export const DocsTargetArchitectureRoute = createDocsRoute(
LazyDocsTargetArchitecturePage, LazyDocsTargetArchitecturePage,
); );
export const DocsTechnicalEditorRoute = createDocsRoute( export const DocsTechnicalEditorRoute = createDocsRoute(
LazyDocsTechnicalEditorPage, LazyDocsTechnicalEditorPage,
); );
export const DocsAudioRoute = createDocsRoute(LazyDocsAudioPage);
export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage); export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage);
export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage); export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage);
export const DocsThreeDebuggingRoute = createDocsRoute(
LazyDocsThreeDebuggingPage,
);
export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage); export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);
export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage); export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage);
export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage); export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage);
export const DocsAnimationRoute = createDocsRoute(LazyDocsAnimationPage); export const DocsAnimationRoute = createDocsRoute(LazyDocsAnimationPage);
export const DocsCodeReviewRoute = createDocsRoute(LazyDocsCodeReviewPage);
+24
View File
@@ -0,0 +1,24 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface CinematicCameraKeyframe {
time: number;
position: Vector3Tuple;
target: Vector3Tuple;
}
export interface CinematicDialogueCue {
time: number;
dialogueId: string;
}
export interface CinematicDefinition {
id: string;
timecode?: number;
cameraKeyframes: CinematicCameraKeyframe[];
dialogueCues?: CinematicDialogueCue[];
}
export interface CinematicManifest {
version: 1;
cinematics: CinematicDefinition[];
}
+24
View File
@@ -0,0 +1,24 @@
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
export type DialogueVoiceId = "narrateur" | "fermier" | "electricienne";
export type DialogueSpeaker = "Narrateur" | "Fermier" | "Electricienne";
export interface DialogueVoice {
id: DialogueVoiceId;
speaker: DialogueSpeaker;
subtitles: Partial<Record<SubtitleLanguage, string>>;
}
export interface DialogueDefinition {
id: string;
voice: DialogueVoiceId;
audio: string;
subtitleCueIndex: number;
timecode?: number;
}
export interface DialogueManifest {
version: 1;
voices: DialogueVoice[];
dialogues: DialogueDefinition[];
}
@@ -0,0 +1,102 @@
import type {
CinematicCameraKeyframe,
CinematicDefinition,
CinematicDialogueCue,
CinematicManifest,
} from "@/types/cinematics/cinematics";
import type { Vector3Tuple } from "@/types/three/three";
export function parseCinematicManifest(data: unknown): CinematicManifest {
if (!isRecord(data) || data.version !== 1) {
throw new Error("Invalid cinematic manifest version");
}
if (!Array.isArray(data.cinematics)) {
throw new Error("Cinematic manifest requires a cinematics array");
}
return {
version: 1,
cinematics: data.cinematics.map(parseCinematicDefinition),
};
}
function parseCinematicDefinition(data: unknown): CinematicDefinition {
if (!isRecord(data) || typeof data.id !== "string") {
throw new Error("Invalid cinematic definition");
}
if (!Array.isArray(data.cameraKeyframes)) {
throw new Error(`Cinematic ${data.id} requires cameraKeyframes`);
}
const cameraKeyframes = data.cameraKeyframes.map(parseCameraKeyframe);
if (cameraKeyframes.length < 2) {
throw new Error(`Cinematic ${data.id} requires at least two keyframes`);
}
cameraKeyframes.forEach((keyframe, index) => {
const previousKeyframe = cameraKeyframes[index - 1];
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
throw new Error(`Cinematic ${data.id} keyframe times must increase`);
}
});
const cinematic: CinematicDefinition = {
id: data.id,
cameraKeyframes,
};
if (typeof data.timecode === "number") {
cinematic.timecode = data.timecode;
}
if (Array.isArray(data.dialogueCues)) {
cinematic.dialogueCues = data.dialogueCues.map(parseDialogueCue);
}
return cinematic;
}
function parseDialogueCue(data: unknown): CinematicDialogueCue {
if (
!isRecord(data) ||
typeof data.time !== "number" ||
typeof data.dialogueId !== "string"
) {
throw new Error("Invalid cinematic dialogue cue");
}
return {
time: data.time,
dialogueId: data.dialogueId,
};
}
function parseCameraKeyframe(data: unknown): CinematicCameraKeyframe {
if (!isRecord(data) || typeof data.time !== "number") {
throw new Error("Invalid cinematic camera keyframe");
}
return {
time: data.time,
position: parseVector3(data.position),
target: parseVector3(data.target),
};
}
function parseVector3(value: unknown): Vector3Tuple {
if (
!Array.isArray(value) ||
value.length !== 3 ||
value.some((item) => typeof item !== "number")
) {
throw new Error("Invalid cinematic vector");
}
return [value[0], value[1], value[2]];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,14 @@
import type { CinematicManifest } from "@/types/cinematics/cinematics";
import { parseCinematicManifest } from "@/utils/cinematics/cinematicManifestValidation";
const CINEMATIC_MANIFEST_PATH = "/cinematics.json";
export async function loadCinematicManifest(): Promise<CinematicManifest | null> {
const response = await fetch(CINEMATIC_MANIFEST_PATH);
if (!response.ok) {
return null;
}
return parseCinematicManifest(await response.json());
}
@@ -0,0 +1,140 @@
import type {
DialogueDefinition,
DialogueManifest,
DialogueSpeaker,
DialogueVoice,
DialogueVoiceId,
} from "@/types/dialogues/dialogues";
const VALID_VOICE_IDS = new Set<DialogueVoiceId>([
"narrateur",
"fermier",
"electricienne",
]);
const VALID_SPEAKERS = new Set<DialogueSpeaker>([
"Narrateur",
"Fermier",
"Electricienne",
]);
export function parseDialogueManifest(data: unknown): DialogueManifest {
if (!isRecord(data)) {
throw new Error("Dialogue manifest must be an object");
}
if (data.version !== 1) {
throw new Error("Unsupported dialogue manifest version");
}
if (!Array.isArray(data.voices) || !Array.isArray(data.dialogues)) {
throw new Error("Dialogue manifest requires voices and dialogues arrays");
}
const voices = data.voices.map(parseDialogueVoice);
const voiceIds = new Set(voices.map((voice) => voice.id));
const dialogues = data.dialogues.map((dialogue) =>
parseDialogueDefinition(dialogue, voiceIds),
);
return {
version: 1,
voices,
dialogues,
};
}
function parseDialogueVoice(data: unknown): DialogueVoice {
if (!isRecord(data)) {
throw new Error("Dialogue voice must be an object");
}
if (!isDialogueVoiceId(data.id)) {
throw new Error("Dialogue voice has an invalid id");
}
if (!isDialogueSpeaker(data.speaker)) {
throw new Error(`Dialogue voice ${data.id} has an invalid speaker`);
}
if (!isRecord(data.subtitles)) {
throw new Error(`Dialogue voice ${data.id} must define subtitles`);
}
const subtitles: DialogueVoice["subtitles"] = {};
const frSubtitle = getOptionalPath(data.subtitles.fr);
const enSubtitle = getOptionalPath(data.subtitles.en);
if (frSubtitle) subtitles.fr = frSubtitle;
if (enSubtitle) subtitles.en = enSubtitle;
return {
id: data.id,
speaker: data.speaker,
subtitles,
};
}
function parseDialogueDefinition(
data: unknown,
voiceIds: Set<DialogueVoiceId>,
): DialogueDefinition {
if (!isRecord(data)) {
throw new Error("Dialogue definition must be an object");
}
if (typeof data.id !== "string" || data.id.length === 0) {
throw new Error("Dialogue definition has an invalid id");
}
if (!isDialogueVoiceId(data.voice) || !voiceIds.has(data.voice)) {
throw new Error(`Dialogue ${data.id} references an unknown voice`);
}
if (typeof data.audio !== "string" || data.audio.length === 0) {
throw new Error(`Dialogue ${data.id} has an invalid audio path`);
}
const subtitleCueIndex = data.subtitleCueIndex;
if (
typeof subtitleCueIndex !== "number" ||
!Number.isInteger(subtitleCueIndex) ||
subtitleCueIndex < 1
) {
throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`);
}
const timecode = data.timecode;
if (timecode !== undefined && typeof timecode !== "number") {
throw new Error(`Dialogue ${data.id} has an invalid timecode`);
}
const dialogue: DialogueDefinition = {
id: data.id,
voice: data.voice,
audio: data.audio,
subtitleCueIndex,
};
if (timecode !== undefined) dialogue.timecode = timecode;
return dialogue;
}
function getOptionalPath(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function isDialogueVoiceId(value: unknown): value is DialogueVoiceId {
return (
typeof value === "string" && VALID_VOICE_IDS.has(value as DialogueVoiceId)
);
}
function isDialogueSpeaker(value: unknown): value is DialogueSpeaker {
return (
typeof value === "string" && VALID_SPEAKERS.has(value as DialogueSpeaker)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+116
View File
@@ -0,0 +1,116 @@
import type {
DialogueDefinition,
DialogueManifest,
DialogueVoice,
} from "@/types/dialogues/dialogues";
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
import { parseSrt } from "@/utils/subtitles/parseSrt";
import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
export interface DialogueSubtitleCue {
voice: DialogueVoice;
cue: SubtitleCue;
subtitlePath: string;
}
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
const response = await fetch(DIALOGUE_MANIFEST_PATH);
if (!response.ok) {
return null;
}
return parseDialogueManifest(await response.json());
}
export function resolveDialogueSubtitlePath(
manifest: DialogueManifest,
dialogue: DialogueDefinition,
language: SubtitleLanguage,
): string | null {
const voice = getDialogueVoice(manifest, dialogue.voice);
if (!voice) return null;
return getVoiceSubtitlePath(voice, language);
}
export function getDialogueVoice(
manifest: DialogueManifest,
voiceId: DialogueDefinition["voice"],
): DialogueVoice | null {
return manifest.voices.find((voice) => voice.id === voiceId) ?? null;
}
export async function loadDialogueSubtitleCue(
manifest: DialogueManifest,
dialogue: DialogueDefinition,
language: SubtitleLanguage,
): Promise<DialogueSubtitleCue | null> {
const voice = getDialogueVoice(manifest, dialogue.voice);
if (!voice) return null;
const subtitles = await loadVoiceSubtitleCues(voice, language);
if (!subtitles) return null;
const cue = subtitles.cues.find(
(item) => item.index === dialogue.subtitleCueIndex,
);
if (!cue) return null;
return {
voice,
cue,
subtitlePath: subtitles.path,
};
}
export async function loadVoiceSubtitleCues(
voice: DialogueVoice,
language: SubtitleLanguage,
): Promise<{ path: string; cues: SubtitleCue[] } | null> {
const paths = getVoiceSubtitlePaths(voice, language);
for (const path of paths) {
const srtContent = await loadSrtContent(path);
if (srtContent !== null) {
return { path, cues: parseSrt(srtContent) };
}
}
return null;
}
async function loadSrtContent(path: string): Promise<string | null> {
const response = await fetch(path);
if (!response.ok) {
return null;
}
return response.text();
}
function getVoiceSubtitlePaths(
voice: DialogueVoice,
language: SubtitleLanguage,
): string[] {
return [voice.subtitles[language], voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE]]
.filter((path): path is string => Boolean(path))
.filter((path, index, paths) => paths.indexOf(path) === index);
}
function getVoiceSubtitlePath(
voice: DialogueVoice,
language: SubtitleLanguage,
): string | null {
return (
voice.subtitles[language] ??
voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE] ??
null
);
}
+162
View File
@@ -0,0 +1,162 @@
import { AudioManager } from "@/managers/AudioManager";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueManifest } from "@/types/dialogues/dialogues";
import {
loadDialogueManifest,
loadDialogueSubtitleCue,
} from "@/utils/dialogues/loadDialogueManifest";
interface QueuedDialogueRequest {
manifest: DialogueManifest;
dialogueId: string;
resolve: (audio: HTMLAudioElement | null) => void;
}
const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
const dialogueQueue: QueuedDialogueRequest[] = [];
let gameplayDialogueManifestPromise: Promise<DialogueManifest | null> | null =
null;
let isDialogueQueuePlaying = false;
export function queueDialogueById(
manifest: DialogueManifest,
dialogueId: string,
): Promise<HTMLAudioElement | null> {
return new Promise((resolve) => {
dialogueQueue.push({ manifest, dialogueId, resolve });
void playNextQueuedDialogue();
});
}
export function clearQueuedDialogues(): void {
while (dialogueQueue.length > 0) {
dialogueQueue.shift()?.resolve(null);
}
}
export async function playGameplayDialogueById(
dialogueId: string,
): Promise<HTMLAudioElement | null> {
gameplayDialogueManifestPromise ??= loadDialogueManifest();
const manifest = await gameplayDialogueManifestPromise;
if (!manifest) return null;
return queueDialogueById(manifest, dialogueId);
}
export async function playDialogueById(
manifest: DialogueManifest,
dialogueId: string,
): Promise<HTMLAudioElement | null> {
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
if (!dialogue) return null;
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
const subtitle = await loadDialogueSubtitleCue(
manifest,
dialogue,
subtitleLanguage,
);
const audio = AudioManager.getInstance().playSound(dialogue.audio, 1, {
category: "dialogue",
});
if (!subtitle) return audio;
const clearSubtitle = (): void => {
useSubtitleStore.getState().clearActiveSubtitle();
};
const cleanup = (): void => {
audio.removeEventListener("play", syncSubtitle);
audio.removeEventListener("timeupdate", syncSubtitle);
audio.removeEventListener("ended", cleanup);
audio.removeEventListener("pause", cleanup);
clearSubtitle();
};
const syncSubtitle = (): void => {
const currentTime = audio.currentTime;
const shouldShowSubtitle =
currentTime >= subtitle.cue.startTime &&
currentTime <= subtitle.cue.endTime;
if (shouldShowSubtitle) {
useSubtitleStore.getState().setActiveSubtitle({
speaker: subtitle.voice.speaker,
text: subtitle.cue.text,
});
return;
}
clearSubtitle();
};
audio.addEventListener("play", syncSubtitle);
audio.addEventListener("timeupdate", syncSubtitle);
audio.addEventListener("ended", cleanup);
audio.addEventListener("pause", cleanup);
return audio;
}
async function playNextQueuedDialogue(): Promise<void> {
if (isDialogueQueuePlaying) return;
isDialogueQueuePlaying = true;
while (dialogueQueue.length > 0) {
const request = dialogueQueue.shift();
if (!request) continue;
try {
const audio = await playDialogueById(
request.manifest,
request.dialogueId,
);
request.resolve(audio);
if (audio) await waitForDialogueToFinish(audio);
} catch {
request.resolve(null);
}
}
isDialogueQueuePlaying = false;
}
function waitForDialogueToFinish(audio: HTMLAudioElement): Promise<void> {
if (audio.ended) return Promise.resolve();
return new Promise((resolve) => {
let hasStarted = !audio.paused;
let startTimeout: ReturnType<typeof setTimeout> | null = null;
function cleanup(): void {
if (startTimeout) clearTimeout(startTimeout);
audio.removeEventListener("play", handlePlay);
audio.removeEventListener("ended", finish);
audio.removeEventListener("pause", finish);
audio.removeEventListener("error", finish);
}
function finish(): void {
cleanup();
resolve();
}
function handlePlay(): void {
hasStarted = true;
if (startTimeout) clearTimeout(startTimeout);
}
audio.addEventListener("play", handlePlay);
audio.addEventListener("ended", finish);
audio.addEventListener("pause", finish);
audio.addEventListener("error", finish);
startTimeout = setTimeout(() => {
if (!hasStarted && audio.paused) finish();
}, DIALOGUE_PLAY_START_TIMEOUT_MS);
});
}
+62
View File
@@ -0,0 +1,62 @@
export interface SubtitleCue {
index: number;
startTime: number;
endTime: number;
text: string;
}
const SRT_TIME_SEPARATOR = " --> ";
const SRT_TIME_PATTERN = /^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/;
export function parseSrt(srtContent: string): SubtitleCue[] {
return srtContent
.replace(/^\uFEFF/, "")
.replace(/\r/g, "")
.trim()
.split(/\n{2,}/)
.map(parseSrtBlock)
.filter((cue): cue is SubtitleCue => cue !== null);
}
function parseSrtBlock(block: string): SubtitleCue | null {
const lines = block
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length < 3) return null;
const index = Number(lines[0]);
if (!Number.isInteger(index)) return null;
const [start, end] = lines[1]?.split(SRT_TIME_SEPARATOR) ?? [];
if (!start || !end) return null;
const startTime = parseSrtTime(start);
const endTime = parseSrtTime(end);
if (startTime === null || endTime === null || endTime <= startTime) {
return null;
}
return {
index,
startTime,
endTime,
text: lines.slice(2).join("\n"),
};
}
function parseSrtTime(value: string): number | null {
const match = value.match(SRT_TIME_PATTERN);
if (!match) return null;
const [, hours, minutes, seconds, milliseconds] = match;
if (!hours || !minutes || !seconds || !milliseconds) return null;
return (
Number(hours) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(milliseconds) / 1000
);
}
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import gsap from "gsap";
import * as THREE from "three";
import { useGameStore } from "@/managers/stores/useGameStore";
import type {
CinematicDefinition,
CinematicManifest,
} from "@/types/cinematics/cinematics";
import type { DialogueManifest } from "@/types/dialogues/dialogues";
import { logger } from "@/utils/core/Logger";
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { queueDialogueById } from "@/utils/dialogues/playDialogue";
export function GameCinematics(): null {
const camera = useThree((state) => state.camera);
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
const [dialogueManifest, setDialogueManifest] =
useState<DialogueManifest | null>(null);
const playedCinematicsRef = useRef(new Set<string>());
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
const startedAtRef = useRef<number | null>(null);
useEffect(() => {
let mounted = true;
const activeAudios = activeAudiosRef.current;
void loadCinematicManifest()
.then((loadedManifest) => {
if (mounted) setManifest(loadedManifest);
})
.catch((error: unknown) => {
logger.error("GameCinematics", "Failed to load cinematic manifest", {
error: error instanceof Error ? error : String(error),
});
});
void loadDialogueManifest()
.then((loadedManifest) => {
if (mounted) setDialogueManifest(loadedManifest);
})
.catch((error: unknown) => {
logger.error("GameCinematics", "Failed to load dialogue manifest", {
error: error instanceof Error ? error : String(error),
});
});
return () => {
mounted = false;
stopActiveCinematic(timelineRef);
activeAudios.forEach((audio) => audio.pause());
activeAudios.clear();
useGameStore.getState().setCinematicPlaying(false);
};
}, []);
useFrame(({ clock }) => {
if (!manifest) return;
startedAtRef.current ??= clock.getElapsedTime();
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
manifest.cinematics.forEach((cinematic) => {
if (cinematic.timecode === undefined) return;
if (cinematic.timecode > elapsedTime) return;
if (cinematic.dialogueCues && !dialogueManifest) return;
if (playedCinematicsRef.current.has(cinematic.id)) return;
playedCinematicsRef.current.add(cinematic.id);
playCinematic(camera, cinematic, timelineRef, {
dialogueManifest,
activeAudiosRef,
});
});
});
return null;
}
function stopActiveCinematic(
timelineRef: MutableRefObject<gsap.core.Timeline | null>,
): void {
timelineRef.current?.kill();
timelineRef.current = null;
}
function playCinematic(
camera: THREE.Camera,
cinematic: CinematicDefinition,
timelineRef: MutableRefObject<gsap.core.Timeline | null>,
dialogueOptions: {
dialogueManifest: DialogueManifest | null;
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
},
): void {
const firstKeyframe = cinematic.cameraKeyframes[0];
if (!firstKeyframe) return;
document.exitPointerLock();
timelineRef.current?.kill();
useGameStore.getState().setCinematicPlaying(true);
const target = new THREE.Vector3(...firstKeyframe.target);
camera.position.set(...firstKeyframe.position);
camera.lookAt(target);
const timeline = gsap.timeline({
onUpdate: () => camera.lookAt(target),
onComplete: () => {
timelineRef.current = null;
useGameStore.getState().setCinematicPlaying(false);
},
});
cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
const previousKeyframe = cinematic.cameraKeyframes[index];
if (!previousKeyframe) return;
const duration = keyframe.time - previousKeyframe.time;
timeline.to(
camera.position,
{
x: keyframe.position[0],
y: keyframe.position[1],
z: keyframe.position[2],
duration,
ease: "power2.inOut",
},
previousKeyframe.time,
);
timeline.to(
target,
{
x: keyframe.target[0],
y: keyframe.target[1],
z: keyframe.target[2],
duration,
ease: "power2.inOut",
},
previousKeyframe.time,
);
});
cinematic.dialogueCues?.forEach((cue) => {
timeline.call(
() => {
if (!dialogueOptions.dialogueManifest) return;
void queueDialogueById(
dialogueOptions.dialogueManifest,
cue.dialogueId,
).then((audio) => {
if (!audio) return;
dialogueOptions.activeAudiosRef.current.add(audio);
audio.addEventListener(
"ended",
() => dialogueOptions.activeAudiosRef.current.delete(audio),
{ once: true },
);
});
},
undefined,
cue.time,
);
});
timelineRef.current = timeline;
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import type { DialogueManifest } from "@/types/dialogues/dialogues";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import {
clearQueuedDialogues,
queueDialogueById,
} from "@/utils/dialogues/playDialogue";
import { logger } from "@/utils/core/Logger";
export function GameDialogues(): null {
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
const playedDialoguesRef = useRef(new Set<string>());
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
const startedAtRef = useRef<number | null>(null);
useEffect(() => {
let mounted = true;
const activeAudios = activeAudiosRef.current;
void loadDialogueManifest()
.then((loadedManifest) => {
if (mounted) setManifest(loadedManifest);
})
.catch((error: unknown) => {
logger.error("GameDialogues", "Failed to load dialogue manifest", {
error: error instanceof Error ? error : String(error),
});
});
return () => {
mounted = false;
clearQueuedDialogues();
activeAudios.forEach((audio) => audio.pause());
activeAudios.clear();
};
}, []);
useFrame(({ clock }) => {
if (!manifest) return;
startedAtRef.current ??= clock.getElapsedTime();
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
manifest.dialogues.forEach((dialogue) => {
if (dialogue.timecode === undefined) return;
if (dialogue.timecode > elapsedTime) return;
if (playedDialoguesRef.current.has(dialogue.id)) return;
playedDialoguesRef.current.add(dialogue.id);
void queueDialogueById(manifest, dialogue.id).then((audio) => {
if (!audio) return;
activeAudiosRef.current.add(audio);
audio.addEventListener(
"ended",
() => activeAudiosRef.current.delete(audio),
{ once: true },
);
});
});
});
return null;
}
+86 -47
View File
@@ -1,5 +1,12 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Component, Suspense, useEffect, useState } from "react"; import {
Component,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { GameMapCollision } from "@/world/GameMapCollision"; import { GameMapCollision } from "@/world/GameMapCollision";
@@ -20,6 +27,7 @@ interface ErrorBoundaryProps {
fallback: ReactNode; fallback: ReactNode;
modelUrl: string | null; modelUrl: string | null;
node: MapNode; node: MapNode;
onSettled: () => void;
} }
interface ErrorBoundaryState { interface ErrorBoundaryState {
@@ -50,6 +58,7 @@ class ModelErrorBoundary extends Component<
}, },
error, error,
); );
this.props.onSettled();
} }
render(): ReactNode { render(): ReactNode {
@@ -65,20 +74,42 @@ interface GameMapProps {
onLoaded?: (() => void) | undefined; onLoaded?: (() => void) | undefined;
onLoadingStateChange?: SceneLoadingChangeHandler | undefined; onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
onOctreeReady: OctreeReadyHandler; onOctreeReady: OctreeReadyHandler;
buildOctree?: boolean;
} }
const MAP_RENDER_BATCH_SIZE = 12;
export function GameMap({ export function GameMap({
buildOctree = true,
onLoaded, onLoaded,
onLoadingStateChange, onLoadingStateChange,
onOctreeReady, onOctreeReady,
}: GameMapProps): React.JSX.Element { }: GameMapProps): React.JSX.Element {
const settledMapNodesRef = useRef(new Set<number>());
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]); const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
const [mapLoaded, setMapLoaded] = useState(false); const [mapLoaded, setMapLoaded] = useState(false);
const [visibleNodeCount, setVisibleNodeCount] = useState(0); const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
const visibleMapNodes = mapNodes.slice(0, visibleNodeCount); const mapReady = mapLoaded && settledMapNodeCount >= mapNodes.length;
const mapReady = mapLoaded && visibleNodeCount >= mapNodes.length;
const handleMapNodeSettled = useCallback((index: number) => {
if (settledMapNodesRef.current.has(index)) return;
settledMapNodesRef.current.add(index);
setSettledMapNodeCount(settledMapNodesRef.current.size);
}, []);
const showEmptyMap = useCallback(
(currentStep: string) => {
setMapNodes([]);
setMapLoaded(true);
settledMapNodesRef.current.clear();
setSettledMapNodeCount(0);
onLoadingStateChange?.({
currentStep,
progress: 0.7,
status: "loading",
});
},
[onLoadingStateChange],
);
useEffect(() => { useEffect(() => {
onLoadingStateChange?.({ onLoadingStateChange?.({
@@ -92,11 +123,7 @@ export function GameMap({
const sceneData = await loadMapSceneData(); const sceneData = await loadMapSceneData();
if (!sceneData) { if (!sceneData) {
logger.warn("GameMap", "map.json not found"); logger.warn("GameMap", "map.json not found");
onLoadingStateChange?.({ showEmptyMap("Map introuvable");
currentStep: "Map introuvable",
progress: 1,
status: "loading",
});
return; return;
} }
@@ -126,9 +153,10 @@ export function GameMap({
setMapNodes(loadedMapNodes); setMapNodes(loadedMapNodes);
setMapLoaded(true); setMapLoaded(true);
setVisibleNodeCount(0); settledMapNodesRef.current.clear();
setSettledMapNodeCount(0);
onLoadingStateChange?.({ onLoadingStateChange?.({
currentStep: "Montage progressif des models", currentStep: "Chargement des modèles de la map",
progress: 0.25, progress: 0.25,
status: "loading", status: "loading",
}); });
@@ -136,67 +164,46 @@ export function GameMap({
logger.error("GameMap", "Error loading map", { logger.error("GameMap", "Error loading map", {
error: error instanceof Error ? error : new Error(String(error)), error: error instanceof Error ? error : new Error(String(error)),
}); });
onLoadingStateChange?.({ showEmptyMap("Erreur de chargement de la map");
currentStep: "Erreur de chargement de la map",
progress: 1,
status: "loading",
});
} }
}; };
loadMap(); loadMap();
}, [onLoaded, onLoadingStateChange]); }, [onLoadingStateChange, showEmptyMap]);
useEffect(() => {
if (mapNodes.length === 0 || visibleNodeCount >= mapNodes.length) return;
const frameId = window.requestAnimationFrame(() => {
setVisibleNodeCount((current) =>
Math.min(current + MAP_RENDER_BATCH_SIZE, mapNodes.length),
);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [mapNodes.length, visibleNodeCount]);
useEffect(() => { useEffect(() => {
if (mapNodes.length === 0) return; if (mapNodes.length === 0) return;
const renderProgress = const renderProgress =
mapNodes.length === 0 ? 1 : visibleNodeCount / mapNodes.length; mapNodes.length === 0 ? 1 : settledMapNodeCount / mapNodes.length;
onLoadingStateChange?.({ onLoadingStateChange?.({
currentStep: "Montage progressif des models", currentStep: "Chargement des modèles de la map",
progress: 0.25 + renderProgress * 0.45, progress: 0.25 + renderProgress * 0.45,
status: "loading", status: "loading",
}); });
}, [mapNodes.length, onLoadingStateChange, visibleNodeCount]); }, [mapNodes.length, onLoadingStateChange, settledMapNodeCount]);
return ( return (
<> <>
<group> <group>
{visibleMapNodes.map((mapNode, index) => ( {mapNodes.map((mapNode, index) => (
<ModelErrorBoundary <ModelErrorBoundary
key={index} key={index}
fallback={<FallbackMapNode node={mapNode.node} />} fallback={<FallbackMapNode node={mapNode.node} />}
modelUrl={mapNode.modelUrl} modelUrl={mapNode.modelUrl}
node={mapNode.node} node={mapNode.node}
onSettled={() => handleMapNodeSettled(index)}
> >
{mapNode.modelUrl ? ( <MapNodeInstance
<Suspense fallback={<FallbackMapNode node={mapNode.node} />}> node={mapNode.node}
<ModelInstance modelUrl={mapNode.modelUrl}
node={mapNode.node} onSettled={() => handleMapNodeSettled(index)}
modelUrl={mapNode.modelUrl} />
/>
</Suspense>
) : (
<FallbackMapNode node={mapNode.node} />
)}
</ModelErrorBoundary> </ModelErrorBoundary>
))} ))}
</group> </group>
<GameMapCollision <GameMapCollision
buildOctree={buildOctree}
mapReady={mapReady} mapReady={mapReady}
nodes={mapNodes} nodes={mapNodes}
onLoaded={onLoaded} onLoaded={onLoaded}
@@ -207,12 +214,40 @@ export function GameMap({
); );
} }
function MapNodeInstance({
node,
modelUrl,
onSettled,
}: {
node: MapNode;
modelUrl: string | null;
onSettled: () => void;
}): React.JSX.Element {
useEffect(() => {
if (modelUrl !== null) return;
onSettled();
}, [modelUrl, onSettled]);
if (!modelUrl) {
return <FallbackMapNode node={node} />;
}
return (
<Suspense fallback={<FallbackMapNode node={node} />}>
<ModelInstance node={node} modelUrl={modelUrl} onLoaded={onSettled} />
</Suspense>
);
}
function ModelInstance({ function ModelInstance({
node, node,
modelUrl, modelUrl,
onLoaded,
}: { }: {
node: MapNode; node: MapNode;
modelUrl: string; modelUrl: string;
onLoaded: () => void;
}): React.JSX.Element { }): React.JSX.Element {
const { position, rotation, scale } = node; const { position, rotation, scale } = node;
const { scene } = useLoggedGLTF(modelUrl, { const { scene } = useLoggedGLTF(modelUrl, {
@@ -223,6 +258,10 @@ function ModelInstance({
}); });
const sceneInstance = useClonedObject(scene); const sceneInstance = useClonedObject(scene);
useEffect(() => {
onLoaded();
}, [onLoaded]);
return ( return (
<primitive <primitive
object={sceneInstance} object={sceneInstance}
+21 -5
View File
@@ -27,6 +27,7 @@ interface ResolvedGameMapCollisionNode {
} }
interface GameMapCollisionProps { interface GameMapCollisionProps {
buildOctree?: boolean;
mapReady: boolean; mapReady: boolean;
nodes: readonly GameMapCollisionNode[]; nodes: readonly GameMapCollisionNode[];
onLoaded?: (() => void) | undefined; onLoaded?: (() => void) | undefined;
@@ -92,6 +93,7 @@ function isCollisionNode(
} }
export function GameMapCollision({ export function GameMapCollision({
buildOctree = true,
mapReady, mapReady,
nodes, nodes,
onLoaded, onLoaded,
@@ -100,11 +102,19 @@ export function GameMapCollision({
}: GameMapCollisionProps): React.JSX.Element { }: GameMapCollisionProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const settledCollisionNodesRef = useRef(new Set<number>()); const settledCollisionNodesRef = useRef(new Set<number>());
const loadedNotifiedRef = useRef(false);
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0); const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
const collisionNodes = nodes.filter(isCollisionNode); const collisionNodes = nodes.filter(isCollisionNode);
const collisionReady = const collisionReady =
mapReady && settledCollisionNodeCount >= collisionNodes.length; mapReady && settledCollisionNodeCount >= collisionNodes.length;
const notifyLoaded = useCallback(() => {
if (loadedNotifiedRef.current) return;
loadedNotifiedRef.current = true;
onLoaded?.();
}, [onLoaded]);
const handleCollisionNodeSettled = useCallback((index: number) => { const handleCollisionNodeSettled = useCallback((index: number) => {
if (settledCollisionNodesRef.current.has(index)) return; if (settledCollisionNodesRef.current.has(index)) return;
@@ -120,23 +130,28 @@ export function GameMapCollision({
status: "loading", status: "loading",
}); });
onOctreeReady(octree); onOctreeReady(octree);
onLoaded?.(); notifyLoaded();
}, },
[onLoaded, onLoadingStateChange, onOctreeReady], [notifyLoaded, onLoadingStateChange, onOctreeReady],
); );
useOctreeGraphNode( useOctreeGraphNode(
groupRef, groupRef,
handleOctreeReady, handleOctreeReady,
collisionReady ? collisionNodes.length : 0, collisionReady ? collisionNodes.length : 0,
collisionReady && collisionNodes.length > 0, buildOctree && collisionReady && collisionNodes.length > 0,
); );
useEffect(() => { useEffect(() => {
if (!mapReady) return; if (!mapReady) return;
if (collisionNodes.length === 0) { if (collisionNodes.length === 0) {
onLoaded?.(); notifyLoaded();
return;
}
if (collisionReady && !buildOctree) {
notifyLoaded();
return; return;
} }
@@ -148,10 +163,11 @@ export function GameMapCollision({
status: "loading", status: "loading",
}); });
}, [ }, [
buildOctree,
collisionNodes.length, collisionNodes.length,
collisionReady, collisionReady,
mapReady, mapReady,
onLoaded, notifyLoaded,
onLoadingStateChange, onLoadingStateChange,
]); ]);
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
const GAME_MUSIC_PATH = "/sounds/musique/test.mp3"; const GAME_MUSIC_PATH = "/sounds/musique/test.mp3";
const GAME_MUSIC_VOLUME = 0.45; const GAME_MUSIC_VOLUME = 0.33;
export function GameMusic(): null { export function GameMusic(): null {
useEffect(() => { useEffect(() => {
+35 -5
View File
@@ -1,4 +1,4 @@
import { Suspense } from "react"; import { Suspense, useEffect } from "react";
import { Physics } from "@react-three/rapier"; import { Physics } from "@react-three/rapier";
import { import {
PLAYER_SPAWN_POSITION_GAME, PLAYER_SPAWN_POSITION_GAME,
@@ -8,10 +8,13 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading"; import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
import { useGameStore } from "@/managers/stores/useGameStore";
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls"; import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers"; import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove"; import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
import { Environment } from "@/world/Environment"; import { Environment } from "@/world/Environment";
import { GameCinematics } from "@/world/GameCinematics";
import { GameDialogues } from "@/world/GameDialogues";
import { GameMusic } from "@/world/GameMusic"; import { GameMusic } from "@/world/GameMusic";
import { Lighting } from "@/world/Lighting"; import { Lighting } from "@/world/Lighting";
import { GameMap } from "@/world/GameMap"; import { GameMap } from "@/world/GameMap";
@@ -27,9 +30,16 @@ interface WorldProps {
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const mainState = useGameStore((state) => state.mainState);
const { status, usageStatus } = useHandTrackingSnapshot(); const { status, usageStatus } = useHandTrackingSnapshot();
const { octree, showGameStage, handleGameMapLoaded, handleOctreeReady } = const {
useWorldSceneLoading({ sceneMode, onLoadingStateChange }); octree,
gameplayReady,
showGameStage,
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const playerSpawnPosition = const playerSpawnPosition =
sceneMode === "game" sceneMode === "game"
? PLAYER_SPAWN_POSITION_GAME ? PLAYER_SPAWN_POSITION_GAME
@@ -37,6 +47,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
const showHandTrackingGloves = const showHandTrackingGloves =
sceneMode === "physics" || sceneMode === "physics" ||
(status !== "idle" && usageStatus !== "inactive"); (status !== "idle" && usageStatus !== "inactive");
const spawnPlayer =
cameraMode !== "debug" &&
(sceneMode === "game" ? gameplayReady : octree !== null);
return ( return (
<> <>
@@ -52,7 +65,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
{cameraMode === "debug" ? <DebugCameraControls /> : null} {cameraMode === "debug" ? <DebugCameraControls /> : null}
{sceneMode === "game" ? ( {sceneMode === "game" ? (
<> <>
<GameMusic />
<GameMap <GameMap
onLoaded={handleGameMapLoaded} onLoaded={handleGameMapLoaded}
onLoadingStateChange={onLoadingStateChange} onLoadingStateChange={onLoadingStateChange}
@@ -60,16 +72,34 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
/> />
{showGameStage ? ( {showGameStage ? (
<Physics> <Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
<GameStageContent /> <GameStageContent />
</Physics> </Physics>
) : null} ) : null}
{spawnPlayer ? (
<>
<GameMusic />
{mainState === "outro" ? <GameCinematics /> : null}
<GameDialogues />
<Player octree={octree} spawnPosition={playerSpawnPosition} />
</>
) : null}
</> </>
) : ( ) : (
<TestMap onOctreeReady={handleOctreeReady} /> <TestMap onOctreeReady={handleOctreeReady} />
)} )}
{cameraMode !== "debug" ? (
{sceneMode !== "game" && spawnPlayer ? (
<Player octree={octree} spawnPosition={playerSpawnPosition} /> <Player octree={octree} spawnPosition={playerSpawnPosition} />
) : null} ) : null}
</> </>
); );
} }
function GameStageLoaded({ onLoaded }: { onLoaded: () => void }): null {
useEffect(() => {
onLoaded();
}, [onLoaded]);
return null;
}
+1 -1
View File
@@ -154,7 +154,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}> <ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
<AnimatedModel <AnimatedModel
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH} modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
defaultAnimation="Idle" defaultAnimation="Dance"
position={[0, 0, -5]} position={[0, 0, -5]}
scale={1} scale={1}
/> />
+2 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useLayoutEffect } from "react";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import type { Octree } from "three/addons/math/Octree.js"; import type { Octree } from "three/addons/math/Octree.js";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
@@ -16,7 +16,7 @@ export function Player({
}: PlayerProps): React.JSX.Element { }: PlayerProps): React.JSX.Element {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
useEffect(() => { useLayoutEffect(() => {
camera.position.set(...spawnPosition); camera.position.set(...spawnPosition);
}, [camera, spawnPosition]); }, [camera, spawnPosition]);
+41 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
import { Capsule } from "three/addons/math/Capsule.js"; import { Capsule } from "three/addons/math/Capsule.js";
@@ -25,6 +25,8 @@ import {
} from "@/data/player/playerConfig"; } from "@/data/player/playerConfig";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked"; import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
import { InteractionManager } from "@/managers/InteractionManager"; import { InteractionManager } from "@/managers/InteractionManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
type Keys = { type Keys = {
@@ -55,6 +57,25 @@ const _up = new THREE.Vector3(0, 1, 0);
const _translateVec = new THREE.Vector3(); const _translateVec = new THREE.Vector3();
const _collisionCorrection = new THREE.Vector3(); const _collisionCorrection = new THREE.Vector3();
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
return new Capsule(
new THREE.Vector3(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
spawnPosition[2],
),
new THREE.Vector3(...spawnPosition),
PLAYER_CAPSULE_RADIUS,
);
}
function isPlayerInputLocked(): boolean {
return (
useSettingsStore.getState().isSettingsMenuOpen ||
useGameStore.getState().isCinematicPlaying
);
}
function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean { function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
switch (key.toLowerCase()) { switch (key.toLowerCase()) {
case MOVE_FORWARD_KEY: case MOVE_FORWARD_KEY:
@@ -85,16 +106,10 @@ export function PlayerController({
const velocity = useRef(new THREE.Vector3()); const velocity = useRef(new THREE.Vector3());
const onFloor = useRef(false); const onFloor = useRef(false);
const wantsJump = useRef(false); const wantsJump = useRef(false);
const initializedRef = useRef(false);
const capsule = useRef(createSpawnCapsule(spawnPosition));
const capsule = useRef( useLayoutEffect(() => {
new Capsule(
new THREE.Vector3(0, PLAYER_CAPSULE_RADIUS, 0),
new THREE.Vector3(0, PLAYER_EYE_HEIGHT - PLAYER_CAPSULE_RADIUS, 0),
PLAYER_CAPSULE_RADIUS,
),
);
useEffect(() => {
capsule.current.start.set( capsule.current.start.set(
spawnPosition[0], spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS, spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
@@ -105,6 +120,7 @@ export function PlayerController({
onFloor.current = false; onFloor.current = false;
wantsJump.current = false; wantsJump.current = false;
camera.position.copy(capsule.current.end); camera.position.copy(capsule.current.end);
initializedRef.current = true;
}, [camera, spawnPosition]); }, [camera, spawnPosition]);
useEffect(() => { useEffect(() => {
@@ -122,6 +138,8 @@ export function PlayerController({
const interaction = InteractionManager.getInstance(); const interaction = InteractionManager.getInstance();
const handleKeyDown = (event: KeyboardEvent): void => { const handleKeyDown = (event: KeyboardEvent): void => {
if (isPlayerInputLocked()) return;
if (setMovementKey(keys.current, event.key, true)) { if (setMovementKey(keys.current, event.key, true)) {
if (movementLockedRef.current) { if (movementLockedRef.current) {
keys.current = { ...DEFAULT_KEYS }; keys.current = { ...DEFAULT_KEYS };
@@ -151,12 +169,15 @@ export function PlayerController({
}; };
const handleKeyUp = (event: KeyboardEvent): void => { const handleKeyUp = (event: KeyboardEvent): void => {
if (isPlayerInputLocked()) return;
if (setMovementKey(keys.current, event.key, false)) { if (setMovementKey(keys.current, event.key, false)) {
event.preventDefault(); event.preventDefault();
} }
}; };
const handleMouseDown = (event: MouseEvent): void => { const handleMouseDown = (event: MouseEvent): void => {
if (isPlayerInputLocked()) return;
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return; if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
if (interaction.getState().focused?.kind === "grab") { if (interaction.getState().focused?.kind === "grab") {
interaction.pressInteract(); interaction.pressInteract();
@@ -164,6 +185,7 @@ export function PlayerController({
}; };
const handleMouseUp = (event: MouseEvent): void => { const handleMouseUp = (event: MouseEvent): void => {
if (isPlayerInputLocked()) return;
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return; if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
if (interaction.getState().holding) { if (interaction.getState().holding) {
interaction.releaseInteract(); interaction.releaseInteract();
@@ -185,6 +207,15 @@ export function PlayerController({
}, []); }, []);
useFrame((_, delta) => { useFrame((_, delta) => {
if (!initializedRef.current) return;
if (isPlayerInputLocked()) {
keys.current = { ...DEFAULT_KEYS };
velocity.current.set(0, 0, 0);
wantsJump.current = false;
return;
}
const dt = Math.min(delta, PLAYER_MAX_DELTA); const dt = Math.min(delta, PLAYER_MAX_DELTA);
camera.getWorldDirection(_forward); camera.getWorldDirection(_forward);
+596 -7
View File
@@ -6,12 +6,23 @@ import { fileURLToPath } from "node:url";
import type { ServerResponse } from "node:http"; import type { ServerResponse } from "node:http";
import type { Plugin } from "vite"; import type { Plugin } from "vite";
import { parseMapNodes } from "./src/utils/map/mapNodeValidation"; import { parseMapNodes } from "./src/utils/map/mapNodeValidation";
import { parseSrt } from "./src/utils/subtitles/parseSrt";
const __dirname = fileURLToPath(new URL(".", import.meta.url)); const __dirname = fileURLToPath(new URL(".", import.meta.url));
const THREE_SOURCE_ENTRY = fileURLToPath(
new URL("./node_modules/three/src/Three.js", import.meta.url),
);
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024; const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
const JSON_HEADERS = { "Content-Type": "application/json" }; const JSON_HEADERS = { "Content-Type": "application/json" };
type JsonResponseBody = Readonly<Record<string, string | boolean>>; type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
type JsonObject = { readonly [key: string]: JsonValue };
type JsonResponseBody = Readonly<Record<string, JsonValue>>;
const SRT_VOICES = new Set(["narrateur", "fermier", "electricienne"]);
const SRT_LANGUAGES = new Set(["fr", "en"]);
function sendJson( function sendJson(
res: ServerResponse, res: ServerResponse,
@@ -72,11 +83,589 @@ const saveMapPlugin = (): Plugin => ({
}, },
}); });
export default defineConfig({ const saveSrtPlugin = (): Plugin => ({
plugins: [react(), saveMapPlugin()], name: "save-srt-api",
resolve: { configureServer(server) {
alias: { server.middlewares.use("/api/save-srt", async (req, res) => {
"@": fileURLToPath(new URL("./src", import.meta.url)), if (req.method !== "POST") {
}, sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_SRT_PAYLOAD_BYTES) {
sendJson(res, 413, { error: "Payload too large" });
req.destroy();
return;
}
chunks.push(buffer);
}
try {
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
if (!isSrtPayload(data)) {
sendJson(res, 400, { error: "Invalid SRT payload" });
return;
}
if (!isValidSrtContent(data.content)) {
sendJson(res, 400, { error: "Invalid SRT content" });
return;
}
const subtitlesRoot = path.resolve(
__dirname,
"public/sounds/dialogue/subtitles",
);
const srtPath = path.resolve(
subtitlesRoot,
data.language,
`${data.voice}.srt`,
);
if (!srtPath.startsWith(`${subtitlesRoot}${path.sep}`)) {
sendJson(res, 400, { error: "Invalid SRT path" });
return;
}
await fs.promises.mkdir(path.dirname(srtPath), { recursive: true });
await fs.promises.writeFile(srtPath, data.content, "utf8");
sendJson(res, 200, { success: true });
} catch (err) {
const status = err instanceof SyntaxError ? 400 : 500;
const message = err instanceof Error ? err.message : "Unknown error";
sendJson(res, status, { error: message });
}
});
}, },
}); });
const validateDialoguesPlugin = (): Plugin => ({
name: "validate-dialogues-api",
configureServer(server) {
server.middlewares.use("/api/validate-dialogues", async (req, res) => {
if (req.method !== "GET") {
sendJson(res, 405, { error: "Method not allowed" }, { Allow: "GET" });
return;
}
try {
const result = await validateDialogueAssets();
sendJson(res, result.valid ? 200 : 400, result);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
sendJson(res, 500, { error: message });
}
});
},
});
const saveDialogueManifestPlugin = (): Plugin => ({
name: "save-dialogue-manifest-api",
configureServer(server) {
server.middlewares.use("/api/save-dialogues", async (req, res) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES) {
sendJson(res, 413, { error: "Payload too large" });
req.destroy();
return;
}
chunks.push(buffer);
}
try {
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
parseDialogueManifestData(data);
const manifestPath = path.resolve(
__dirname,
"public/sounds/dialogue/dialogues.json",
);
await fs.promises.writeFile(
manifestPath,
`${JSON.stringify(data, null, 2)}\n`,
"utf8",
);
sendJson(res, 200, { success: true });
} catch (err) {
const status = err instanceof SyntaxError ? 400 : 500;
const message = err instanceof Error ? err.message : "Unknown error";
sendJson(res, status, { error: message });
}
});
},
});
const saveCinematicManifestPlugin = (): Plugin => ({
name: "save-cinematic-manifest-api",
configureServer(server) {
server.middlewares.use("/api/save-cinematics", async (req, res) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES) {
sendJson(res, 413, { error: "Payload too large" });
req.destroy();
return;
}
chunks.push(buffer);
}
try {
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
const manifest = parseCinematicManifestData(data);
const dialogueManifest = await loadDialogueManifestData();
validateCinematicDialogueCues(manifest, dialogueManifest);
const manifestPath = path.resolve(__dirname, "public/cinematics.json");
await fs.promises.writeFile(
manifestPath,
`${JSON.stringify(data, null, 2)}\n`,
"utf8",
);
sendJson(res, 200, { success: true });
} catch (err) {
const status = err instanceof SyntaxError ? 400 : 500;
const message = err instanceof Error ? err.message : "Unknown error";
sendJson(res, status, { error: message });
}
});
},
});
interface SrtPayload {
voice: string;
language: string;
content: string;
}
interface DialogueManifestData {
voices: DialogueVoiceData[];
dialogues: DialogueData[];
}
interface DialogueVoiceData {
id: string;
speaker: string;
subtitles: Partial<Record<"fr" | "en", string>>;
}
interface DialogueData {
id: string;
voice: string;
audio: string;
subtitleCueIndex: number;
timecode?: number;
}
interface CinematicManifestData {
cinematics: CinematicData[];
}
interface CinematicData {
id: string;
timecode?: number;
dialogueCues?: CinematicDialogueCueData[];
cameraKeyframes: CinematicKeyframeData[];
}
interface CinematicDialogueCueData {
time: number;
dialogueId: string;
}
interface CinematicKeyframeData {
time: number;
position: [number, number, number];
target: [number, number, number];
}
function isSrtPayload(data: unknown): data is SrtPayload {
if (!data || typeof data !== "object") return false;
const payload = data as Partial<SrtPayload>;
return (
typeof payload.voice === "string" &&
SRT_VOICES.has(payload.voice) &&
typeof payload.language === "string" &&
SRT_LANGUAGES.has(payload.language) &&
typeof payload.content === "string"
);
}
function isValidSrtContent(content: string): boolean {
const blocks = content
.replace(/^\uFEFF/, "")
.replace(/\r/g, "")
.trim()
.split(/\n{2,}/)
.filter(Boolean);
const cues = parseSrt(content);
if (blocks.length === 0 || cues.length !== blocks.length) return false;
const cueIndexes = new Set<number>();
for (const cue of cues) {
if (cueIndexes.has(cue.index)) return false;
cueIndexes.add(cue.index);
}
return true;
}
interface DialogueValidationResult extends JsonObject {
valid: boolean;
errors: string[];
warnings: string[];
}
async function validateDialogueAssets(): Promise<DialogueValidationResult> {
const errors: string[] = [];
const warnings: string[] = [];
const manifest = await loadDialogueManifestData();
const subtitleCueCache = new Map<string, Set<number>>();
for (const voice of manifest.voices) {
const frSubtitlePath = voice.subtitles.fr;
if (!frSubtitlePath) {
errors.push(`Voice ${voice.id} must define a French subtitle file`);
} else {
await validateSubtitleFile(frSubtitlePath, errors, subtitleCueCache);
}
const enSubtitlePath = voice.subtitles.en;
if (enSubtitlePath) {
const resolvedEnPath = resolvePublicPath(enSubtitlePath);
if (!resolvedEnPath || !fs.existsSync(resolvedEnPath)) {
warnings.push(
`English subtitle file missing for voice ${voice.id}; runtime will fall back to French`,
);
}
}
}
for (const dialogue of manifest.dialogues) {
const audioPath = resolvePublicPath(dialogue.audio);
if (!audioPath || !fs.existsSync(audioPath)) {
errors.push(`Dialogue ${dialogue.id} audio file is missing`);
}
const voice = manifest.voices.find(
(item: DialogueVoiceData) => item.id === dialogue.voice,
);
const frSubtitlePath = voice?.subtitles.fr;
const cueIndexes = frSubtitlePath
? subtitleCueCache.get(frSubtitlePath)
: undefined;
if (!cueIndexes?.has(dialogue.subtitleCueIndex)) {
errors.push(
`Dialogue ${dialogue.id} references missing cue ${dialogue.subtitleCueIndex}`,
);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
async function loadDialogueManifestData(): Promise<DialogueManifestData> {
const manifestPath = path.resolve(
__dirname,
"public/sounds/dialogue/dialogues.json",
);
const manifestContent = await fs.promises.readFile(manifestPath, "utf8");
return parseDialogueManifestData(JSON.parse(manifestContent));
}
function parseDialogueManifestData(data: unknown): DialogueManifestData {
if (!isRecord(data) || data.version !== 1) {
throw new Error("Invalid dialogue manifest");
}
if (!Array.isArray(data.voices) || !Array.isArray(data.dialogues)) {
throw new Error("Dialogue manifest requires voices and dialogues arrays");
}
const voices = data.voices.map(parseDialogueVoiceData);
const voiceIds = new Set(voices.map((voice) => voice.id));
const dialogues = data.dialogues.map((dialogue) =>
parseDialogueData(dialogue, voiceIds),
);
return { voices, dialogues };
}
function parseDialogueVoiceData(data: unknown): DialogueVoiceData {
if (!isRecord(data) || typeof data.id !== "string") {
throw new Error("Invalid dialogue voice");
}
if (typeof data.speaker !== "string") {
throw new Error(`Dialogue voice ${data.id} must define a speaker`);
}
if (!isRecord(data.subtitles)) {
throw new Error(`Dialogue voice ${data.id} must define subtitles`);
}
const subtitles: DialogueVoiceData["subtitles"] = {};
if (typeof data.subtitles.fr === "string") subtitles.fr = data.subtitles.fr;
if (typeof data.subtitles.en === "string") subtitles.en = data.subtitles.en;
return {
id: data.id,
speaker: data.speaker,
subtitles,
};
}
function parseDialogueData(data: unknown, voiceIds: Set<string>): DialogueData {
if (!isRecord(data)) {
throw new Error("Invalid dialogue definition");
}
if (
typeof data.id !== "string" ||
typeof data.voice !== "string" ||
!voiceIds.has(data.voice) ||
typeof data.audio !== "string" ||
typeof data.subtitleCueIndex !== "number" ||
!Number.isInteger(data.subtitleCueIndex)
) {
throw new Error("Invalid dialogue definition");
}
const dialogue: DialogueData = {
id: data.id,
voice: data.voice,
audio: data.audio,
subtitleCueIndex: data.subtitleCueIndex,
};
if (data.timecode !== undefined) {
if (typeof data.timecode !== "number") {
throw new Error("Invalid dialogue definition");
}
dialogue.timecode = data.timecode;
}
return dialogue;
}
function parseCinematicManifestData(data: unknown): CinematicManifestData {
if (!isRecord(data) || data.version !== 1) {
throw new Error("Invalid cinematic manifest");
}
if (!Array.isArray(data.cinematics)) {
throw new Error("Cinematic manifest requires a cinematics array");
}
return {
cinematics: data.cinematics.map(parseCinematicData),
};
}
function parseCinematicData(data: unknown): CinematicData {
if (!isRecord(data) || typeof data.id !== "string") {
throw new Error("Invalid cinematic definition");
}
if (!Array.isArray(data.cameraKeyframes)) {
throw new Error(`Cinematic ${data.id} requires cameraKeyframes`);
}
const cameraKeyframes = data.cameraKeyframes.map(parseCinematicKeyframeData);
if (cameraKeyframes.length < 2) {
throw new Error(`Cinematic ${data.id} requires at least two keyframes`);
}
cameraKeyframes.forEach((keyframe, index) => {
const previousKeyframe = cameraKeyframes[index - 1];
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
throw new Error(`Cinematic ${data.id} keyframe times must increase`);
}
});
const cinematic: CinematicData = {
id: data.id,
cameraKeyframes,
};
if (data.timecode !== undefined) {
if (typeof data.timecode !== "number") {
throw new Error(`Cinematic ${data.id} has an invalid timecode`);
}
cinematic.timecode = data.timecode;
}
if (data.dialogueCues !== undefined) {
if (!Array.isArray(data.dialogueCues)) {
throw new Error(`Cinematic ${data.id} has invalid dialogue cues`);
}
cinematic.dialogueCues = data.dialogueCues.map(
parseCinematicDialogueCueData,
);
}
return cinematic;
}
function validateCinematicDialogueCues(
cinematicManifest: CinematicManifestData,
dialogueManifest: DialogueManifestData,
): void {
const dialogueIds = new Set(
dialogueManifest.dialogues.map((dialogue) => dialogue.id),
);
for (const cinematic of cinematicManifest.cinematics) {
for (const cue of cinematic.dialogueCues ?? []) {
if (!dialogueIds.has(cue.dialogueId)) {
throw new Error(
`Cinematic ${cinematic.id} references unknown dialogue ${cue.dialogueId}`,
);
}
}
}
}
function parseCinematicDialogueCueData(
data: unknown,
): CinematicDialogueCueData {
if (
!isRecord(data) ||
typeof data.time !== "number" ||
typeof data.dialogueId !== "string"
) {
throw new Error("Invalid cinematic dialogue cue");
}
return {
time: data.time,
dialogueId: data.dialogueId,
};
}
function parseCinematicKeyframeData(data: unknown): CinematicKeyframeData {
if (!isRecord(data) || typeof data.time !== "number") {
throw new Error("Invalid cinematic camera keyframe");
}
return {
time: data.time,
position: parseCinematicVector(data.position),
target: parseCinematicVector(data.target),
};
}
function parseCinematicVector(value: unknown): [number, number, number] {
if (
!Array.isArray(value) ||
value.length !== 3 ||
value.some((item) => typeof item !== "number")
) {
throw new Error("Invalid cinematic vector");
}
return [value[0], value[1], value[2]];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
async function validateSubtitleFile(
publicPath: string,
errors: string[],
subtitleCueCache: Map<string, Set<number>>,
): Promise<void> {
const subtitlePath = resolvePublicPath(publicPath);
if (!subtitlePath || !fs.existsSync(subtitlePath)) {
errors.push(`Subtitle file ${publicPath} is missing`);
return;
}
const content = await fs.promises.readFile(subtitlePath, "utf8");
if (!isValidSrtContent(content)) {
errors.push(`Subtitle file ${publicPath} is invalid`);
return;
}
subtitleCueCache.set(
publicPath,
new Set(parseSrt(content).map((cue) => cue.index)),
);
}
function resolvePublicPath(publicPath: string): string | null {
if (!publicPath.startsWith("/")) return null;
const publicRoot = path.resolve(__dirname, "public");
const resolvedPath = path.resolve(publicRoot, publicPath.slice(1));
if (!resolvedPath.startsWith(`${publicRoot}${path.sep}`)) return null;
return resolvedPath;
}
export default defineConfig(({ mode }) => {
const isThreeDebug = mode === "three-debug";
return {
plugins: [
react(),
saveMapPlugin(),
saveSrtPlugin(),
saveDialogueManifestPlugin(),
saveCinematicManifestPlugin(),
validateDialoguesPlugin(),
],
resolve: {
alias: [
{
find: "@",
replacement: fileURLToPath(new URL("./src", import.meta.url)),
},
...(isThreeDebug
? [{ find: /^three$/, replacement: THREE_SOURCE_ENTRY }]
: []),
],
},
...(isThreeDebug
? {
optimizeDeps: {
exclude: ["three"],
},
}
: {}),
};
});