Démo jouable en navigateur : React + TypeScript + Three.js (React Three Fiber), physique Rapier, état zustand.
Suivi du projet, décisions et reste à faire : ROADMAP.md.
Plans d'implémentation détaillés : docs/ — actuellement
points d'intérêt interactifs.
Reprise du projet par un autre agent : HANDOFF.md.
npm install
npm run dev # http://localhost:5173
npm run dev -- --openAjouter ?debug à l'URL (http://localhost:5173/?debug) affiche les colliders Rapier.
src/
App.tsx Canvas, Physics, KeyboardControls
config/controls.ts mapping clavier (codes physiques → WASD + ZQSD)
config/gameplay.ts constantes réglables (vitesse, saut, caméra, map)
config/world.ts relief, mer, classification des biomes, bruit
config/worldShape.ts ce qu'un monde doit savoir répondre au décor
config/continentShape.ts le continent, vu comme une `WorldShape`
config/beyond.ts l'Outremonde : relief, roue des biomes, Sanctuaire
config/beyondEnemies.ts peuplement de l'Outremonde, postes des Lynels
config/challenge.ts le défi : difficultés, durées, barème, rangs
scores/entry.ts le classement : pseudo, rang, tamis (pur, testé)
scores/firestore.ts les deux seuls échanges réseau : lire, enregistrer
firebase/app.ts l'app Firebase, chargée à la demande
config/biomes.ts palettes des sept biomes
config/enemies.ts statistiques par espèce, placement
config/landmarks.ts points d'intérêt : position, terrasse, découverte
config/sites.ts lieux remarquables : ce qui se nomme sans s'ouvrir
config/bridge.ts tracé du pont de Nakano, ancrages lus dans le relief
config/items.ts objets : effets, emplacements, silhouettes, lames
config/chests.ts coffres : monument porteur, position locale, contenu
config/quests.ts journal de quêtes : dérivé de l'état, jamais stocké
i18n/fr.json, en.json dictionnaires (interface + contenu du portfolio)
i18n/index.ts types, dictionnaires, format()
i18n/useI18n.ts accès au dictionnaire courant
state/gameClock.ts horloge de jeu : s'arrête en pause
types/game.ts types métier (phase, ennemis, biomes)
store/useGameStore.ts état de partie zustand (vie, phase, kills)
store/useScoresStore.ts le classement en ligne : filtres, lignes, envoi
store/quests.ts le journal, relu depuis l'état de partie
state/playerTransform.ts transform du joueur partagé hors React (60 fps)
state/cameraView.ts matrice view-projection publiée hors React + projection écran
state/enemyRegistry.ts position, état d'IA et PV des ennemis vivants
state/projectiles.ts pool de tirs
state/pickups.ts pool de cœurs lâchés
components/
Player.tsx contrôleur physique du personnage
CameraRig.tsx caméra 3e personne lissée
Environment.tsx composition du décor (ciel, lumières, sol, végétation)
PostFX.tsx bloom + tilt-shift + vignette (rendu "diorama")
HUD.tsx overlay 2D
Minimap.tsx carte 2D, position du joueur, ennemis
CombatOverlay.tsx barres de vie et chevrons d'alerte, en canvas 2D
StrikeArc.tsx traînée du coup (arc de lame ou onde de poing), verdict
Pickups.tsx cœurs lâchés : chute, flottement, ramassage
Enemy.tsx physique et machine à états d'un ennemi
Enemies.tsx peuplement de la carte
Projectiles.tsx pool de projectiles, un seul draw call
enemies/models.tsx Octorok et Moblin, matériaux par instance
environment/Terrain.tsx relief, couleurs par sommet, collider heightfield
(prend une `WorldShape` : continent ou Outremonde)
environment/Water.tsx mer translucide, houle en vertex shader
environment/StarrySky.tsx ciel procédural : Voie lactée, étoiles, nébuleuses
environment/Vegetation.tsx semis instancié des sept biomes
environment/Landmarks.tsx monuments, proximité et touche d'interaction
environment/Temple.tsx Temple du Sommet, géométrie fusionnée et colliders
environment/InteractionMarker.tsx braise bleue : « on peut agir ici »
environment/Pyramid.tsx Pyramide de la Jungle — la présentation
environment/Stele.tsx Grande Stèle — les compétences
environment/Statue.tsx Idole des Terres Arides — le parcours
environment/Ruins.tsx Ruines de l'Île — le contact
environment/Pagoda.tsx Temple de Nakano — la pagode de l'îlot du nord-est
environment/Bridge.tsx pont et torii vers l'îlot : tablier, piles, colliders
environment/TreasureChest.tsx coffre au trésor et séquence d'ouverture
environment/solids.ts primitives partagées des monuments
environment/faceted.ts normales par face, partagé par le décor
environment/windMaterial.ts matériau toon + vent en vertex shader
GameClock.tsx avance l'horloge de jeu, avant tout le monde
KeyboardGuard.tsx relâche les touches quand la fenêtre perd le focus
LanguageToggle.tsx sélecteur FR / EN
portfolio/PortfolioDialog.tsx panneau des projets, navigation, focus
portfolio/ProjectStepper.tsx pastilles de progression
portfolio/ProjectIllustration.tsx illustration générée par projet
portfolio/illustration/ projection isométrique et motifs
portfolio/sections.ts contenu d'une section → diapositives
quests/QuestButton.tsx pastille du journal, point d'appel, bulle d'entrée
quests/QuestPanel.tsx le journal : quêtes en cours et accomplies
models/SafeModel.tsx fallback si un .glb est absent
models/HeroModel.tsx modèle du joueur (.glb ou personnage procédural)
models/HeroPlaceholder.tsx personnage articulé + animation procédurale
models/toonGradient.ts rampe de cel-shading partagée
beyond/Beyond.tsx l'Outremonde : le fragment chargé à la demande
beyond/BeyondSky.tsx ciel violet, aurore, planète annelée
beyond/Sanctuary.tsx la zone franche : dallage, monolithes, trêve
beyond/Crucible.tsx le dallage noir du centre, où attend la Déchue
beyond/Sensei.tsx le maître : modèle, salut, proximité
beyond/BeyondPopulation.tsx ennemis, cinq Lynels et une Malenia
ChallengeHUD.tsx réglages du défi, décompte, chronomètre, résultat
scores/ScoreSubmit.tsx enregistrer son score, et la place obtenue
scores/ScoreBoard.tsx le tableau : quatre filtres, huit colonnes
BeyondArrival.tsx la fanfare des cinq premières secondes
Les scores du défi du maître sont enregistrés dans Firestore. C'est la seule partie du jeu qui sorte du navigateur, et elle est entièrement facultative : sans configuration, le jeu est complet et le classement n'apparaît nulle part.
Configuration. Copier .env.example en .env et y mettre les identifiants du
projet Firebase (console Firebase > Paramètres du projet > Vos applications). Ces
identifiants sont publics par construction : ils désignent le projet, ils ne
l'autorisent pas — c'est le rôle des règles de sécurité.
Règles de sécurité. firestore.rules est à publier une fois, depuis la
console (Firestore Database > Règles) ou avec
firebase deploy --only firestore:rules. Sans elles, un projet en mode test
s'ouvre à tout et se referme au bout de trente jours : le classement marcherait
un mois, puis cesserait sans prévenir. Elles n'autorisent que la création, jamais
la modification ni la suppression, et valident la forme de chaque ligne.
Aucun index à créer. La lecture ne filtre que sur un seul champ — la
catégorie — parce que Firestore indexe automatiquement les champs pris isolément.
Le tri et les filtres de tenue et d'arme sont faits en mémoire, ce qui les rend
instantanés. Voir l'en-tête de src/scores/firestore.ts.
Le SDK ne pèse pas sur le démarrage. Il arrive par import() au premier
affichage du classement, dans un chunk à part : un visiteur qui ne finit jamais
le jeu ne le télécharge jamais.
Ce que le classement ne peut pas faire. Le jeu n'a pas de comptes : il n'y a donc pas d'auteur à qui restreindre l'écriture, et rien n'empêche quelqu'un de poster un score qu'il n'a pas fait. Les règles valident la forme, pas la sincérité. C'est un classement de jeu de navigateur, et la seule protection sérieuse serait de rejouer la partie côté serveur.
Les règles de jeu, elles, sont dans le client : seul un joueur encore en vie à la fin de son défi se voit proposer d'enregistrer, et personne n'y est obligé. Les pseudos identiques sont autorisés — sans compte, un nom n'a pas de propriétaire.
Deux collecteurs reçoivent exactement les mêmes événements : Umami pour un tableau de bord qui se lit d'un coup d'œil, Google Analytics 4 pour les entonnoirs, la comparaison de périodes et l'origine du trafic. Les deux sont facultatifs et indépendants — vider une variable d'environnement en éteint un sans toucher à l'autre, et sans rien changer au jeu.
Leurs totaux ne coïncideront jamais : les bloqueurs de publicité coupent couramment Google sans toucher à Umami. Ce sont deux mesures partielles, pas une seule à deux endroits.
Configuration. VITE_UMAMI_WEBSITE_ID pour Umami. Pour Google, rien à faire
si Firebase est déjà configuré : VITE_FIREBASE_MEASUREMENT_ID porte
l'identifiant du flux GA4 (G-…) et sert de repli. VITE_GA_MEASUREMENT_ID le
remplace le jour où la mesure quitterait Firebase.
Umami ne pose aucun cookie et ne demande donc rien à personne : il compte tout le monde, y compris ceux qui refusent Google.
Google, lui, démarre refusé et attend une réponse. Le tag est chargé en
mode consentement refusé (analytics_storage: 'denied'), et un bandeau — voir
components/ConsentBanner.tsx — demande au visiteur une fois le monde chargé.
Ce détour a une raison qu'il vaut mieux connaître avant de vouloir le supprimer : un relevé sans cookie ne remonte nulle part. Il ne porte ni identifiant d'utilisateur ni identifiant de session, donc GA4 ne l'affiche ni en temps réel, ni dans les rapports, ni dans DebugView ; il ne nourrit que la modélisation comportementale, qui réclame mille utilisateurs consentants par jour pour s'activer. Sans bandeau, Google recevait tout et ne montrait rien — la propriété est restée vide pendant les premiers jours pour cette seule raison. Ce n'est donc pas une précaution juridique ajoutée après coup, c'est ce qui rend le collecteur utilisable.
Où sont les scripts. Posés dans le <head> au build par
plugins/analytics.ts, jamais importés depuis le bundle : chargés après les
~3,6 Mo de moteur 3D, ils rateraient précisément les visiteurs qui abandonnent
pendant le téléchargement, c'est-à-dire la mesure la plus utile du site. Rien
n'est émis en développement, ni depuis les préproductions *.vercel.app.
Il ne paraît qu'une fois le monde chargé : l'écran de chargement est la première image du portfolio, et une boîte de dialogue posée devant coûterait plus de visiteurs que la mesure n'en explique. Il ne met pas la partie en pause et ne capture pas le clavier — on peut l'ignorer et jouer, le refus étant l'état par défaut.
Les deux boutons sont identiques, et aucun modificateur CSS n'est prévu pour les distinguer : refuser doit être aussi simple qu'accepter, faute de quoi l'accord ne vaut rien juridiquement et ne dit rien de vrai statistiquement.
Le choix est rangé dans localStorage sous z-project:consent, relu par le
script du <head> avant le premier relevé : sans ça, un visiteur déjà
consentant repartirait anonyme pendant toute la première seconde, page vue
comprise. Cette clé est la seule valeur du projet écrite à deux endroits — le
plugin tourne dans Node, le store dans le navigateur — et un test compare les
deux fichiers pour qu'elle ne puisse pas diverger en silence.
Une pastille discrète, en bas à gauche, rouvre le bandeau une fois la réponse donnée : on doit pouvoir se rétracter aussi facilement qu'on a accepté. Elle n'existe pas tant que la question n'a pas été posée.
Ils sont déclarés en un seul endroit, src/analytics/index.ts, chacun avec la
question à laquelle il répond. En résumé :
| Événement | Ce qu'il dit |
|---|---|
boot_complete |
le monde est jouable, en combien de temps — et le taux d'abandon au chargement |
landmark_opened |
quelle rubrique du portfolio s'ouvre, à pied ou par téléportation |
portfolio_slide_viewed |
quel projet, quelle école, quel contact est réellement atteint dans le panneau |
landmark_closed |
combien de temps une rubrique est restée ouverte, et combien de pages y ont été lues |
project_photo_opened |
une capture est ouverte en plein écran |
outbound_link |
un lien sortant est cliqué (contact, projet, réseau) |
language_changed |
la langue est changée à la main — donc la détection s'était trompée |
portal_opened |
la carte est vidée, le portail s'ouvre : par les combats ou par le code de triche |
sky_island_entered |
le portail est franchi |
boss_engaged / boss_parry / boss_defeated |
le Lynel : engagé, paré, tombé, et avec combien de cœurs |
trial_cleared / golden_slain |
l'épreuve des trois Lynels, puis le Lynel doré du sommet |
malenia_engaged / malenia_morph / malenia_parry / malenia_defeated |
le dernier combat, de la première phase à la fin du jeu |
beyond_entered |
l'après-partie est découverte |
player_died |
le joueur tombe : qui l'a tué, et sur quelle carte |
challenge_started / challenge_ended |
un défi, ses réglages, son score et ce qui l'a arrêté |
score_submitted |
un score est écrit au classement |
C'est le piège de Google, et il est silencieux. GA4 reçoit et conserve les
paramètres de chaque événement — landmark, hearts, attack… — mais n'en
affiche aucun tant qu'ils n'ont pas été déclarés dans Admin > Définitions
personnalisées. Rien ne le signale, et les données d'avant la déclaration ne
remontent pas : un paramètre déclaré en retard est perdu pour la période
écoulée. Umami, lui, les montre sans rien demander.
Quatorze dimensions et sept métriques, soit vingt et un formulaires de trois champs où une lettre mal recopiée donne une colonne vide. D'où un script :
node scripts/ga4-custom-definitions.mjs <clé.json> --dry-run # pour voir
node scripts/ga4-custom-definitions.mjs <clé.json> # pour faire
Il se relance sans risque — ce qui existe est sauté, rien n'est modifié ni
supprimé — et il ne demande pas quel compte viser : il lit l'identifiant de flux
dans .env et travaille sur la propriété qui le porte, ou s'arrête. La liste
des définitions est en tête du fichier, et c'est le miroir de
src/analytics/index.ts : ajouter un paramètre d'événement, c'est ajouter une
ligne là et relancer.
Ce qu'il faut lui donner. Une clé JSON de compte de service (celui de
Firebase fait l'affaire) dont l'adresse a été ajoutée en rôle Éditeur dans
GA4 > Admin > Gestion des accès à la propriété. L'API analyticsadmin doit
être activée sur le projet Google Cloud. La clé est un identifiant de longue
durée : elle se garde hors du dépôt et se supprime une fois le travail fait.
Compter ensuite 24 à 48 h avant de voir les paramètres dans les rapports standard ; le DebugView et le temps réel, eux, répondent tout de suite.
Tous les modèles sont chargés depuis public/models/. Tant qu'un fichier est
absent, un modèle de remplacement s'affiche : le jeu reste jouable.
Le joueur a un personnage procédural complet (HeroPlaceholder) : silhouette
chibi en primitives, meshToonMaterial + contour Outlines pour le
cel-shading, et animation à la main (marche synchronisée à la vitesse réelle,
pose aérienne, coup d'épée). Il reste le fallback même une fois le .glb posé.
Convention : l'avant d'un modèle est +Z. Si un .glb regarde dans l'autre
sens, ajouter rotation-y={Math.PI} sur son <primitive>.
| Fichier attendu | Étape | Où le trouver (CC0) |
|---|---|---|
public/models/hero.glb |
1 | Quaternius Ultimate Modular Characters / Kenney Blocky Characters |
La végétation ne passe pas par des .glb : arbres, rochers, touffes
d'herbe et fleurs sont générés en primitives dans Vegetation.tsx, puis rendus
en InstancedMesh avec une couleur par instance. Ça garantit une palette
cohérente par biome et zéro octet à télécharger. Pour brancher des modèles
téléchargés à la place, il suffit de remplacer les entrées de l'objet
geometries par les géométries extraites du .glb — le semis, les couleurs,
le vent et les colliders restent inchangés.
Les fichiers des ennemis seront listés ici à l'étape suivante.
Sources : Kenney, Quaternius, Poly Pizza.