252 Commits

Author SHA1 Message Date
Tom Boullay 6854f52b23 fix: a pb with octree
🔍 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-11 16:43:02 +02:00
Tom Boullay 601cc4b6be update: en dialogue sub 2026-05-11 13:37:12 +02:00
Tom Boullay ee41361a90 update: cinematic references 2026-05-11 13:22:15 +02:00
Tom Boullay b077b65640 update: doc 2026-05-11 13:14:08 +02:00
Tom Boullay 808fd1631b update: doc dialogue and cinematic tools 2026-05-11 13:10:26 +02:00
Tom Boullay 85c45029f2 update: assit dialogue and srt creation 2026-05-11 13:05:03 +02:00
Tom Boullay 5802d5adf8 update: edit cinematic dialogue 2026-05-11 13:01:56 +02:00
Tom Boullay 35f8d8fc87 update: sync dialogue and cinematic 2026-05-11 12:58:12 +02:00
Tom Boullay 0b58b9aeef add: cinematic preview 2026-05-11 12:53:18 +02:00
Tom Boullay f9e7243659 update: add dialogue preview 2026-05-11 12:48:59 +02:00
Tom Boullay 9e41004a99 update: add cinecmatic editor 2026-05-11 12:11:58 +02:00
Tom Boullay 6d73ffaccf update: add dialogue manifest 2026-05-11 11:48:05 +02:00
Tom Boullay d059a9aa14 update: align srt duration 2026-05-11 11:38:19 +02:00
Tom Boullay 40ba348b1f update: french srt 2026-05-11 11:22:06 +02:00
Tom Boullay d74de82cae update: add runtine camera keyframe 2026-05-11 11:13:49 +02:00
Tom Boullay 241e4140e7 update: audio already use 2026-05-11 10:29:46 +02:00
Tom Boullay c4f3cc0ff6 update: audimanager 2026-05-11 10:22:12 +02:00
Tom Boullay 78f6f5c1b0 update: trigger dialogue en fonction du gameplay 2026-05-11 10:03:07 +02:00
Tom Boullay c5cc6f685a docs: queue dialogue 2026-05-11 09:43:40 +02:00
Tom Boullay dd41d2cbb2 docs: add some docs 2026-05-11 09:18:46 +02:00
Tom Boullay daba532b5f add: dev dialogue manisfest validation panel 2026-05-11 09:09:34 +02:00
Tom Boullay b088db2a8b add: dev manifest api validation 2026-05-10 00:54:33 +01:00
Tom Boullay 5646bae7ef update: add stereo 2026-05-10 00:50:34 +01:00
Tom Boullay 352296b98d fix: add config vite error srt 2026-05-10 00:49:19 +01:00
Tom Boullay a800cd2bfc update: gros commit fix editor srt panel 3 2026-05-10 00:40:26 +01:00
Tom Boullay 2757b5c389 update: gros commit fix editor srt panel 2 2026-05-10 00:37:28 +01:00
Tom Boullay 64ebeee014 update: gros commit fix editor srt panl 2026-05-10 00:35:23 +01:00
Tom Boullay 9969e86e9c update: confort + ui 2026-05-10 00:33:18 +01:00
Tom Boullay 6a394b301e add: audio preview 2026-05-10 00:31:16 +01:00
Tom Boullay b5b69afa3c update: generate complete srt template 2026-05-10 00:29:42 +01:00
Tom Boullay 3bc7524220 update: fix bug de merde 2026-05-10 00:27:48 +01:00
Tom Boullay 7aafc4da5f update: validation/errors srt 2026-05-10 00:25:45 +01:00
Tom Boullay cd29805009 update: save srt files 2026-05-10 00:23:37 +01:00
Tom Boullay 54274d49ed add: french subtitles 2026-05-10 00:20:16 +01:00
Tom Boullay b4c49d87d8 add: add str editing panel 2026-05-10 00:13:42 +01:00
Tom Boullay 70346de362 add: trigger dialogue with timecode 2026-05-10 00:10:16 +01:00
Tom Boullay 2bb980c71c update: play audio + srt sync 2026-05-10 00:07:56 +01:00
Tom Boullay 205169c98f upatde: load dialogue en fonction du language 2026-05-10 00:04:59 +01:00
Tom Boullay 27a2f5b816 add: load dialohue manifest 2026-05-10 00:02:48 +01:00
Tom Boullay f4eee8b483 add: dialoguejson 2026-05-10 00:00:36 +01:00
Tom Boullay 8a8005bd7f add: parser srt files 2026-05-09 23:53:19 +01:00
Tom Boullay 7cf622d787 add: basic subtitle 2026-05-09 23:51:22 +01:00
Tom Boullay 486aea9647 add: settings menu + menu store 2026-05-09 23:45:05 +01:00
Tom Boullay 41cb61ae2d add: global cat volumes 2026-05-09 23:37:07 +01:00
Tom Boullay e0adb84eca add: type audio playback cat 2026-05-09 23:30:14 +01:00
Tom Boullay b0bb127459 Merge pull request #7 from La-Fabrik-Durable/feat/main-feature
Feat/main feature
2026-05-08 01:56:18 +02:00
Tom Boullay f84aa748cd Update HandTrackingGlove.tsx 2026-05-08 00:54:42 +01:00
Tom Boullay a1ff534aa7 add hand tracking source debug switch 2026-05-06 23:23:10 +01:00
Tom Boullay 5824ae162a add browser hand tracking source 2026-05-06 23:23:04 +01:00
Tom Boullay d7dd76a853 fix hand tracking glove root transform 2026-05-06 23:22:56 +01:00
Tom Boullay 9a1849b0f8 fix: flickering hands 2026-05-06 23:16:58 +01:00
Tom Boullay 74a901a48b fix; distance grab objetc 2026-05-02 21:34:23 +02:00
Tom Boullay 584a68bce6 add hand tracking glove bone mapping 2026-05-02 11:38:02 +02:00
Tom Boullay 94cea80af4 fix hand tracking glove fallback and loading 2026-05-02 11:35:28 +02:00
Tom Boullay 0b950a4557 fix hand tracking glove rendering 2026-05-02 11:32:00 +02:00
Tom Boullay 442bfbc8d4 update: remove old model elec 2026-05-02 11:02:51 +02:00
Tom Boullay 5bd0680b64 fix three and rapier warning dependencies 2026-05-02 11:01:50 +02:00
Tom Boullay 04ece5b1d2 fix electricienne debug model loading 2026-05-02 10:58:00 +02:00
Tom Boullay 8fbb2e9428 feat add left hand tracking glove model 2026-05-02 00:14:56 +02:00
Tom Boullay 2aa662669f feat add model loading diagnostics 2026-05-02 00:14:47 +02:00
Tom Boullay 4031f0de87 cleaning repo models 2026-05-01 23:54:48 +02:00
Tom Boullay e6d78d203a update: models made them working 2026-05-01 23:45:58 +02:00
Tom Boullay 50ddd35979 update: debug overlay layout controls 2026-05-01 23:39:04 +02:00
Tom Boullay 6f264969ee Update TestMap.tsx 2026-04-30 16:29:56 +02:00
Tom Boullay 106b68d487 connect repair gameplay to zustand progression 2026-04-30 16:25:54 +02:00
Tom Boullay aaedd9e3a4 fix: models 2026-04-30 15:48:45 +02:00
Tom Boullay 1b50fe4f5b Merge branch 'develop' into feat/main-feature 2026-04-30 15:48:35 +02:00
Tom Boullay 4bc385fb09 Update arbre.bin 2026-04-30 15:09:27 +02:00
Tom Boullay 65450d9208 Merge branch 'design' into feat/main-feature 2026-04-30 15:09:22 +02:00
Tom Boullay 9fa4439de8 Merge pull request #11 from La-Fabrik-Durable/feat/zustand
Feat/zustand
2026-04-30 15:07:44 +02:00
Tom Boullay c7128d58ed resolve three component type exports 2026-04-30 15:06:26 +02:00
Tom Boullay 0858525c44 address zustand progression review feedback 2026-04-30 14:59:41 +02:00
Tom Boullay e7bb4d2b63 clarify managers and zustand store responsibilities 2026-04-30 14:38:07 +02:00
Tom Boullay 0f845f28c5 add zustand game state 2026-04-30 14:29:29 +02:00
Tom Boullay d740e2a436 add : some sounds 2026-04-30 14:25:36 +02:00
Tom Boullay cf20aa8ea4 connect game progression state to world 2026-04-30 14:24:59 +02:00
Tom Boullay 85b91e63cb add zustand game progression store 2026-04-30 14:04:01 +02:00
Tom Boullay 9998fb65f8 chore: align repo health checks and docs 2026-04-30 13:51:39 +02:00
Tom Boullay c5b672cdb5 add: prettier eslint 2026-04-30 13:36:07 +02:00
Tom Boullay fda70bade2 refactor: clean architecture and remove unused code 2026-04-30 13:33:28 +02:00
Tom Boullay c698b9ef78 refactor: split hooks types and utils by domain 2026-04-30 11:49:18 +02:00
Tom Boullay 081e87c96d refactor: organize three components by domain 2026-04-30 11:35:53 +02:00
Tom Boullay ab8376b03e fix: correct repair case open state rotation 2026-04-30 10:42:47 +02:00
Tom Boullay d5f537eb8b feat: add game music loop and mallette sounds 2026-04-30 10:06:00 +02:00
Tom Boullay 475a4c7c5e refactor: prepare main feature gameplay object and use GLB sky model 2026-04-30 10:02:00 +02:00
Tom Boullay d7b77b2f44 feat: expand main feature model catalog 2026-04-29 23:30:40 +02:00
Tom Boullay 793997ed06 feat: add main feature module selection 2026-04-29 23:30:31 +02:00
Tom Boullay 72e4047420 feat: add openable repair case model 2026-04-29 23:30:22 +02:00
Tom Boullay 638e10a132 chore: track bin assets with lfs 2026-04-29 17:07:44 +02:00
Tom Boullay 4e594d36fa 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 3a0639bdaa feat: support glb model assets 2026-04-29 16:18:24 +02:00
Tom Boullay d0361c0a38 Merge branch 'develop' into feat/main-feature 2026-04-29 15:01:17 +02:00
Tom Boullay 471424f83d update: docs 2026-04-29 13:01:10 +02:00
math-pixel 60c966be93 Merge branch 'design' of https://github.com/La-Fabrik-Durable/La-Fabrik into design 2026-04-29 12:16:51 +02:00
math-pixel 62deb6e322 add object 2026-04-29 12:00:11 +02:00
math-pixel bc3f28bdb2 feat: add tree 2026-04-29 11:57:43 +02:00
Tom Boullay 2a3b088294 fix: position perf panel beside debug gui 2026-04-29 11:56:46 +02:00
math-pixel 4e8a68b04a Merge pull request #10 from La-Fabrik-Durable/feat-animation
🔍 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
Feat/animation
2026-04-29 11:50:07 +02:00
Tom Boullay 5627373752 feat: improve hand grab targeting 2026-04-29 11:40:17 +02:00
math-pixel b997f576c5 fix: pr 2026-04-29 11:35:17 +02:00
math-pixel 20142b7e5f 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 2b6b045f4a fix: pr issues 2026-04-29 11:23:40 +02:00
Tom Boullay 5b14a1d971 fix: decouple hand tracking from crosshair focus 2026-04-29 11:13:11 +02:00
math-pixel 29cd03fc21 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 7c5d7f3834 update: animation doc 2026-04-29 11:00:32 +02:00
Tom Boullay 7a3dd976e7 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 fffabc01c2 feat: improve fist grab depth tracking 2026-04-29 10:52:35 +02:00
math-pixel 93744b15f7 fix : comflic 2026-04-29 10:51:40 +02:00
Tom Boullay c5bf10a7fb update: upload-gltf add a new model -> hand_l
📦 Model
   model.gltf

🎨 Textures (color)
   color_gant.png (compressed)

🧭 Textures (normal)
   normal_gant.png (compressed)

🧱 Textures (orm)
   orm_gant.png (compressed)

🧩 Assets
   hanf_l.bin
2026-04-29 10:45:16 +02:00
Tom Boullay d9fc9d0a15 feat: grab objects with closed fist raycast 2026-04-29 10:40:48 +02:00
Tom Boullay d4dd0fa283 refactor: replace pinch gesture with fist gesture 2026-04-29 10:34:11 +02:00
Tom Boullay e42c06b888 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 3230b644e4 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 3503ff52ed fix: guard hand landmark visualization 2026-04-29 09:52:46 +02:00
Tom Boullay a8ece3a448 Create model.gltf 2026-04-29 09:05:04 +02:00
Tom Boullay 3f1e15f616 Merge branch 'design' into feat/main-feature 2026-04-29 09:05:00 +02:00
Tom Boullay b0f0f3cb91 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 35cd3c7c64 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 cfa1bd9e16 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 359417ecd4 feat: animator 2026-04-28 20:14:37 +02:00
Tom Boullay b9a3fbfc99 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 9d814c9924 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 eb875068eb 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 e8a5a44218 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 9c12c7a9e5 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 1907f2623b 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 cf5be3d45d 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 9ada4298c3 wip 2026-04-28 16:54:00 +02:00
Tom Boullay 9ff75e0516 fix: persist debug modes and skip missing map models 2026-04-28 16:35:33 +02:00
Tom Boullay 3b8c59db87 Merge branch 'develop' into feat/main-feature 2026-04-28 16:27:05 +02:00
Tom Boullay 5e0125e05a 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 b81f85cd50 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 8f1a553601 update: upload-gltf add a new model -> gant
📦 Model
   model.gltf

🎨 Textures (color)
   color_gant.png (compressed)

🧭 Textures (normal)
   normal_gant.png (compressed)

🧱 Textures (orm)
   orm_gant.png (compressed)

🧩 Assets
   gant.bin
2026-04-28 16:01:41 +02:00
Tom Boullay 9b8bb1a182 Merge pull request #6 from La-Fabrik-Durable/feat/docs-routing
Feat/docs-routing
2026-04-28 15:04:29 +02:00
Tom Boullay a8c6fafbcd address docs routing review feedback 2026-04-28 15:02:50 +02:00
Tom Boullay 14a55e8dd1 clean docs router declarations 2026-04-28 14:53:28 +02:00
Tom Boullay 2dd5bfeda1 move debug components out of utils 2026-04-28 14:47:26 +02:00
Tom Boullay e20ead88e1 standardize source naming conventions 2026-04-28 14:46:27 +02:00
Tom Boullay 7e99d455b4 fix runtime map loading lifecycle 2026-04-28 14:42:49 +02:00
Tom Boullay 8c6af0ed6d rename pages 2026-04-28 14:25:29 +02:00
Tom Boullay 324aa9dc0f clean branch-scoped code quality issues 2026-04-28 14:23:37 +02:00
Tom Boullay 356bb5ef88 organize data configs by domain 2026-04-28 14:17:21 +02:00
Tom Boullay ece9b1268f refactor feature folders by code type 2026-04-28 14:14:15 +02:00
Tom Boullay d2735b72a0 refactor docs into feature folder 2026-04-28 13:54:41 +02:00
Tom Boullay fc5e4acba4 group docs navigation by audience 2026-04-28 13:48:03 +02:00
Tom Boullay a3db0b2f0d add editor documentation pages 2026-04-28 13:47:56 +02:00
Tom Boullay f9a0480121 fix react three peer dependencies 2026-04-28 13:47:49 +02:00
Tom Boullay aa7db176e6 update: app and main 2026-04-28 13:32:54 +02:00
Tom Boullay 0f83f57e23 Merge branch 'develop' into feat/docs-routing 2026-04-28 13:31:40 +02:00
Tom Boullay d4e7edaa89 Merge pull request #4 from La-Fabrik-Durable/feat-editor
Feat/editor
2026-04-28 13:22:44 +02:00
Tom Boullay ab21df18cb fix editor map reliability 2026-04-28 11:06:09 +02:00
Tom Boullay 7e067ecccd Update Map.tsx 2026-04-28 10:53:57 +02:00
Tom Boullay 3fac43d5f1 Create Map.tsx 2026-04-28 10:52:05 +02:00
Tom Boullay abfbb284f5 Update debugConfig.ts 2026-04-28 10:48:42 +02:00
math-pixel e3162d6588 Merge pull request #9 from La-Fabrik-Durable/feat/deploy-test
Feat/deploy test
2026-04-28 10:45:25 +02:00
math-pixel 7588f7f736 Merge pull request #8 from La-Fabrik-Durable/feat/deploy-test
Feat/deploy test
2026-04-28 10:44:12 +02:00
Tom Boullay 5ff5b89302 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 af35150452 cleaaning 2026-04-28 10:42:57 +02:00
math-pixel 31a99902dd Delete test-editor.html 2026-04-28 10:39:58 +02:00
Tom Boullay a259c3d2e2 fix: style 2026-04-28 10:30:31 +02:00
Tom Boullay e19cc72ad5 tyle: refresh editor controls with monochrome UI 2026-04-28 10:08:17 +02:00
Tom Boullay e1d2bfdc75 refactor: move game map into world folder 2026-04-28 09:47:09 +02:00
Tom Boullay 8f40bb8133 docs: document editor architecture and user features 2026-04-28 09:43:51 +02:00
Tom Boullay 7b38f04a0d refactor: move editor page and types to conventional folders 2026-04-28 09:29:18 +02:00
Tom Boullay 7dea0f99a8 add: stylesheet 2026-04-28 09:07:56 +02:00
math-pixel eade051241 update: deploy file 2026-04-27 20:46:52 +02:00
math-pixel e868e72402 update: deploy file 2026-04-27 20:40:05 +02:00
math-pixel 4783784fb3 feat: change version 2026-04-27 20:27:21 +02:00
math-pixel bfe8c49323 fix :editor 2026-04-27 17:25:56 +02:00
Tom Boullay 06e59a972f docs: clarify backend virtual environment setup 2026-04-27 17:11:08 +02:00
math-pixel 1a91fcaca0 fix: main model map 2026-04-27 16:38:05 +02:00
Tom Boullay 055e7b2e63 fix: address docs routing review 2026-04-27 16:32:23 +02:00
Tom Boullay 149f9aa26c clean: package json 2026-04-27 16:27:57 +02:00
math-pixel 21d91f1de1 update models loading in /editor 2026-04-27 16:27:56 +02:00
Tom Boullay 68b0ceb593 feat: add localized docs pages 2026-04-27 16:27:08 +02:00
math-pixel 868f7a1cfd fix: load all models/ 2026-04-27 16:07:57 +02:00
Tom Boullay e25152b3e5 feat move debug cube with remote hand tracking 2026-04-27 16:07:54 +02:00
Tom Boullay 641d2f8871 feat add remote hand tracking backend 2026-04-27 15:49:02 +02:00
Tom Boullay 7fd39f58d8 feat: add docs routing 2026-04-27 15:35:56 +02:00
Tom Boullay 2b6bcc4d92 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 2001955625 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 3254291ba7 fix: lint 2026-04-27 14:19:26 +02:00
math-pixel 753a767662 fix: format 2026-04-27 13:57:17 +02:00
math-pixel bcf3a63fc5 Merge branch 'develop' into feat-editor 2026-04-27 13:55:13 +02:00
math-pixel ab8c84e006 Merge remote-tracking branch 'origin/develop' into feat-editor 2026-04-27 13:52:08 +02:00
Tom Boullay 8abc69ebc3 Merge branch 'develop' of https://github.com/La-Fabrik-Durable/La-Fabrik into develop 2026-04-27 13:50:50 +02:00
math-pixel b63412de13 update: map & update: package json for CI 2026-04-27 13:44:14 +02:00
Tom Boullay 9fdf065c1d 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 4a697ab790 cleaning: repo + model 2026-04-27 13:36:35 +02:00
Tom Boullay 29144f8844 Merge pull request #3 from La-Fabrik-Durable/design
upload: models
2026-04-27 13:31:21 +02:00
Tom Boullay 74b9bf57c8 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 5402c343fa fix: docs update debug project tree 2026-04-27 11:57:21 +02:00
Tom Boullay 5569da07c1 Create .prettierignore 2026-04-27 11:23:29 +02:00
Tom Boullay 38abeb3b49 fix: format & lint 2026-04-27 11:20:59 +02:00
Tom Boullay eb0db21d29 clean 2026-04-27 11:14:43 +02:00
Tom Boullay 393b653cca fix: archi 2026-04-27 10:53:50 +02:00
Tom Boullay e87004652f update: upload-gltf update -> lafabrik
📦 Model
  ↔️ model.glb (compressed)

🎨 Textures (color)
  🔄 anneaux_base_color.png (compressed)

🪶 Textures (roughness)
  🔄 anneaux_roughness.png (compressed)
   verre_fenetre_roughness.png (compressed)

🧭 Textures (normal)
  🔄 anneaux_normal.png (compressed)
  🔄 anneaux_normal_opengl.png (compressed)

🔩 Textures (metalness)
  🔄 anneaux_metallic.png (compressed)

🧩 Assets
  🔄 anneaux_mixed_ao.png (compressed)
  🔄 anneaux_height.png (compressed)
2026-04-24 17:55:13 +02:00
Tom Boullay 9d6693b5b6 update: upload-gltf update -> lafabrik
📦 Model
  ↔️ model.glb (compressed)

🎨 Textures (color)
  🔄 bat_base_color.png (compressed)
  🔄 comptoir_base_color.png (compressed)
  🔄 porte_base_color.png (compressed)

🪶 Textures (roughness)
  🔄 bat_roughness.png (compressed)
  🔄 anneaux_roughness.png (compressed)
  🔄 porte_roughness.png (compressed)
  🔄 tiges_roughness.png (compressed)

🧭 Textures (normal)
  🔄 tiges_normal.png (compressed)
  🔄 porte_normal.png (compressed)
  🔄 porte_normal_opengl.png (compressed)
  🔄 anneaux_normal.png (compressed)
  🔄 bat_normal.png (compressed)
  🔄 anneaux_normal_opengl.png (compressed)
  🔄 bat_normal_opengl.png (compressed)

🔩 Textures (metalness)
  🔄 porte_metallic.png (compressed)
  🔄 tiges_metallic.png (compressed)
  🔄 comptoir_metallic.png (compressed)
  🔄 anneaux_metallic.png (compressed)
  🔄 bat_metallic.png (compressed)

🧩 Assets
  🔄 comptoir_height.png (compressed)
  🔄 porte_height.png (compressed)
  🔄 porte_mixed_ao.png (compressed)
  🔄 tiges_mixed_ao.png (compressed)
  🔄 anneaux_mixed_ao.png (compressed)
  🔄 bat_height.png (compressed)
  🔄 bat_mixed_ao.png (compressed)

🗑 Deleted
   verre_fenetre_roughness.png
2026-04-24 17:44:02 +02:00
Tom Boullay 1db7c22a90 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 bf858645fd 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 8c84663472 add: a logger utils 2026-04-24 14:02:16 +02:00
math-pixel 6b8ba3d58d feat : save map.json on project 2026-04-23 15:40:10 +02:00
math-pixel d0cf876372 feat editor 2026-04-23 15:24:40 +02:00
Tom Boullay 38f9f087d1 Create package-lock.json 2026-04-19 16:51:10 +02:00
Tom Boullay dcbc1c73f5 refacto : cleaning the codebasebase again 2026-04-19 16:50:11 +02:00
Tom Boullay f9c4495610 refacto: cleanning the codebase 2026-04-17 16:03:29 +02:00
Tom Boullay 23d4291458 update: upload-gltf add a new model -> general/electricienne
📦 Model
   model.gltf
🎨 Textures
   roughness (manquant)
   normal (manquant)
   metalness (manquant)
   color (manquant)
   displace (manquant)
2026-04-17 15:58:30 +02:00
Tom Boullay 638022339e update : put every constante in the data folder 2026-04-17 15:42:10 +02:00
Tom Boullay 20fbaf05e1 update : add map model + octree algo 2026-04-17 11:36:03 +02:00
Tom Boullay ed7681a293 update: add a physic scenne 2026-04-17 10:48:18 +02:00
Tom Boullay b26da614f0 refacto: enleve la map 2026-04-16 16:11:20 +02:00
Tom Boullay 106727256b 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 86e9860121 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 1eed905e8b fix: archi player 2026-04-16 11:00:08 +02:00
Tom Boullay 7769959135 refactor: tighten project structure and strengthen tooling 2026-04-16 10:45:05 +02:00
Tom Boullay fd7571fbe1 Update hero.png 2026-04-16 09:25:23 +02:00
Tom Boullay 3506858c96 fix: lint 2026-04-15 16:42:06 +02:00
Tom Boullay 61d7495ec9 feat: add player camera 2026-04-15 16:40:52 +02:00
Tom Boullay d486f6f381 feat: add the map 2026-04-15 16:09:02 +02:00
Tom Boullay f67799db30 feat: add map blocking and cleanup 2026-04-15 13:36:53 +02:00
Tom Boullay 76dc306d4d Merge branch 'main' into design 2026-04-15 13:32:38 +02:00
Tom Boullay 8300ff844f cleaning 2026-04-15 13:32:21 +02:00
Tom Boullay 4c9594e260 fix ci 2026-04-15 13:30:04 +02:00
Tom Boullay 27214b02c1 update: add more CI 2026-04-15 11:17:48 +02:00
Tom Boullay 3da749c73e update : docs and skills 2026-04-15 11:06:41 +02:00
Tom Boullay 922df5b2b4 update: upload-gltf add a new model -> general/coffeetest
📦 Model
   model.gltf
🎨 Textures
   roughness.jpg
   normal.jpg
   metalness.jpg
   color.jpg
   displace.jpg
2026-04-15 09:51:23 +02:00
Tom Boullay cd1abd504e update: upload-gltf add a new model -> general/coffeetest
📦 Model
   model.gltf
🎨 Textures
   roughness.jpg
   normal.jpg
   metalness.jpg
   color.jpg
   displace.jpg
2026-04-15 09:51:21 +02:00
Tom Boullay 648c6d9992 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 361a52d84b update: remove eveything 2026-04-15 09:41:46 +02:00
Tom Boullay d07e7ac62a update: upload-gltf update -> general/vase
📦 Model
  ↔️ model.gltf (inchange)
🎨 Textures
   normal.jpg
2026-04-15 09:25:02 +02:00
Tom Boullay 726c9abae8 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 0fc4b5ecbe upatde: add models 2026-04-15 09:02:33 +02:00
Tom Boullay 0c7eca9396 Merge branch 'design' of https://github.com/La-Fabrik-Durable/La-Fabrik into design 2026-04-15 09:02:24 +02:00
Tom Boullay d3102d4e1c update : add models 2026-04-15 09:02:15 +02:00
Tom Boullay a72d312494 update: upload-gltf update -> general/vase
📦 Model
  🔄 model.gltf (compressed)
🎨 Textures
   color.jpg
2026-04-14 16:41:58 +02:00
Tom Boullay 455071ed40 update: upload-gltf update -> general/vase
📦 Model
  🔄 model.gltf (compressed)
🎨 Textures
   color.jpg (supprime)
2026-04-14 16:40:55 +02:00
Tom Boullay 283efef321 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 0c49d63bbf update: upload-gltf update -> general/coffeetest
🎨 Textures
   metalness.jpg
2026-04-14 13:56:37 +02:00
Tom Boullay 1e3832454c 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 47572d3793 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 ff6bb8b986 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 8dae23acc3 add some folder 2026-04-14 12:22:20 +02:00
Tom Boullay 82c4b612bf update: add agent.md + skills 2026-04-14 09:20:30 +02:00
Tom Boullay afd72b9f6c Create ci.yml 2026-04-14 09:02:12 +02:00
Tom Boullay dbb3c46e35 upatde: add prettier 2026-04-14 08:59:36 +02:00
Tom Boullay 25e3d503b2 update: add basic structure 2026-04-14 08:39:09 +02:00
Tom Boullay c12026a331 add : finish readme, git lfs and gitignore 2026-04-13 23:30:33 +02:00
Tom Boullay 9966bb8e25 Update README.md 2026-04-13 23:17:59 +02:00
Tom Boullay 96976de21b Update README.md 2026-04-13 22:59:56 +02:00
Tom Boullay 86b889e2fc add: license + v1 archi 2026-04-13 22:24:20 +02:00
Tom Boullay b55e60bb16 add readme 2026-04-13 16:37:37 +02:00
Tom Boullay dfd46d420b Initial commit 2026-04-13 16:12:21 +02:00
145 changed files with 1092 additions and 3656 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ You are working on **La Fabrik**, an interactive 3D web experience built with Re
## Current Implementation
- Stack: React 19, Three.js, `@react-three/fiber`, `@react-three/drei`, `@react-three/rapier`, TypeScript, Vite
- Zustand is used for shared game progression state.
- No external global state library is used.
- Current singleton-style services are limited to:
- `InteractionManager`
- `AudioManager`
-1
View File
@@ -8,7 +8,6 @@ __pycache__/
# Build
dist/
dist-ssr/
.vite/
*.local
# Environment
+2 -4
View File
@@ -49,8 +49,7 @@ la-fabrik/
└── src/
├── world/ # Persistent 3D world composition
│ ├── World.tsx # Active scene composition
│ ├── GameMap.tsx # Map loading and progressive rendering
│ ├── GameMapCollision.tsx # Collision-only octree source
│ ├── GameMap.tsx # Map loading and octree collision
│ ├── Lighting.tsx # Ambient, directional, point lights
│ ├── Environment.tsx # Scene background / sky model
│ ├── GameMusic.tsx # Game scene music lifecycle
@@ -102,8 +101,7 @@ la-fabrik/
│ ├── 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
├── App.tsx # Canvas bootstrap
└── main.tsx
```
+3 -2
View File
@@ -37,8 +37,9 @@ Use `useClonedObject` when a GLTF scene is reused by a component instance. It me
src/components/three/
├── gameplay/
│ ├── RepairCaseModel.tsx
│ ├── RepairGame.tsx
── RepairRepairingStep.tsx
│ ├── RepairCaseObject.tsx
── RepairGameZone.tsx
│ └── RepairModuleSlot.tsx
├── interaction/
│ ├── GrabbableObject.tsx
│ ├── InteractableObject.tsx
+7 -24
View File
@@ -14,24 +14,10 @@ This document describes the code that exists today in the repository.
- debug helpers and debug camera mode
- either the map scene or the debug physics test scene
- the player rig when the active camera mode is `player`
- `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/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.
- `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/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, and builds the collision octree.
- `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map.
- `src/world/player/Player.tsx` mounts the camera and controller.
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, repair-step movement locking, and interaction input.
## Physics Boundaries
The project currently uses two collision layers with separate responsibilities:
- `GameMapCollision` builds an octree used by the player controller for map collision.
- 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.
- `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.
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input.
## Interaction Model
@@ -92,7 +78,6 @@ Keep the player and map octree outside the Rapier provider until there is a deli
- `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.
@@ -103,7 +88,7 @@ Keep the player and map octree outside the Rapier provider until there is a deli
- `src/components/three/models/` contains reusable model helpers such as `ExplodableModel`.
- `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.
- `src/components/three/gameplay/` contains the reusable production `RepairGame` flow, repair case, repair steps, and repair prompt components.
- `src/components/three/gameplay/` contains the current core repair gameplay prototype: the repair case, repair game zone, and module slots.
- `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`.
## Editor System
@@ -121,22 +106,20 @@ Keep the player and map octree outside the Rapier provider until there is a deli
- `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/types/editor/editor.ts` contains the shared `MapNode`, `SceneData`, and `TransformMode` types.
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used across store, config, debug UI, and gameplay components.
## Map Data
- `public/map.json` is expected to be a `MapNode[]`.
- 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.
- The game scene renders fallback cubes for nodes whose model cannot be resolved.
- 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.
- The game scene filters out nodes whose model cannot be resolved.
## Current Limitations
- The repository is a prototype, not the full intended game runtime.
- `src/world/debug/TestMap.tsx` is part of the active scene composition.
- There is no central gameplay orchestrator such as `GameManager`.
- Mission state exists in Zustand and the repair flow is implemented as a prototype for the current repair missions.
- Cinematics and dialogues exist as prototype timecode-driven systems; dialogue branching and broader gameplay orchestration are still limited.
- Missions and zones are not implemented.
- Dialogue branching and gameplay-triggered orchestration are still limited.
- The player uses octree collision and simple movement rules, not a complete gameplay physics stack.
- Editor save-to-server is implemented as a Vite dev-server plugin, not a production backend API.
+5 -14
View File
@@ -4,9 +4,9 @@ This document describes the hand tracking system that exists in the current code
## Purpose
Hand tracking started as a debug-stage interaction system used to test direct 3D object manipulation with a webcam. It allows a user to close their fist to grab a nearby object and move it in 3D space without relying on the center crosshair.
Hand tracking is a debug-stage interaction system used to test direct 3D object manipulation with a webcam. It allows a user to close their fist to grab a nearby object and move it in 3D space without relying on the center crosshair.
It is now also available to the production repair flow when a mission reaches a hand-driven step.
The feature is scoped to the debug physics scene rather than production gameplay input.
## Runtime Flow
@@ -16,13 +16,13 @@ It is now also available to the production repair flow when a mission reaches a
4. The backend returns hand data including landmarks, handedness, score, center point, and `isFist`.
5. React stores the latest snapshot in the hand tracking provider.
6. `GrabbableObject` reads that snapshot each frame and uses fist state plus raycasting to grab objects.
7. `HandTrackingGlove` reads the same snapshot and places the rigged `gant_l` and `gant_r` models on the detected hands when hand tracking is active.
7. `HandTrackingGlove` reads the same snapshot and places the rigged `gant_l` and `gant_r` models on the detected hands in the debug physics scene.
## Activation Rules
Hand tracking is intentionally gated so the webcam and backend are not used all the time.
The debug activation conditions are:
The current activation conditions are:
- debug mode is active with `?debug`
- scene mode is `physics`
@@ -30,15 +30,6 @@ The debug activation conditions are:
This keeps hand tracking active while the player is inside an interaction zone, even if the camera is not aimed directly at the object.
The production repair activation conditions are:
- active `mainState` is `bike`, `pylone`, or `ferme`
- the active mission step is `inspected`, `repairing`, `reassembling`, or `done`
This keeps the webcam off during `waiting`, `fragmented`, and `scanning`, then enables hand input only when the repair flow is expected to use hands.
In the current production repair flow, `inspected` uses a two-fists hold gesture to advance to `fragmented`. The hold must last one second and is independent from local object interaction distance once the mission is in the correct state. Keyboard input for the same transition is handled separately by the repair case trigger, so pressing `E` requires the case to be focused through the shared interaction system.
## Backend
The backend lives in `backend/` and exposes:
@@ -130,7 +121,7 @@ The glove models are intentionally smaller than the raw SVG overlay so they do n
## Known Limitations
- Production usage is currently limited to repair mission steps that explicitly need hands.
- The feature is debug-only and focused on the physics test scene.
- MediaPipe depth is relative and can be noisy.
- 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.
+1 -25
View File
@@ -75,7 +75,6 @@ The mission steps currently use this sequence:
"fragmented" |
"scanning" |
"repairing" |
"reassembling" |
"done";
```
@@ -115,32 +114,10 @@ setMainState("bike");
Direct setters are useful for debug panels, but production gameplay should prefer business actions such as `advanceGameState`, `completeBike`, or `completePylone`.
Mission gameplay that can target `bike`, `pylone`, or `ferme` should prefer the generic mission actions:
```ts
const setMissionStep = useGameStore((state) => state.setMissionStep);
const completeMission = useGameStore((state) => state.completeMission);
setMissionStep("bike", "inspected");
completeMission("bike");
```
This keeps reusable gameplay components such as repair flows from duplicating mission-specific branches like `setBikeState`, `setPyloneState`, and `setFermeState`.
## World Integration
`src/world/GameStageContent.tsx` subscribes to `mainState` and mounts stage-specific content.
For repair missions, it mounts the reusable `RepairGame` component with a mission id:
```tsx
<RepairGame mission="bike" position={[8, 0, -6]} />
```
`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.
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`.
That means the scene can progressively move toward this pattern:
```tsx
@@ -170,7 +147,6 @@ Current overlays:
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states, stepping backward or forward, and resetting the store
- `Crosshair`: player aiming helper
- `InteractPrompt`: interaction prompt
- `RepairMovementLockIndicator`: player-facing indicator shown while repair steps temporarily disable movement
`src/pages/page.tsx` should stay thin and mount only the canvas and `GameUI`.
@@ -185,4 +161,4 @@ Current overlays:
## Next Steps
Move repair validation into mission data once each mission has distinct broken module nodes, replacement assets, and completion events.
The next natural step is to replace the temporary stage anchors in `GameStageContent` with real stage components, for example `IntroContent`, `BikeContent`, `PyloneContent`, `FermeContent`, and `OutroContent`.
+3 -16
View File
@@ -6,9 +6,7 @@ This document lists features that are implemented in the current codebase.
- Fullscreen React Three Fiber scene
- Main map scene loaded from `public/map.json` and matching `public/models/{name}/model.glb` or `model.gltf` assets
- Minimal fullscreen scene loading overlay for 3D scenes, with a global progress bar used by the production map, debug physics scene, and editor scene
- 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`
- Rapier physics context available for production stage gameplay objects
- Debug physics test scene selectable from the debug panel
- Ambient and directional lighting
- Environment background setup
@@ -18,24 +16,15 @@ This document lists features that are implemented in the current codebase.
- Pointer lock mouse look
- Movement with `ZQSD`
- Jumping
- Movement lock during active repair steps, with an on-screen indicator while keeping trigger interactions available
- Octree-based collision against dedicated map collision nodes, currently scoped to `terrain`
- Octree-based collision against the loaded map
## Interactions
- Focus detection by distance and raycast
- Trigger interactions activated with `E`
- Grab interactions activated with the primary mouse button
- Physics-backed gameplay objects can be mounted inside stage content without replacing player octree collision
- Interaction prompt shown for trigger interactions
## Repair Gameplay
- Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states
- 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
- Repair mission config shared through `src/data/gameplay/repairMissions.ts`, including per-mission broken nodes, placeholder targets, scan timing, and reassembly timing
- 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
## Audio
- Category-based volumes for music, SFX, and dialogue
@@ -75,7 +64,6 @@ This document lists features that are implemented in the current codebase.
- `?debug` query param enables the debug panel
- `lil-gui` controls for camera mode, scene mode, `R3F Perf`, `Debug Overlay`, and interaction tuning
- Compact debug overlay for game state controls and hand tracking status
- Debug game-state mission switching unlocks locked repair missions at `waiting` for faster testing
- Debug scene helpers
- Free debug camera
- `r3f-perf` overlay
@@ -102,9 +90,8 @@ This document lists features that are implemented in the current codebase.
## Not Implemented Yet
- complete mission system
- mission system
- zone system
- full cinematic system beyond current timecode prototype
- gameplay-triggered dialogue branches beyond current prototype triggers
- loading flow
- minimap and mission HUD
+29 -59
View File
@@ -1,78 +1,50 @@
# Main Feature
This document explains the current repair-game flow in La-Fabrik.
This document explains the current repair-game prototype in La-Fabrik.
## What It Does
The main feature is a reusable repair flow mounted in the production game scene. It lets the player approach the active mission object, inspect it, fragment it, scan the broken part, install the correct replacement, validate completion, and move to the next mission state.
The main feature is a repair interaction sandbox mounted in the debug physics scene. It lets the player approach a repair case, open it, and interact with module slots that can show selectable models and exploded-model states.
The current user flow is:
1. Enter a mission state such as `bike`, `pylone`, or `ferme`.
2. Move close to the active repair object in the game scene.
3. Aim at the object and press the interaction key when prompted.
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.
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`.
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.
9. In `repairing`, the case opens in a larger focused view and several grabbable replacement parts appear on the case placeholders.
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.
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.
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`.
1. Open the app with `?debug`.
2. Switch the scene to `Physics` in the debug panel.
3. Move close to the repair case.
4. Press the interaction key when prompted.
5. Watch the case open or close with sound feedback.
6. Interact with repair module slots to cycle/select repair models.
## 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 core repair fantasy 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.
## 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.
The repair case reacts to player proximity. When the player is close enough, it floats upward and rotates gently to signal interactivity. When the player moves away, it returns to its resting transform.
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.
Interacting with the case toggles its open state. The lid animation is handled with GSAP because it is a discrete interaction animation, not a continuous per-frame loop.
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.
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.
Repair module slots are configured from static gameplay data. They render selectable repair models and can use exploded model visualization to show parts separated from their original positions.
## Key Files
- `src/world/GameStageContent.tsx` mounts production `RepairGame` instances for `bike`, `pylone`, and `ferme`.
- `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, case exit animation, and mission UI prompt.
- `src/components/three/gameplay/RepairGame.tsx` composes the reusable production repair flow.
- `src/components/three/gameplay/RepairBrokenPartHighlight.tsx` renders the red halo and wire marker around detected broken parts during scanning.
- `src/components/three/gameplay/RepairBrokenPartPrompt.tsx` centers the configured broken UI video on detected broken parts during scanning.
- `src/components/three/gameplay/RepairInspectionObject.tsx` handles the `waiting` inspection interaction.
- `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection.
- `src/components/three/gameplay/RepairRepairingStep.tsx` renders grabbable replacement choices, grabbable scanned broken parts, placeholder placement markers, snap placement behavior, correct-part and broken-part placement validation, and the install trigger in `repairing`.
- `src/components/three/gameplay/RepairReassemblyStep.tsx` renders the inverse fragmentation animation before the final completion step.
- `src/components/three/gameplay/RepairCompletionParticles.tsx` renders the green completion particles during reassembly.
- `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/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/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/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator.
- `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/world/debug/TestMap.tsx` mounts the repair-game prototype in the debug physics scene.
- `src/components/three/gameplay/RepairGameZone.tsx` composes the repair-game zone.
- `src/components/three/gameplay/RepairCaseObject.tsx` connects the repair case to trigger interaction and audio.
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model.
- `src/components/three/gameplay/RepairModuleSlot.tsx` renders repair slots and model selection behavior.
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
- `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants.
- `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants.
- `src/data/gameplay/repairMissions.ts` stores reusable repair mission config for `bike`, `pylone`, and `ferme`.
- `src/managers/stores/useGameStore.ts` stores mission progression state and generic mission step helpers.
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used by the store, data config, debug UI, and gameplay components.
- `src/data/gameplay/repairGameConfig.ts` stores repair zone and slot positions.
- `src/data/gameplay/repairGameModelCatalog.ts` stores selectable repair models.
## Runtime Requirements
## Debug Requirements
The production repair flow currently requires:
The repair-game prototype currently requires:
- the active `mainState` to be one of `bike`, `pylone`, or `ferme`
- `GameStageContent` mounted inside the game scene Rapier `Physics` boundary
- the app opened with `?debug`
- the debug scene set to `Physics`
- model assets available under `public/models/`
- sound assets available under `public/sounds/`
@@ -82,17 +54,15 @@ Frontend command:
npm run dev
```
Debug URL for state switching and inspection:
Debug URL:
```txt
http://localhost:5173/?debug
```
The debug physics scene keeps the existing grab, trigger, and animated model tests, and also exposes separate `Bike`, `Pylone`, and `Farm` repair playground zones. Use the debug game-state panel to switch `mainState`; selecting a locked repair mission in that panel opens it at `waiting`, and the matching repair zone mounts the same reusable `RepairGame` flow with that mission's model, broken parts, replacement parts, prompts, and timings.
## Related Hand Tracking
Hand tracking can move grabbable physics objects with webcam input in debug scenes. In the production repair flow, it is also used for the `inspected -> fragmented` transition through the two-fists hold gesture.
Hand tracking is a separate debug interaction layer. It can move grabbable physics objects with webcam input, but it is not yet integrated into the repair-game mission flow.
For hand tracking, run the Python backend separately:
@@ -103,8 +73,8 @@ python -m backend.main
## Current Limitations
- 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.
- There is no central `GameManager` in this branch.
- 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.
- It is mounted only in the debug physics scene.
- There is no mission progression system yet.
- There is no central `GameManager` or Zustand store in this branch.
- Hand tracking is available as debug interaction input, not as final repair gameplay.
- The repair-game content is configured statically in `src/data/gameplay/`.
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.
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.
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.
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.
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.
Binary file not shown.
Binary file not shown.
@@ -1,56 +0,0 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
interface RepairBrokenPartHighlightProps {
target: THREE.Object3D;
}
const _box = new THREE.Box3();
const _sphere = new THREE.Sphere();
const _worldPosition = new THREE.Vector3();
const _localPosition = new THREE.Vector3();
export function RepairBrokenPartHighlight({
target,
}: RepairBrokenPartHighlightProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
useFrame(({ clock }) => {
const group = groupRef.current;
if (!group) return;
_box.setFromObject(target).getBoundingSphere(_sphere);
_worldPosition.copy(_sphere.center);
_localPosition.copy(_worldPosition);
group.parent?.worldToLocal(_localPosition);
group.position.copy(_localPosition);
const pulse = 1 + Math.sin(clock.elapsedTime * 5) * 0.08;
const radius = Math.max(_sphere.radius, 0.35) * pulse;
group.scale.setScalar(radius);
});
return (
<group ref={groupRef}>
<mesh>
<sphereGeometry args={[1, 32, 16]} />
<meshBasicMaterial color="#ef4444" transparent opacity={0.14} />
</mesh>
<mesh>
<sphereGeometry args={[1.06, 32, 16]} />
<meshBasicMaterial
color="#ef4444"
wireframe
transparent
opacity={0.65}
/>
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1.12, 0.025, 8, 96]} />
<meshBasicMaterial color="#dc2626" transparent opacity={0.9} />
</mesh>
</group>
);
}
@@ -1,36 +0,0 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
interface RepairBrokenPartPromptProps {
src: string;
target: THREE.Object3D;
}
const _box = new THREE.Box3();
const _sphere = new THREE.Sphere();
const _localPosition = new THREE.Vector3();
export function RepairBrokenPartPrompt({
src,
target,
}: RepairBrokenPartPromptProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
useFrame(() => {
const group = groupRef.current;
if (!group) return;
_box.setFromObject(target).getBoundingSphere(_sphere);
_localPosition.copy(_sphere.center);
group.parent?.worldToLocal(_localPosition);
group.position.copy(_localPosition);
});
return (
<group ref={groupRef}>
<RepairPromptVideo src={src} position={[0, 0, 0]} size={72} />
</group>
);
}
@@ -8,39 +8,20 @@ import {
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE,
REPAIR_CASE_FLOAT_DOWN_SPEED,
REPAIR_CASE_FLOAT_HEIGHT,
REPAIR_CASE_EXIT_DURATION,
REPAIR_CASE_EXIT_Y_OFFSET,
REPAIR_CASE_FLOAT_UP_SPEED,
REPAIR_CASE_LID_NODE_NAME,
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
REPAIR_CASE_CLOSE_SOUND_PATH,
REPAIR_CASE_OPEN_SOUND_PATH,
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
REPAIR_CASE_POP_DURATION,
REPAIR_CASE_POP_Y_OFFSET,
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
REPAIR_CASE_ROTATION_RESET_SPEED,
} from "@/data/gameplay/repairCaseConfig";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { AudioManager } from "@/managers/AudioManager";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import type { ModelTransformProps } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
export interface RepairCasePlaceholder {
name: string;
position: Vector3Tuple;
}
interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string;
open: boolean;
exiting?: boolean;
floating?: boolean;
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onExitComplete?: (() => void) | undefined;
}
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
@@ -56,10 +37,6 @@ const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
export function RepairCaseModel({
modelPath,
open,
exiting = false,
floating = true,
onPlaceholdersChange,
onExitComplete,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
@@ -78,80 +55,22 @@ export function RepairCaseModel({
const floatHeight = useRef(0);
const animationActiveRef = useRef(false);
const phase = useRef({ x: 0, y: 0, z: 0 });
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
const onExitCompleteRef = useRef(onExitComplete);
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
const initialOpen = useRef(open);
const previousOpen = useRef(open);
const openedRotationZ = useRef(0);
const parsedScale = toVector3Scale(scale);
const placeholderNodes = useRef<THREE.Object3D[]>([]);
const placeholderSignature = useRef("__initial__");
const placeholderPosition = useRef(new THREE.Vector3());
const placeholderLocalPosition = useRef(new THREE.Vector3());
useEffect(() => {
onExitCompleteRef.current = onExitComplete;
}, [onExitComplete]);
useEffect(() => {
onPlaceholdersChangeRef.current = onPlaceholdersChange;
}, [onPlaceholdersChange]);
useEffect(() => {
const popAnimation = pop.current;
phase.current = {
x: Math.random() * Math.PI * 2,
y: Math.random() * Math.PI * 2,
z: Math.random() * Math.PI * 2,
};
gsap.to(popAnimation, {
scale: 1,
yOffset: 0,
duration: REPAIR_CASE_POP_DURATION,
ease: "back.out(1.7)",
});
return () => {
gsap.killTweensOf(popAnimation);
};
}, []);
useEffect(() => {
if (!exiting) return undefined;
const popAnimation = pop.current;
gsap.to(popAnimation, {
scale: 0.001,
yOffset: REPAIR_CASE_EXIT_Y_OFFSET,
duration: REPAIR_CASE_EXIT_DURATION,
ease: "back.in(1.4)",
overwrite: true,
onComplete: () => {
onExitCompleteRef.current?.();
},
});
return () => {
gsap.killTweensOf(popAnimation);
};
}, [exiting]);
useEffect(() => {
const lid = model.getObjectByName(REPAIR_CASE_LID_NODE_NAME);
lidRef.current = lid ?? null;
openedRotationZ.current = lid?.rotation.z ?? 0;
placeholderNodes.current = [];
model.traverse((child) => {
if (
child.name.toLowerCase().startsWith(REPAIR_CASE_PLACEHOLDER_NAME_PREFIX)
) {
placeholderNodes.current.push(child);
}
});
if (lid) {
lid.rotation.z =
@@ -181,26 +100,14 @@ export function RepairCaseModel({
};
}, [open]);
useEffect(() => {
if (previousOpen.current === open) return;
previousOpen.current = open;
AudioManager.getInstance().playSound(
open ? REPAIR_CASE_OPEN_SOUND_PATH : REPAIR_CASE_CLOSE_SOUND_PATH,
0.85,
);
}, [open]);
useFrame(({ clock }, delta) => {
const group = groupRef.current;
if (!group) return;
group.getWorldPosition(worldPosition.current);
const isNear =
floating &&
!exiting &&
worldPosition.current.distanceTo(camera.position) <=
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
const floatSpeed = isNear
? REPAIR_CASE_FLOAT_UP_SPEED
@@ -212,43 +119,7 @@ export function RepairCaseModel({
floatSpeed,
delta,
);
group.position.y = position[1] + floatHeight.current + pop.current.yOffset;
group.scale.set(
parsedScale[0] * pop.current.scale,
parsedScale[1] * pop.current.scale,
parsedScale[2] * pop.current.scale,
);
if (placeholderNodes.current.length > 0) {
const placeholders: RepairCasePlaceholder[] = [];
placeholderNodes.current.forEach((child) => {
child.getWorldPosition(placeholderPosition.current);
placeholderLocalPosition.current.copy(placeholderPosition.current);
group.parent?.worldToLocal(placeholderLocalPosition.current);
placeholders.push({
name: child.name,
position: [
placeholderLocalPosition.current.x,
placeholderLocalPosition.current.y,
placeholderLocalPosition.current.z,
],
});
});
placeholders.sort((a, b) => a.name.localeCompare(b.name));
const nextSignature = placeholders
.map(
(placeholder) =>
`${placeholder.name}:${placeholder.position
.map((value) => value.toFixed(3))
.join(",")}`,
)
.join("|");
if (nextSignature !== placeholderSignature.current) {
placeholderSignature.current = nextSignature;
onPlaceholdersChangeRef.current?.(placeholders);
}
}
group.position.y = position[1] + floatHeight.current;
animationActiveRef.current = isNear;
@@ -287,7 +158,12 @@ export function RepairCaseModel({
});
return (
<group ref={groupRef} position={position} rotation={rotation} scale={0.001}>
<group
ref={groupRef}
position={position}
rotation={rotation}
scale={parsedScale}
>
<primitive object={model} />
</group>
);
@@ -0,0 +1,109 @@
import type { ReactNode } from "react";
import { Component } from "react";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel";
import {
REPAIR_CASE_MODEL_PATH,
REPAIR_CASE_OPEN_SOUND_PATH,
} from "@/data/gameplay/repairCaseConfig";
import { AudioManager } from "@/managers/AudioManager";
import type { Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
const REPAIR_CASE_PAN_RANGE = 20;
interface RepairCaseErrorBoundaryProps {
children: ReactNode;
}
interface RepairCaseErrorBoundaryState {
hasError: boolean;
}
class RepairCaseErrorBoundary extends Component<
RepairCaseErrorBoundaryProps,
RepairCaseErrorBoundaryState
> {
constructor(props: RepairCaseErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): RepairCaseErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: REPAIR_CASE_MODEL_PATH,
scope: "RepairCaseObject",
position: [0, -0.45, 0],
scale: 1.5,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) {
return <RepairCaseFallback />;
}
return this.props.children;
}
}
interface RepairCaseObjectProps {
position: Vector3Tuple;
open: boolean;
onInspect: () => void;
}
export function RepairCaseObject({
position,
open,
onInspect,
}: RepairCaseObjectProps): React.JSX.Element {
const pan = Math.max(-1, Math.min(1, position[0] / REPAIR_CASE_PAN_RANGE));
return (
<TriggerObject
position={position}
colliders="cuboid"
label={open ? "Mallette inspectée" : "Inspecter la mallette"}
onTrigger={() => {
if (open) return;
AudioManager.getInstance().playSound(REPAIR_CASE_OPEN_SOUND_PATH, 1, {
category: "sfx",
pan,
});
onInspect();
}}
>
<RepairCaseErrorBoundary>
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
open={open}
position={[0, -0.45, 0]}
scale={1.5}
/>
</RepairCaseErrorBoundary>
</TriggerObject>
);
}
function RepairCaseFallback(): React.JSX.Element {
return (
<group position={[0, -0.25, 0]}>
<mesh castShadow receiveShadow>
<boxGeometry args={[1.5, 0.5, 1]} />
<meshStandardMaterial color="#2563eb" roughness={0.55} />
</mesh>
<mesh position={[0, 0.35, -0.25]} castShadow receiveShadow>
<boxGeometry args={[1.5, 0.12, 0.65]} />
<meshStandardMaterial color="#1d4ed8" roughness={0.55} />
</mesh>
</group>
);
}
@@ -1,52 +0,0 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
const PARTICLES = Array.from({ length: 24 }, (_, index) => {
const angle = (index / 24) * Math.PI * 2;
const ring = index % 3;
return {
angle,
radius: 0.45 + ring * 0.28,
y: 0.35 + (index % 5) * 0.16,
speed: 0.8 + (index % 4) * 0.18,
};
});
export function RepairCompletionParticles(): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
useFrame(({ clock }) => {
const group = groupRef.current;
if (!group) return;
group.rotation.y = clock.elapsedTime * 0.9;
group.children.forEach((child, index) => {
const particle = PARTICLES[index];
if (!particle) return;
const pulse = 1 + Math.sin(clock.elapsedTime * 5 + index) * 0.35;
child.position.y =
particle.y + Math.sin(clock.elapsedTime * particle.speed) * 0.08;
child.scale.setScalar(pulse);
});
});
return (
<group ref={groupRef}>
{PARTICLES.map((particle, index) => (
<mesh
key={index}
position={[
Math.cos(particle.angle) * particle.radius,
particle.y,
Math.sin(particle.angle) * particle.radius,
]}
>
<sphereGeometry args={[0.045, 12, 12]} />
<meshBasicMaterial color="#86efac" transparent opacity={0.85} />
</mesh>
))}
</group>
);
}
@@ -1,73 +0,0 @@
import { useEffect, useState } from "react";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairCompletionStepProps {
config: RepairMissionConfig;
onComplete: () => void;
}
export function RepairCompletionStep({
config,
onComplete,
}: RepairCompletionStepProps): React.JSX.Element {
const [isClosingCase, setIsClosingCase] = useState(false);
const [isExitingCase, setIsExitingCase] = useState(false);
useEffect(() => {
if (!isClosingCase) return undefined;
const timeoutId = window.setTimeout(() => {
setIsExitingCase(true);
}, REPAIR_CASE_ANIMATION_DURATION * 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosingCase]);
return (
<group>
<RepairMissionCase
config={config}
exiting={isExitingCase}
open={!isClosingCase}
onExitComplete={onComplete}
/>
<RepairObjectModel
label={config.label}
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
/>
{!isClosingCase ? (
<TriggerObject
position={[0, 1.1, 0]}
colliders="ball"
label={`Valider ${config.label}`}
radius={REPAIR_INTERACTION_RADIUS}
onTrigger={() => setIsClosingCase(true)}
>
<mesh>
<torusGeometry args={[1.35, 0.045, 12, 96]} />
<meshBasicMaterial color="#22c55e" transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.2, 1.25, 96]} />
<meshBasicMaterial color="#bbf7d0" transparent opacity={0.3} />
</mesh>
</TriggerObject>
) : null}
{!isClosingCase ? (
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
) : null}
</group>
);
}
@@ -1,192 +0,0 @@
import { Suspense, useEffect, useMemo, useState } from "react";
import { useGLTF } from "@react-three/drei";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
import {
RepairScanSequence,
type RepairScannedBrokenPart,
} from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
import {
REPAIR_MISSIONS,
type RepairMissionConfig,
} from "@/data/gameplay/repairMissions";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import type {
MissionStep,
RepairMissionId,
} from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
interface RepairGameProps extends Required<
Pick<ModelTransformProps, "position">
> {
mission: RepairMissionId;
rotation?: Vector3Tuple;
scale?: ModelTransformProps["scale"];
}
interface RepairMissionAssetPreloaderProps {
config: RepairMissionConfig;
}
function RepairMissionAssetPreloader({
config,
}: RepairMissionAssetPreloaderProps): null {
const modelPaths = useMemo(
() => getRepairMissionModelPaths(config),
[config],
);
useGLTF(modelPaths);
return null;
}
export function RepairGame({
mission,
position,
rotation = [0, 0, 0],
scale = 1,
}: RepairGameProps): React.JSX.Element | null {
const config = REPAIR_MISSIONS[mission];
const mainState = useGameStore((state) => state.mainState);
const completeMission = useGameStore((state) => state.completeMission);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const step = useRepairMissionStep(mission);
const [casePlaceholders, setCasePlaceholders] = useState<
readonly RepairCasePlaceholder[]
>([]);
const [scannedBrokenParts, setScannedBrokenParts] = useState<
readonly RepairScannedBrokenPart[]
>([]);
const parsedScale = toVector3Scale(scale);
const readyForFragmentation = step === "inspected";
useRepairFragmentationInput({
enabled: mainState === mission && readyForFragmentation,
keyboardEnabled: false,
onFragment: () => setMissionStep(mission, "fragmented"),
});
useEffect(() => {
if (mainState === mission && shouldKeepRepairRuntimeState(step)) return;
const timeoutId = window.setTimeout(() => {
setCasePlaceholders([]);
setScannedBrokenParts([]);
}, 0);
return () => {
window.clearTimeout(timeoutId);
};
}, [mainState, mission, step]);
useEffect(() => {
if (mainState !== mission) return undefined;
if (step !== "fragmented") return undefined;
const timeoutId = window.setTimeout(() => {
setMissionStep(mission, "scanning");
}, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [mainState, mission, setMissionStep, step]);
if (mainState !== mission) return null;
if (step === "locked") return null;
return (
<group position={position} rotation={rotation} scale={parsedScale}>
<Suspense fallback={null}>
<RepairMissionAssetPreloader config={config} />
</Suspense>
<Suspense fallback={null}>
{step === "waiting" ? (
<RepairInspectionObject
config={config}
worldPosition={position}
onInspect={() => setMissionStep(mission, "inspected")}
/>
) : null}
{step === "fragmented" ? (
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split
/>
) : null}
{step === "scanning" ? (
<RepairScanSequence
config={config}
onComplete={(brokenParts) => {
setScannedBrokenParts(brokenParts);
setMissionStep(mission, "repairing");
}}
/>
) : null}
{step === "repairing" ? (
<RepairRepairingStep
brokenParts={scannedBrokenParts}
config={config}
placeholders={casePlaceholders}
onRepair={() => setMissionStep(mission, "reassembling")}
/>
) : null}
{step === "reassembling" ? (
<RepairReassemblyStep
config={config}
onComplete={() => setMissionStep(mission, "done")}
/>
) : null}
{step === "done" ? (
<RepairCompletionStep
config={config}
onComplete={() => completeMission(mission)}
/>
) : null}
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
<RepairMissionCase
config={config}
onPlaceholdersChange={setCasePlaceholders}
open={step === "repairing"}
zoomed={step === "repairing"}
showFragmentationPrompt={readyForFragmentation}
onInteract={
readyForFragmentation
? () => setMissionStep(mission, "fragmented")
: undefined
}
/>
) : null}
</Suspense>
</group>
);
}
function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
return step === "repairing" || step === "reassembling" || step === "done";
}
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
return [
...new Set([
REPAIR_CASE_MODEL_PATH,
config.modelPath,
...config.brokenParts.flatMap((part) => part.modelPath ?? []),
...config.replacementParts.flatMap((part) => part.modelPath ?? []),
]),
];
}
@@ -0,0 +1,124 @@
import { Text } from "@react-three/drei";
import { RepairCaseObject } from "@/components/three/gameplay/RepairCaseObject";
import { RepairModuleSlot } from "@/components/three/gameplay/RepairModuleSlot";
import {
REPAIR_GAME_MODULE_SLOTS,
REPAIR_GAME_ZONE_LABEL,
REPAIR_GAME_ZONE_ORIGIN,
REPAIR_GAME_ZONE_RADIUS,
} from "@/data/gameplay/repairGameConfig";
import { useGameStore } from "@/managers/stores/useGameStore";
import { playGameplayDialogueById } from "@/utils/dialogues/playDialogue";
const CASE_CLOSED_STEPS = new Set(["locked", "waiting"]);
export function RepairGameZone(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const bikeStep = useGameStore((state) => state.bike.currentStep);
const setMainState = useGameStore((state) => state.setMainState);
const setBikeState = useGameStore((state) => state.setBikeState);
const caseOpen = !CASE_CLOSED_STEPS.has(bikeStep);
const slotsDisabled = !caseOpen;
const inspectRepairCase = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (CASE_CLOSED_STEPS.has(bikeStep)) {
setBikeState({ currentStep: "inspected" });
void playGameplayDialogueById("narrateur_ebikecasse");
}
};
const markModelSelected = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (bikeStep === "inspected") {
setBikeState({ currentStep: "fragmented" });
}
};
const markModuleSplit = (): void => {
if (mainState !== "bike") {
setMainState("bike");
}
if (bikeStep === "fragmented") {
setBikeState({ currentStep: "scanning" });
void playGameplayDialogueById("narrateur_galetscan");
}
};
return (
<group>
<mesh
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
0.025,
REPAIR_GAME_ZONE_ORIGIN[2],
]}
rotation={[-Math.PI / 2, 0, 0]}
>
<ringGeometry
args={[REPAIR_GAME_ZONE_RADIUS - 0.08, REPAIR_GAME_ZONE_RADIUS, 96]}
/>
<meshBasicMaterial color="#38bdf8" transparent opacity={0.72} />
</mesh>
<mesh
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
0.02,
REPAIR_GAME_ZONE_ORIGIN[2],
]}
rotation={[-Math.PI / 2, 0, 0]}
>
<circleGeometry args={[REPAIR_GAME_ZONE_RADIUS, 96]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
</mesh>
<Text
position={[
REPAIR_GAME_ZONE_ORIGIN[0],
3.1,
REPAIR_GAME_ZONE_ORIGIN[2] - 1.8,
]}
rotation={[0, 0, 0]}
fontSize={0.55}
maxWidth={5.5}
textAlign="center"
anchorX="center"
anchorY="middle"
color="#f8fafc"
outlineWidth={0.025}
outlineColor="#0f172a"
>
{REPAIR_GAME_ZONE_LABEL}
</Text>
<RepairCaseObject
position={REPAIR_GAME_ZONE_ORIGIN}
open={caseOpen}
onInspect={inspectRepairCase}
/>
{REPAIR_GAME_MODULE_SLOTS.map((slot) => (
<RepairModuleSlot
key={slot.label}
label={slot.label}
position={[
REPAIR_GAME_ZONE_ORIGIN[0] + slot.offset[0],
REPAIR_GAME_ZONE_ORIGIN[1] + slot.offset[1],
REPAIR_GAME_ZONE_ORIGIN[2] + slot.offset[2],
]}
disabled={slotsDisabled}
onModelSelected={markModelSelected}
onSplit={markModuleSplit}
/>
))}
</group>
);
}
@@ -1,35 +0,0 @@
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairInspectionObjectProps {
config: RepairMissionConfig;
worldPosition: Vector3Tuple;
onInspect: () => void;
}
export function RepairInspectionObject({
config,
worldPosition,
onInspect,
}: RepairInspectionObjectProps): React.JSX.Element {
return (
<InteractableObject
kind="trigger"
label={`Inspecter ${config.label}`}
position={worldPosition}
radius={REPAIR_INTERACTION_RADIUS}
onPress={onInspect}
>
<RepairObjectModel
label={config.label}
modelPath={config.modelPath}
scale={config.modelScale ?? 0.9}
/>
<RepairPromptVideo src={config.stageUiPath} />
</InteractableObject>
);
}
@@ -1,89 +0,0 @@
import {
RepairCaseModel,
type RepairCasePlaceholder,
} from "@/components/three/gameplay/RepairCaseModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
REPAIR_CASE_FOCUS_POSITION,
REPAIR_CASE_FOCUS_SCALE,
REPAIR_CASE_MODEL_PATH,
} from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairMissionCaseProps {
config: RepairMissionConfig;
exiting?: boolean;
onPlaceholdersChange?:
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
| undefined;
onExitComplete?: (() => void) | undefined;
open?: boolean;
zoomed?: boolean;
showFragmentationPrompt?: boolean;
onInteract?: (() => void) | undefined;
}
export function RepairMissionCase({
config,
exiting = false,
onPlaceholdersChange,
onExitComplete,
open = false,
zoomed = false,
showFragmentationPrompt = false,
onInteract,
}: RepairMissionCaseProps): React.JSX.Element {
const casePosition = zoomed
? REPAIR_CASE_FOCUS_POSITION
: config.case.position;
const caseScale = zoomed ? REPAIR_CASE_FOCUS_SCALE : config.case.scale;
const modelPosition: Vector3Tuple = onInteract ? [0, 0, 0] : casePosition;
return (
<group>
{onInteract ? (
<TriggerObject
position={casePosition}
colliders="ball"
label={`Ouvrir ${config.label}`}
radius={REPAIR_INTERACTION_RADIUS}
onTrigger={onInteract}
>
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open}
floating={!zoomed}
position={modelPosition}
rotation={config.case.rotation}
scale={caseScale}
/>
</TriggerObject>
) : (
<RepairCaseModel
modelPath={REPAIR_CASE_MODEL_PATH}
exiting={exiting}
onExitComplete={onExitComplete}
onPlaceholdersChange={onPlaceholdersChange}
open={open}
floating={!zoomed}
position={modelPosition}
rotation={config.case.rotation}
scale={caseScale}
/>
)}
{showFragmentationPrompt && !exiting ? (
<RepairPromptVideo
src={config.interactUiPath}
position={[casePosition[0], 2.4, casePosition[2]]}
size={80}
/>
) : null}
</group>
);
}
@@ -0,0 +1,113 @@
import { Html } from "@react-three/drei";
import { useCallback, useState } from "react";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_GAME_MODEL_CATALOG } from "@/data/gameplay/repairGameModelCatalog";
import type { ModelCatalogItem } from "@/data/gameplay/repairGameModelCatalog";
import { useModelSelection } from "@/hooks/gameplay/useModelSelection";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairModuleSlotProps {
position: Vector3Tuple;
label: string;
disabled?: boolean;
onModelSelected?: () => void;
onSplit?: () => void;
}
export function RepairModuleSlot({
position,
label,
disabled = false,
onModelSelected,
onSplit,
}: RepairModuleSlotProps): React.JSX.Element {
const [selectedModel, setSelectedModel] = useState<ModelCatalogItem | null>(
null,
);
const [split, setSplit] = useState(false);
const handleSelect = useCallback(
(model: ModelCatalogItem) => {
setSelectedModel(model);
setSplit(false);
onModelSelected?.();
},
[onModelSelected],
);
const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect);
const triggerLabel = disabled
? "Ouvrir la mallette d'abord"
: selectedModel
? split
? `Réassembler ${label}`
: `Démonter ${label}`
: `Choisir ${label}`;
return (
<group>
<TriggerObject
position={position}
colliders="cuboid"
label={triggerLabel}
onTrigger={() => {
if (disabled) return;
if (selectedModel) {
setSplit((value) => {
const nextSplit = !value;
if (nextSplit) {
onSplit?.();
}
return nextSplit;
});
return;
}
selection.open();
}}
>
{selectedModel ? (
<ExplodableModel
modelPath={selectedModel.path}
split={split}
position={[0, -0.35, 0]}
scale={0.45}
/>
) : (
<mesh castShadow receiveShadow>
<boxGeometry args={[1, 0.18, 1]} />
<meshStandardMaterial
color="#38bdf8"
emissive="#082f49"
roughness={0.55}
/>
</mesh>
)}
</TriggerObject>
{selection.isOpen ? (
<Html position={[position[0], position[1] + 1.2, position[2]]} center>
<div className="model-selector-panel">
<strong>{label}</strong>
<span>Fleches: choisir</span>
<span>E/Enter: valider</span>
<ul>
{REPAIR_GAME_MODEL_CATALOG.map((model, index) => (
<li
key={model.path}
className={
index === selection.selectedIndex
? "is-selected"
: undefined
}
>
{model.name}
</li>
))}
</ul>
</div>
</Html>
) : null}
</group>
);
}
@@ -1,120 +0,0 @@
import type { ReactNode } from "react";
import { Component } from "react";
import { SimpleModel } from "@/components/three/models/SimpleModel";
import type { ModelTransformProps } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import { toVector3Scale } from "@/utils/three/scale";
interface RepairObjectModelProps extends ModelTransformProps {
label: string;
modelPath: string;
}
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
children: ReactNode;
}
interface RepairObjectModelBoundaryState {
hasError: boolean;
}
interface RepairObjectFallbackProps {
label: string;
position?: ModelTransformProps["position"] | undefined;
rotation?: ModelTransformProps["rotation"] | undefined;
scale?: ModelTransformProps["scale"] | undefined;
}
class RepairObjectModelBoundary extends Component<
RepairObjectModelBoundaryProps,
RepairObjectModelBoundaryState
> {
constructor(props: RepairObjectModelBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): RepairObjectModelBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error): void {
logModelLoadError(
{
modelPath: this.props.modelPath,
position: this.props.position,
rotation: this.props.rotation,
scale: this.props.scale,
scope: `RepairObjectModel.${this.props.label}`,
},
error,
);
}
render(): ReactNode {
if (this.state.hasError) {
return (
<RepairObjectFallback
label={this.props.label}
position={this.props.position}
rotation={this.props.rotation}
scale={this.props.scale}
/>
);
}
return this.props.children;
}
}
export function RepairObjectModel({
label,
modelPath,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
}: RepairObjectModelProps): React.JSX.Element {
return (
<RepairObjectModelBoundary
label={label}
modelPath={modelPath}
position={position}
rotation={rotation}
scale={scale}
>
<SimpleModel
modelPath={modelPath}
position={position}
rotation={rotation}
scale={scale}
/>
</RepairObjectModelBoundary>
);
}
function RepairObjectFallback({
label,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
}: Pick<
RepairObjectFallbackProps,
"label" | "position" | "rotation" | "scale"
>): React.JSX.Element {
return (
<group
position={position}
rotation={rotation}
scale={toVector3Scale(scale)}
>
<mesh castShadow receiveShadow>
<boxGeometry args={[1.4, 1.4, 1.4]} />
<meshStandardMaterial color="#facc15" roughness={0.6} wireframe />
</mesh>
<mesh position={[0, 1.05, 0]}>
<sphereGeometry args={[0.08, 16, 16]} />
<meshBasicMaterial color={label ? "#f8fafc" : "#facc15"} />
</mesh>
</group>
);
}
@@ -1,25 +0,0 @@
import { WorldVideoPrompt } from "@/components/three/ui/WorldVideoPrompt";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairPromptVideoProps {
src: string;
position?: Vector3Tuple;
size?: number;
billboard?: boolean;
}
export function RepairPromptVideo({
src,
position = [0, 1.8, 0],
size = 96,
billboard = true,
}: RepairPromptVideoProps): React.JSX.Element {
return (
<WorldVideoPrompt
billboard={billboard}
position={position}
size={size}
src={src}
/>
);
}
@@ -1,45 +0,0 @@
import { useEffect, useState } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
interface RepairReassemblyStepProps {
config: RepairMissionConfig;
onComplete: () => void;
}
export function RepairReassemblyStep({
config,
onComplete,
}: RepairReassemblyStepProps): React.JSX.Element {
const [split, setSplit] = useState(true);
const reassemblySeconds =
config.reassemblySeconds ?? REPAIR_REASSEMBLY_SECONDS;
useEffect(() => {
const closeTimeoutId = window.setTimeout(() => {
setSplit(false);
}, 50);
const completeTimeoutId = window.setTimeout(() => {
onComplete();
}, reassemblySeconds * 1000);
return () => {
window.clearTimeout(closeTimeoutId);
window.clearTimeout(completeTimeoutId);
};
}, [onComplete, reassemblySeconds]);
return (
<group>
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split={split}
splitDistance={1.2}
/>
<RepairCompletionParticles />
</group>
);
}
@@ -1,480 +0,0 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
REPAIR_CASE_FOCUS_POSITION,
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
} from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
const _placeholderPosition = new THREE.Vector3();
const FALLBACK_PLACEHOLDER_OFFSETS: Vector3Tuple[] = [
[-1.15, 1, 0.25],
[0, 1.05, 0.45],
[1.15, 1, 0.25],
];
const BROKEN_PART_START_OFFSETS: Vector3Tuple[] = [
[-1.35, 0.55, -0.85],
[0, 0.6, -1],
[1.35, 0.55, -0.85],
];
const REPAIR_INSTALL_RADIUS = 1.1;
const VALID_PART_COLOR = "#22c55e";
const INVALID_PART_COLOR = "#ef4444";
const STORED_BROKEN_PART_COLOR = "#38bdf8";
interface RepairRepairingStepProps {
brokenParts: readonly RepairScannedBrokenPart[];
config: RepairMissionConfig;
placeholders: readonly RepairCasePlaceholder[];
onRepair: () => void;
}
interface RepairInstallTargetProps {
blockedFeedback: boolean;
fillColor: string;
isReadyToInstall: boolean;
label: string;
ringColor: string;
onBlocked: () => void;
onRepair: () => void;
}
interface RepairPlaceholderMarkersProps {
positions: readonly Vector3Tuple[];
}
interface RepairPartPlacementFeedbackProps {
state: "valid" | "invalid" | "stored" | null;
}
export function RepairRepairingStep({
brokenParts,
config,
placeholders,
onRepair,
}: RepairRepairingStepProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const localPosition = useRef(new THREE.Vector3());
const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>(
{},
);
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
Record<string, boolean>
>({});
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
useState(false);
const replacementParts = getReplacementParts(config);
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
const requiredReplacementPart = replacementParts.find(
(part) => part.id === config.requiredReplacementPartId,
);
const requiredReplacementLabel =
requiredReplacementPart?.label ?? config.label;
const placeholderTargets = getPlaceholderTargets(placeholders);
const placeholderPositions = placeholderTargets.map(
(target) => target.position,
);
const hasCorrectPartPlaced = Boolean(
placedPartIds[config.requiredReplacementPartId],
);
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
(part) => depositedBrokenPartIds[part.id],
);
const hasWrongPartPlaced = replacementParts.some(
(part) =>
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
);
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
const installColor = isReadyToInstall
? "#22c55e"
: hasWrongPartPlaced
? "#ef4444"
: "#f97316";
const installFillColor = isReadyToInstall
? "#86efac"
: hasWrongPartPlaced
? "#fecaca"
: "#fed7aa";
const installLabel = isReadyToInstall
? `Installer ${requiredReplacementLabel}`
: hasWrongPartPlaced
? `Mauvaise pièce`
: hasCorrectPartPlaced
? `Ranger pièce cassée`
: `Approcher ${requiredReplacementLabel}`;
useEffect(() => {
if (!showBlockedInstallFeedback) return undefined;
const timeoutId = window.setTimeout(() => {
setShowBlockedInstallFeedback(false);
}, 900);
return () => {
window.clearTimeout(timeoutId);
};
}, [showBlockedInstallFeedback]);
function handleReplacementPosition(
partId: string,
position: THREE.Vector3,
): void {
const isPlaced = isNearPlaceholder(
getStepLocalPosition(position, groupRef.current, localPosition.current),
placeholderPositions,
);
setPlacedPartIds((current) => {
if (!current[partId] || isPlaced) return current;
return { ...current, [partId]: false };
});
}
function handleReplacementSnap(partId: string): void {
setPlacedPartIds((current) => {
if (current[partId]) return current;
return { ...current, [partId]: true };
});
}
function handleBrokenPartPosition(
partId: string,
position: THREE.Vector3,
targets: readonly Vector3Tuple[],
): void {
const isDeposited = isNearPlaceholder(
getStepLocalPosition(position, groupRef.current, localPosition.current),
targets,
);
setDepositedBrokenPartIds((current) => {
if (!current[partId] || isDeposited) return current;
return { ...current, [partId]: false };
});
}
function handleBrokenPartSnap(partId: string): void {
setDepositedBrokenPartIds((current) => {
if (current[partId]) return current;
return { ...current, [partId]: true };
});
}
return (
<group ref={groupRef}>
<RepairInstallTarget
blockedFeedback={showBlockedInstallFeedback}
fillColor={installFillColor}
isReadyToInstall={isReadyToInstall}
label={installLabel}
ringColor={installColor}
onBlocked={() => setShowBlockedInstallFeedback(true)}
onRepair={onRepair}
/>
<RepairPlaceholderMarkers positions={placeholderPositions} />
{replacementParts.map((part, index) => {
const placeholderPosition =
placeholderPositions[index % placeholderPositions.length] ??
placeholderPositions[0]!;
const isPlaced = Boolean(placedPartIds[part.id]);
const feedbackState = getReplacementFeedbackState(
part.id,
config.requiredReplacementPartId,
isPlaced,
);
return (
<GrabbableObject
key={part.id}
position={placeholderPosition}
colliders="ball"
handControlled
label={`Prendre ${part.label}`}
onPositionChange={(position) => {
handleReplacementPosition(part.id, position);
}}
onSnap={() => {
handleReplacementSnap(part.id);
}}
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
snapTargets={placeholderPositions}
>
<group>
<RepairObjectModel
label={part.label}
modelPath={part.modelPath ?? config.modelPath}
scale={0.36}
/>
<RepairPartPlacementFeedback state={feedbackState} />
</group>
</GrabbableObject>
);
})}
{brokenPartsToDeposit.map((part, index) => {
const startOffset =
BROKEN_PART_START_OFFSETS[index % BROKEN_PART_START_OFFSETS.length] ??
BROKEN_PART_START_OFFSETS[0]!;
const startPosition: Vector3Tuple = [
REPAIR_CASE_FOCUS_POSITION[0] + startOffset[0],
REPAIR_CASE_FOCUS_POSITION[1] + startOffset[1],
REPAIR_CASE_FOCUS_POSITION[2] + startOffset[2],
];
const targetPositions = getBrokenPartTargetPositions(
part,
placeholderTargets,
);
const isDeposited = Boolean(depositedBrokenPartIds[part.id]);
return (
<GrabbableObject
key={part.id}
position={startPosition}
colliders="ball"
handControlled
label={`Ranger ${part.label}`}
onPositionChange={(position) => {
handleBrokenPartPosition(part.id, position, targetPositions);
}}
onSnap={() => {
handleBrokenPartSnap(part.id);
}}
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
snapTargets={targetPositions}
>
<group>
<RepairObjectModel
label={part.label}
modelPath={part.modelPath}
scale={0.24}
/>
<mesh position={[0, 0.42, 0]}>
<sphereGeometry args={[0.11, 16, 16]} />
<meshBasicMaterial color="#ef4444" transparent opacity={0.85} />
</mesh>
<RepairPartPlacementFeedback
state={isDeposited ? "stored" : null}
/>
</group>
</GrabbableObject>
);
})}
{isReadyToInstall ? (
<RepairPromptVideo src={config.interactUiPath} position={[0, 2.3, 0]} />
) : null}
</group>
);
}
function RepairInstallTarget({
blockedFeedback,
fillColor,
isReadyToInstall,
label,
ringColor,
onBlocked,
onRepair,
}: RepairInstallTargetProps): React.JSX.Element {
return (
<TriggerObject
position={INSTALL_TARGET_POSITION}
colliders="ball"
label={label}
radius={REPAIR_INTERACTION_RADIUS}
onTrigger={() => {
if (!isReadyToInstall) {
onBlocked();
return;
}
onRepair();
}}
>
<mesh>
<torusGeometry args={[0.95, 0.045, 12, 96]} />
<meshBasicMaterial color={ringColor} transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.15, 0.9, 96]} />
<meshBasicMaterial color={fillColor} transparent opacity={0.35} />
</mesh>
{blockedFeedback ? (
<group position={[0, 0.28, 0]}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1.08, 0.035, 12, 96]} />
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
</mesh>
<mesh>
<sphereGeometry args={[0.12, 16, 16]} />
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
</mesh>
</group>
) : null}
</TriggerObject>
);
}
function RepairPlaceholderMarkers({
positions,
}: RepairPlaceholderMarkersProps): React.JSX.Element {
return (
<>
{positions.map((position, index) => (
<mesh
key={`${position.join(":")}-${index}`}
position={position}
rotation={[Math.PI / 2, 0, 0]}
>
<torusGeometry args={[0.26, 0.018, 8, 48]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.55} />
</mesh>
))}
</>
);
}
function RepairPartPlacementFeedback({
state,
}: RepairPartPlacementFeedbackProps): React.JSX.Element | null {
if (!state) return null;
const color = getPlacementFeedbackColor(state);
return (
<group position={[0, 0.72, 0]}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.48, 0.035, 12, 64]} />
<meshBasicMaterial color={color} transparent opacity={0.85} />
</mesh>
<mesh position={[0, 0.08, 0]}>
<sphereGeometry args={[0.1, 16, 16]} />
<meshBasicMaterial color={color} transparent opacity={0.9} />
</mesh>
</group>
);
}
function getPlacementFeedbackColor(
state: NonNullable<RepairPartPlacementFeedbackProps["state"]>,
): string {
if (state === "valid") return VALID_PART_COLOR;
if (state === "stored") return STORED_BROKEN_PART_COLOR;
return INVALID_PART_COLOR;
}
function getReplacementFeedbackState(
partId: string,
requiredPartId: string,
isPlaced: boolean,
): RepairPartPlacementFeedbackProps["state"] {
if (!isPlaced) return null;
return partId === requiredPartId ? "valid" : "invalid";
}
function getPlaceholderTargets(
placeholders: readonly RepairCasePlaceholder[],
): readonly RepairCasePlaceholder[] {
if (placeholders.length > 0) {
return placeholders;
}
return FALLBACK_PLACEHOLDER_OFFSETS.map(
(offset, index): RepairCasePlaceholder => ({
name: `placeholder_${index + 1}`,
position: [
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
],
}),
);
}
function getBrokenPartTargetPositions(
part: RepairScannedBrokenPart,
placeholderTargets: readonly RepairCasePlaceholder[],
): readonly Vector3Tuple[] {
if (!part.placeholderName) {
return placeholderTargets.map((placeholder) => placeholder.position);
}
const matchingPlaceholder = placeholderTargets.find(
(placeholder) => placeholder.name === part.placeholderName,
);
return matchingPlaceholder
? [matchingPlaceholder.position]
: placeholderTargets.map((placeholder) => placeholder.position);
}
function isNearPlaceholder(
position: THREE.Vector3,
placeholderPositions: readonly Vector3Tuple[],
): boolean {
return placeholderPositions.some(
(placeholderPosition) =>
position.distanceTo(_placeholderPosition.set(...placeholderPosition)) <=
REPAIR_INSTALL_RADIUS,
);
}
function getStepLocalPosition(
worldPosition: THREE.Vector3,
group: THREE.Group | null,
target: THREE.Vector3,
): THREE.Vector3 {
target.copy(worldPosition);
group?.worldToLocal(target);
return target;
}
function getReplacementParts(
config: RepairMissionConfig,
): readonly RepairMissionPartConfig[] {
if (config.replacementParts.length > 0) return config.replacementParts;
return [
{
id: config.requiredReplacementPartId,
label: config.label,
modelPath: config.modelPath,
},
];
}
function getBrokenPartsToDeposit(
config: RepairMissionConfig,
brokenParts: readonly RepairScannedBrokenPart[],
): readonly RepairScannedBrokenPart[] {
if (brokenParts.length > 0) return brokenParts;
return config.brokenParts.map((part) => ({
id: part.id,
label: part.label,
modelPath: part.modelPath ?? config.modelPath,
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
}));
}
@@ -1,142 +0,0 @@
import { useEffect, useState } from "react";
import * as THREE from "three";
import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight";
import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual";
import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
interface RepairScanSequenceProps {
config: RepairMissionConfig;
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
}
export interface RepairScannedBrokenPart {
id: string;
label: string;
modelPath: string;
placeholderName?: string;
}
export function RepairScanSequence({
config,
onComplete,
}: RepairScanSequenceProps): React.JSX.Element {
const [parts, setParts] = useState<readonly ExplodedPart[]>([]);
const [activePartIndex, setActivePartIndex] = useState(0);
const activePart = parts[activePartIndex];
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
(partIndex) => partIndex <= activePartIndex,
);
useEffect(() => {
if (parts.length === 0) return undefined;
const timeoutId = window.setTimeout(() => {
setActivePartIndex((currentIndex) => {
const nextIndex = currentIndex + 1;
if (nextIndex >= parts.length) {
onComplete(getScannedBrokenParts(parts, config));
return currentIndex;
}
return nextIndex;
});
}, scanPartSeconds * 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [activePartIndex, config, onComplete, parts, scanPartSeconds]);
return (
<group>
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split
onPartsReady={setParts}
/>
<RepairScanVisual target={activePart?.object} />
{visibleBrokenPartIndexes.map((partIndex) => {
const part = parts[partIndex];
if (!part) return null;
return (
<group key={part.object.uuid}>
<RepairBrokenPartHighlight target={part.object} />
<RepairBrokenPartPrompt
src={config.brokenUiPath}
target={part.object}
/>
</group>
);
})}
</group>
);
}
function getScannedBrokenParts(
parts: readonly ExplodedPart[],
config: RepairMissionConfig,
): readonly RepairScannedBrokenPart[] {
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
return brokenPartIndexes.map((_, index) => {
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
return {
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
label: configuredPart?.label ?? `${config.label} broken part`,
modelPath: configuredPart?.modelPath ?? config.modelPath,
...(configuredPart?.placeholderName
? { placeholderName: configuredPart.placeholderName }
: {}),
};
});
}
function getBrokenPartIndexes(
parts: readonly ExplodedPart[],
brokenParts: readonly RepairMissionPartConfig[],
): number[] {
if (parts.length === 0 || brokenParts.length === 0) return [];
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
const { nodeName } = brokenPart;
if (!nodeName) return [];
const index = parts.findIndex((part) =>
objectContainsNodeName(part.object, nodeName),
);
return index >= 0 ? [index] : [];
});
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
return parts.slice(0, brokenParts.length).map((_, index) => index);
}
function objectContainsNodeName(
object: THREE.Object3D,
nodeName: string,
): boolean {
if (object.name === nodeName) return true;
let found = false;
object.traverse((child) => {
if (child.name === nodeName) {
found = true;
}
});
return found;
}
@@ -1,53 +0,0 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
interface RepairScanVisualProps {
target?: THREE.Object3D | null | undefined;
}
export function RepairScanVisual({
target = null,
}: RepairScanVisualProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const scanLineRef = useRef<THREE.Mesh>(null);
const worldPosition = useRef(new THREE.Vector3());
const localPosition = useRef(new THREE.Vector3());
useFrame(({ clock }) => {
const group = groupRef.current;
const scanLine = scanLineRef.current;
if (!group || !scanLine) return;
if (target) {
target.getWorldPosition(worldPosition.current);
localPosition.current.copy(worldPosition.current);
group.parent?.worldToLocal(localPosition.current);
group.position.copy(localPosition.current);
}
scanLine.position.y = 0.35 + Math.sin(clock.elapsedTime * 4) * 0.7;
});
return (
<group ref={groupRef}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[1.35, 0.035, 12, 96]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.75} />
</mesh>
<mesh ref={scanLineRef} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.15, 1.25, 96]} />
<meshBasicMaterial
color="#7dd3fc"
side={THREE.DoubleSide}
transparent
opacity={0.45}
/>
</mesh>
<mesh position={[0, 0.85, 0]}>
<sphereGeometry args={[1.25, 32, 16]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
</mesh>
</group>
);
}
@@ -32,6 +32,7 @@ const GLOVE_CONFIGS: Record<
const GLOVE_MODEL_SCALE = 0.33;
const HAND_SPACE_DISTANCE = 0.5;
const HAND_DEPTH_SCALE = 0.5;
const HAND_TRACKING_HIDE_DELAY_MS = 250;
const FINGER_LANDMARK_CHAINS = [
@@ -125,7 +126,12 @@ function landmarkToWorldPoint(
target.unproject(camera);
_direction.copy(target).sub(_cameraPosition).normalize();
target.copy(_cameraPosition).addScaledVector(_direction, HAND_SPACE_DISTANCE);
target
.copy(_cameraPosition)
.addScaledVector(
_direction,
HAND_SPACE_DISTANCE - landmark.z * HAND_DEPTH_SCALE,
);
return target;
}
@@ -1,8 +1,7 @@
import { useEffect, useRef } from "react";
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier";
import gsap from "gsap";
import * as THREE from "three";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import {
@@ -25,7 +24,10 @@ import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { InteractionManager } from "@/managers/InteractionManager";
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
import type {
HandTrackingHand,
HandTrackingLandmark,
} from "@/types/handTracking/handTracking";
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
interface GrabbableObjectProps {
@@ -34,16 +36,6 @@ interface GrabbableObjectProps {
colliders?: ColliderShape;
label?: string;
handControlled?: boolean;
onPositionChange?: (position: THREE.Vector3) => void;
onSnap?: (position: THREE.Vector3) => void;
snapDuration?: number;
snapRadius?: number;
snapTargets?: readonly Vector3Tuple[];
}
interface HandScreenPoint {
x: number;
y: number;
}
const grabDebugParams = {
@@ -63,11 +55,10 @@ const _handDirection = new THREE.Vector3();
const _handHitDirection = new THREE.Vector3();
const _cameraPos = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _snapPosition = new THREE.Vector3();
const _snapTargetWorldPosition = new THREE.Vector3();
const _handRaycaster = new THREE.Raycaster();
const HAND_GRAB_SCREEN_RADIUS = 0.04;
const HAND_DEPTH_SENSITIVITY = 4;
const HAND_HIT_OFFSETS: Array<[number, number]> = [
[0, 0],
[HAND_GRAB_SCREEN_RADIUS, 0],
@@ -76,10 +67,10 @@ const HAND_HIT_OFFSETS: Array<[number, number]> = [
[0, -HAND_GRAB_SCREEN_RADIUS],
];
function getHandCenterPoint(hand: HandTrackingHand): HandScreenPoint {
const landmarks = hand.landmarks;
function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
const landmarks = hand.landmarks ?? [];
if (landmarks.length === 0) {
return { x: hand.x, y: hand.y };
return { x: hand.x, y: hand.y, z: hand.z };
}
let minX = landmarks[0]!.x;
@@ -97,6 +88,7 @@ function getHandCenterPoint(hand: HandTrackingHand): HandScreenPoint {
return {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
z: hand.z,
};
}
@@ -104,7 +96,7 @@ function getHandHit(
group: THREE.Group | null,
camera: THREE.Camera,
cameraPos: THREE.Vector3,
handCenter: HandScreenPoint,
handCenter: HandTrackingLandmark,
): THREE.Intersection | null {
if (!group) return null;
@@ -131,83 +123,15 @@ export function GrabbableObject({
colliders = GRAB_DEFAULT_COLLIDERS,
label = GRAB_DEFAULT_LABEL,
handControlled = false,
onPositionChange,
onSnap,
snapDuration = 0.25,
snapRadius = 0,
snapTargets = [],
}: GrabbableObjectProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const { hands } = useHandTrackingSnapshot();
const spaceRef = useRef<THREE.Group>(null);
const groupRef = useRef<THREE.Group>(null);
const rbRef = useRef<RapierRigidBody>(null);
const isHolding = useRef(false);
const isHandHolding = useRef(false);
const snapTween = useRef<gsap.core.Tween | null>(null);
useEffect(() => {
return () => {
snapTween.current?.kill();
};
}, []);
function snapToNearestTarget(): void {
const body = rbRef.current;
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
const translation = body.translation();
_currentPos.set(translation.x, translation.y, translation.z);
let nearestTarget: Vector3Tuple | null = null;
let nearestTargetWorld: Vector3Tuple | null = null;
let nearestDistance = snapRadius;
snapTargets.forEach((target) => {
_snapTargetWorldPosition.set(target[0], target[1], target[2]);
spaceRef.current?.localToWorld(_snapTargetWorldPosition);
const distance = _currentPos.distanceTo(_snapTargetWorldPosition);
if (distance <= nearestDistance) {
nearestDistance = distance;
nearestTarget = target;
nearestTargetWorld = [
_snapTargetWorldPosition.x,
_snapTargetWorldPosition.y,
_snapTargetWorldPosition.z,
];
}
});
if (!nearestTarget || !nearestTargetWorld) return;
snapTween.current?.kill();
const animatedPosition = {
x: _currentPos.x,
y: _currentPos.y,
z: _currentPos.z,
};
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel(ZERO_ANGULAR_VELOCITY, true);
snapTween.current = gsap.to(animatedPosition, {
x: nearestTargetWorld[0],
y: nearestTargetWorld[1],
z: nearestTargetWorld[2],
duration: snapDuration,
ease: "power2.out",
onUpdate: () => {
body.setTranslation(animatedPosition, true);
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
},
onComplete: () => {
_snapPosition.set(
animatedPosition.x,
animatedPosition.y,
animatedPosition.z,
);
onSnap?.(_snapPosition);
},
});
}
const handHoldDistance = useRef<number | null>(null);
const handHoldStartZ = useRef<number | null>(null);
useDebugFolder("GrabbableObject", (folder) => {
folder
@@ -248,7 +172,6 @@ export function GrabbableObject({
const t = rbRef.current.translation();
_currentPos.set(t.x, t.y, t.z);
onPositionChange?.(_currentPos);
if (fistHand) {
const handCenter = getHandCenterPoint(fistHand);
@@ -268,22 +191,34 @@ export function GrabbableObject({
: null;
isHandHolding.current = Boolean(hit);
handHoldDistance.current = hit ? GRAB_HOLD_DISTANCE_DEFAULT : null;
handHoldStartZ.current = hit ? fistHand.z : null;
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
}
} else {
if (isHandHolding.current) {
snapToNearestTarget();
}
isHandHolding.current = false;
handHoldDistance.current = null;
handHoldStartZ.current = null;
InteractionManager.getInstance().setHandHolding(false);
}
if (!isHolding.current && !isHandHolding.current) return;
if (fistHand && isHandHolding.current) {
const depthOffset =
handHoldStartZ.current === null
? 0
: (fistHand.z - handHoldStartZ.current) * HAND_DEPTH_SENSITIVITY;
const holdDistance = THREE.MathUtils.clamp(
(handHoldDistance.current ?? grabDebugParams.holdDistance) +
depthOffset,
GRAB_HOLD_DISTANCE_MIN,
GRAB_HOLD_DISTANCE_MAX,
);
_holdTarget
.copy(_cameraPos)
.addScaledVector(_handDirection, grabDebugParams.holdDistance);
.addScaledVector(_handDirection, holdDistance);
} else {
camera.getWorldDirection(_holdTarget);
_holdTarget
@@ -303,45 +238,42 @@ export function GrabbableObject({
});
return (
<group ref={spaceRef}>
<RigidBody
ref={rbRef}
type="dynamic"
colliders={colliders}
position={position}
>
<group ref={groupRef}>
<InteractableObject
kind="grab"
label={label}
position={position}
bodyRef={rbRef}
onPress={() => {
isHolding.current = true;
}}
onRelease={() => {
isHolding.current = false;
snapToNearestTarget();
if (
!rbRef.current ||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
)
return;
const v = rbRef.current.linvel();
rbRef.current.setLinvel(
{
x: v.x * grabDebugParams.throwBoost,
y: v.y * grabDebugParams.throwBoost,
z: v.z * grabDebugParams.throwBoost,
},
true,
);
}}
>
{children}
</InteractableObject>
</group>
</RigidBody>
</group>
<RigidBody
ref={rbRef}
type="dynamic"
colliders={colliders}
position={position}
>
<group ref={groupRef}>
<InteractableObject
kind="grab"
label={label}
position={position}
bodyRef={rbRef}
onPress={() => {
isHolding.current = true;
}}
onRelease={() => {
isHolding.current = false;
if (
!rbRef.current ||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
)
return;
const v = rbRef.current.linvel();
rbRef.current.setLinvel(
{
x: v.x * grabDebugParams.throwBoost,
y: v.y * grabDebugParams.throwBoost,
z: v.z * grabDebugParams.throwBoost,
},
true,
);
}}
>
{children}
</InteractableObject>
</group>
</RigidBody>
);
}
@@ -19,7 +19,6 @@ import type { Vector3Tuple } from "@/types/three/three";
interface InteractableObjectBaseProps {
label: string;
position: Vector3Tuple;
radius?: number;
bodyRef?: RefObject<RapierRigidBody | null>;
onPress: () => void;
children: React.ReactNode;
@@ -65,15 +64,7 @@ function createInteractableHandle(
export function InteractableObject(
props: InteractableObjectProps,
): React.JSX.Element {
const {
kind,
label,
position,
radius = INTERACTION_RADIUS,
bodyRef,
onPress,
children,
} = props;
const { kind, label, position, bodyRef, onPress, children } = props;
const onRelease = props.kind === "grab" ? props.onRelease : null;
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
@@ -157,15 +148,13 @@ export function InteractableObject(
if (bodyRef?.current) {
const t = bodyRef.current.translation();
_objectPos.set(t.x, t.y, t.z);
} else if (group) {
group.getWorldPosition(_objectPos);
} else {
_objectPos.set(...position);
}
camera.getWorldPosition(_cameraPos);
const dist = _cameraPos.distanceTo(_objectPos);
const isNearby = dist <= radius;
const isNearby = dist <= INTERACTION_RADIUS;
manager.setNearby(handle.current, isNearby);
@@ -178,7 +167,7 @@ export function InteractableObject(
camera.getWorldDirection(_cameraDir);
_raycaster.set(_cameraPos, _cameraDir);
_raycaster.far = radius;
_raycaster.far = INTERACTION_RADIUS;
const hits = group ? _raycaster.intersectObject(group, true) : [];
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
@@ -196,7 +185,7 @@ export function InteractableObject(
<mesh ref={debugSphereRef} visible={false}>
<sphereGeometry
args={[
radius,
INTERACTION_RADIUS,
INTERACTION_DEBUG_SPHERE_SEGMENTS,
INTERACTION_DEBUG_SPHERE_SEGMENTS,
]}
@@ -1,10 +1,8 @@
import { useRef, useState } from "react";
import { useState } from "react";
import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
import {
TRIGGER_DEFAULT_COLLIDERS,
TRIGGER_DEFAULT_LABEL,
@@ -24,7 +22,6 @@ interface TriggerObjectProps {
children: React.ReactNode;
colliders?: ColliderShape;
label?: string;
radius?: number;
soundPath?: string;
soundVolume?: number;
spawnModel?: string;
@@ -55,7 +52,6 @@ export function TriggerObject({
children,
colliders = TRIGGER_DEFAULT_COLLIDERS,
label = TRIGGER_DEFAULT_LABEL,
radius = INTERACTION_RADIUS,
soundPath,
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
spawnModel,
@@ -63,22 +59,14 @@ export function TriggerObject({
onTrigger,
}: TriggerObjectProps): React.JSX.Element {
const [spawned, setSpawned] = useState<SpawnedModel[]>([]);
const rbRef = useRef<RapierRigidBody>(null);
return (
<>
<RigidBody
ref={rbRef}
type="fixed"
colliders={colliders}
position={position}
>
<RigidBody type="fixed" colliders={colliders} position={position}>
<InteractableObject
kind="trigger"
label={label}
position={position}
radius={radius}
bodyRef={rbRef}
onPress={() => {
if (soundPath) {
AudioManager.getInstance().playSound(soundPath, soundVolume, {
@@ -7,12 +7,15 @@ import {
type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import type { Vector3Tuple } from "@/types/three/three";
export interface AnimatedModelConfig extends ModelTransformProps {
export interface AnimatedModelConfig {
modelPath: string;
animations?: string[];
defaultAnimation?: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
fadeDuration?: number;
speed?: number;
autoPlay?: boolean;
@@ -4,7 +4,6 @@ import { useFrame } from "@react-three/fiber";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { ExplodedModel } from "@/utils/three/ExplodedModel";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import { toVector3Scale } from "@/utils/three/scale";
@@ -13,8 +12,6 @@ interface ModelErrorBoundaryProps {
children: ReactNode;
modelPath: string;
position?: Vector3Tuple | undefined;
rotation?: Vector3Tuple | undefined;
scale?: ModelTransformProps["scale"] | undefined;
}
interface ModelErrorBoundaryState {
@@ -40,8 +37,6 @@ class ModelErrorBoundary extends Component<
modelPath: this.props.modelPath,
scope: "ExplodableModel",
position: this.props.position,
rotation: this.props.rotation,
scale: this.props.scale,
},
error,
);
@@ -49,13 +44,7 @@ class ModelErrorBoundary extends Component<
render(): ReactNode {
if (this.state.hasError) {
return (
<MissingModelFallback
position={this.props.position}
rotation={this.props.rotation}
scale={this.props.scale}
/>
);
return <MissingModelFallback position={this.props.position} />;
}
return this.props.children;
@@ -66,7 +55,6 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
modelPath: string;
split: boolean;
splitDistance?: number;
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
}
export function ExplodableModel(
@@ -77,8 +65,6 @@ export function ExplodableModel(
key={props.modelPath}
modelPath={props.modelPath}
position={props.position}
rotation={props.rotation}
scale={props.scale}
>
<ExplodableModelInner {...props} />
</ModelErrorBoundary>
@@ -92,7 +78,6 @@ function ExplodableModelInner({
rotation = [0, 0, 0],
scale = 1,
splitDistance = 1.2,
onPartsReady,
}: ExplodableModelInnerProps): React.JSX.Element {
const { scene } = useLoggedGLTF(modelPath, {
scope: "ExplodableModel",
@@ -111,10 +96,6 @@ function ExplodableModelInner({
explodedModel.setSplit(split);
}, [explodedModel, split]);
useEffect(() => {
onPartsReady?.(explodedModel.getParts());
}, [explodedModel, onPartsReady]);
useFrame((_, delta) => {
explodedModel.update(delta);
});
@@ -128,15 +109,11 @@ function ExplodableModelInner({
function MissingModelFallback({
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
}: {
position?: Vector3Tuple | undefined;
rotation?: Vector3Tuple | undefined;
scale?: ModelTransformProps["scale"] | undefined;
}): React.JSX.Element {
return (
<mesh position={position} rotation={rotation} scale={toVector3Scale(scale)}>
<mesh position={position}>
<boxGeometry args={[0.7, 0.7, 0.7]} />
<meshStandardMaterial color="#7f1d1d" wireframe />
</mesh>
+5 -2
View File
@@ -1,9 +1,12 @@
import { useMemo } from "react";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import type { Vector3Tuple } from "@/types/three/three";
export interface SimpleModelConfig extends ModelTransformProps {
export interface SimpleModelConfig {
modelPath: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
castShadow?: boolean;
receiveShadow?: boolean;
}
@@ -1,4 +1,4 @@
import { createContext } from "react";
import { createContext, useContext } from "react";
export interface AnimatedModelContextValue {
play: (name: string, fade?: number) => void;
@@ -12,3 +12,12 @@ export interface AnimatedModelContextValue {
export const AnimatedModelContext =
createContext<AnimatedModelContextValue | null>(null);
export function useAnimatedModel(): AnimatedModelContextValue {
const context = useContext(AnimatedModelContext);
if (!context) {
throw new Error("useAnimatedModel must be used inside AnimatedModel");
}
return context;
}
@@ -1,42 +0,0 @@
import { Html } from "@react-three/drei";
import type { Vector3Tuple } from "@/types/three/three";
interface WorldVideoPromptProps {
src: string;
position?: Vector3Tuple;
size?: number;
billboard?: boolean;
}
export function WorldVideoPrompt({
src,
position = [0, 0, 0],
size = 96,
billboard = true,
}: WorldVideoPromptProps): React.JSX.Element {
return (
<Html
position={position}
center
transform
sprite={billboard}
occlude={false}
>
<video
aria-hidden="true"
autoPlay
loop
muted
playsInline
src={src}
style={{
display: "block",
height: size,
objectFit: "contain",
pointerEvents: "none",
width: size,
}}
/>
</Html>
);
}
-2
View File
@@ -3,7 +3,6 @@ import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles";
export function GameUI(): React.JSX.Element {
@@ -11,7 +10,6 @@ export function GameUI(): React.JSX.Element {
<>
<DebugOverlayLayout />
<Crosshair />
<RepairMovementLockIndicator />
<InteractPrompt />
<HandTrackingVisualizer />
<Subtitles />
+1 -1
View File
@@ -47,7 +47,7 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
return (
<svg className="hand-tracking-visualizer" aria-hidden="true">
{hands.map((hand, handIndex) => {
const landmarks = hand.landmarks;
const landmarks = hand.landmarks ?? [];
if (landmarks.length === 0) return null;
const color = hand.isFist ? "#facc15" : "#38bdf8";
@@ -1,20 +0,0 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
export function RepairMovementLockIndicator(): React.JSX.Element | null {
const cameraMode = useCameraMode();
const movementLocked = useRepairMovementLocked();
if (cameraMode !== "player") return null;
if (!movementLocked) return null;
return (
<div className="repair-movement-lock-indicator" aria-live="polite">
<span
className="repair-movement-lock-indicator__dot"
aria-hidden="true"
/>
<span>Déplacement verrouillé pendant la réparation</span>
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
import type { SceneLoadingState } from "@/types/world/sceneLoading";
interface SceneLoadingOverlayProps {
state: SceneLoadingState;
}
export function SceneLoadingOverlay({
state,
}: SceneLoadingOverlayProps): React.JSX.Element | null {
const isReady = state.status === "ready";
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
return (
<div
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
aria-live="polite"
>
<div className="scene-loading-overlay__content">
<strong>{state.currentStep}</strong>
<div className="scene-loading-overlay__track">
<span style={{ width: `${progress}%` }} />
<em>{progress}%</em>
</div>
</div>
</div>
);
}
+16 -32
View File
@@ -1,9 +1,9 @@
import { RotateCcw, StepBack, StepForward } from "lucide-react";
import {
type MainGameState,
type MissionStep,
useGameStore,
} from "@/managers/stores/useGameStore";
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
const MAIN_STATES: MainGameState[] = [
"intro",
@@ -13,6 +13,16 @@ const MAIN_STATES: MainGameState[] = [
"outro",
];
const MISSION_STEPS: MissionStep[] = [
"locked",
"waiting",
"inspected",
"fragmented",
"scanning",
"repairing",
"done",
];
function toPascalCase(value: string): string {
return value
.split(/[-_\s]+/)
@@ -23,9 +33,6 @@ function toPascalCase(value: string): string {
export function GameStateDebugPanel(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const bikeStep = useGameStore((state) => state.bike.currentStep);
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
const fermeStep = useGameStore((state) => state.ferme.currentStep);
const detail = useGameStore((state) => {
switch (state.mainState) {
case "intro":
@@ -63,45 +70,22 @@ export function GameStateDebugPanel(): React.JSX.Element {
return;
}
if (mainState === "outro") {
setOutroState({ hasStarted: nextSubState === "started" });
return;
}
if (!isMissionStep(nextSubState)) return;
if (mainState === "bike") {
setBikeState({ currentStep: nextSubState });
setBikeState({ currentStep: nextSubState as MissionStep });
return;
}
if (mainState === "pylone") {
setPyloneState({ currentStep: nextSubState });
setPyloneState({ currentStep: nextSubState as MissionStep });
return;
}
if (mainState === "ferme") {
setFermeState({ currentStep: nextSubState });
return;
}
}
function setDebugMainState(nextMainState: MainGameState): void {
setMainState(nextMainState);
if (nextMainState === "bike" && bikeStep === "locked") {
setBikeState({ currentStep: "waiting" });
setFermeState({ currentStep: nextSubState as MissionStep });
return;
}
if (nextMainState === "pylone" && pyloneStep === "locked") {
setPyloneState({ currentStep: "waiting" });
return;
}
if (nextMainState === "ferme" && fermeStep === "locked") {
setFermeState({ currentStep: "waiting" });
}
setOutroState({ hasStarted: nextSubState === "started" });
}
return (
@@ -129,7 +113,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
aria-pressed={state === mainState}
className={state === mainState ? "is-active" : undefined}
type="button"
onClick={() => setDebugMainState(state)}
onClick={() => setMainState(state)}
>
{toPascalCase(state)}
</button>

Some files were not shown because too many files have changed in this diff Show More