|
|
há 1 semana atrás | |
|---|---|---|
| assets | há 1 semana atrás | |
| scripts | há 1 semana atrás | |
| src | há 1 semana atrás | |
| tests | há 1 semana atrás | |
| .gitignore | há 1 semana atrás | |
| CLAUDE.md | há 1 semana atrás | |
| electron-builder.yml | há 1 semana atrás | |
| electron.vite.config.ts | há 1 semana atrás | |
| eslint.config.mjs | há 1 semana atrás | |
| package-lock.json | há 1 semana atrás | |
| package.json | há 1 semana atrás | |
| readme.md | há 1 semana atrás | |
| tsconfig.base.json | há 1 semana atrás | |
| tsconfig.json | há 1 semana atrás | |
| tsconfig.node.json | há 1 semana atrás | |
| tsconfig.web.json | há 1 semana atrás | |
| vitest.config.ts | há 1 semana atrás |
Jeu de baston 2D en temps réel, exclusivement LAN, de 3 à 8 joueurs en chacun pour soi. Le décor (un goûter géant) est entièrement destructible. Le but : tenir la cerise le plus longtemps possible pendant que la crème anglaise monte.
Ce fichier sert de référence pour le game design et l'architecture. Lis-le avant toute modification. Si une décision le contredit, mets-le à jour dans le même commit.
| Rôle | Choix |
|---|---|
| Langage | TypeScript (strict, ESM) |
| Runtime / desktop | Node.js + Electron |
| Build / dev | electron-vite (HMR sur le renderer) |
| Rendu 2D | PixiJS v8 |
| Physique | planck.js (port de Box2D, déterministe et sans dépendance) |
| Réseau | ws (WebSocket sur TCP local) + dgram (UDP broadcast pour la découverte) |
| Sérialisation | msgpackr (binaire, dès le départ) |
| Tests | vitest (logique de shared/ et server/) |
| Packaging | electron-builder (cible Windows en priorité) |
| UI des menus | DOM + CSS simple, pas de framework |
Pas de nouvelle dépendance sans justification dans le message de commit.
npm install
npm run dev # Electron en dev (HMR renderer)
npm run server # serveur headless en Node pur (tests, bots, debug)
# ex. : npm run server -- --name="Test" --round=30 --rounds=2 --pause=5
npm test # vitest
npm run bench # benchmarks vitest (budget terrain §5)
npm run bench:node # même budget mesuré sous Node, sans le biais du lanceur de tests
npm run gen:maps # régénère les PNG placeholder des cartes intégrées
npm run lint # eslint + tsc --noEmit
npm run build # build de prod
npm run dist # installeur Windows (electron-builder)
goutermageddon/
├── CLAUDE.md
├── package.json
├── electron.vite.config.ts
├── src/
│ ├── main/ # Process principal Electron : fenêtres, découverte LAN, lancement du serveur
│ ├── preload/ # Pont sécurisé (contextIsolation) : API minimale exposée au renderer
│ ├── renderer/ # Client : rendu PixiJS, inputs, UI, interpolation, sons
│ ├── server/ # Simulation autoritaire : Node pur, AUCUNE dépendance Electron/DOM
│ └── shared/ # Code commun : terrain, matériaux, protocole, constantes, RNG, types
├── assets/
│ ├── maps/ # Une carte par dossier (voir §6)
│ ├── characters/ # Sprites et données des personnages
│ ├── materials/ # Textures des matériaux
│ ├── items/
│ └── audio/
└── tests/
shared/ ne dépend de rien d'autre que de lui-même : pas de Pixi, pas de DOM, pas d'Electron, pas de fs.server/ dépend de shared/ et de Node.renderer/ dépend de shared/ et de Pixi. Il ne contient aucune logique de jeu autoritaire.main/ orchestre : il crée les fenêtres, lance le serveur dans un utilityProcess quand le joueur héberge, et gère la découverte UDP.
L'hôte fait autorité. Le joueur qui héberge lance server/ dans un utilityProcess. Tous les clients, y compris le renderer de l'hôte, se connectent en WebSocket à IP_hôte:47100.
Découverte : le serveur émet un broadcast UDP sur le port 47101 toutes les secondes (nom de la partie, carte, nombre de joueurs, version). Les clients écoutent et listent les parties.
Simulation : pas fixe à 60 Hz côté serveur. Snapshots envoyés à 30 Hz.
Clients : ils envoient leurs inputs à chaque tick (bitmask des touches + direction de visée). Pas de prédiction au départ, car la latence LAN est inférieure à 5 ms. Les autres entités sont interpolées avec environ 50 ms de retard.
Le terrain n'est JAMAIS envoyé en entier, sauf au chargement de la carte (par son id et sa seed, chaque client charge les fichiers localement). Ensuite, on n'envoie que des événements de destruction (§5).
Tous les messages sont typés dans shared/protocol.ts et sérialisés avec msgpackr. Le protocole est versionné : un client d'une autre version est refusé avec un message clair.
Toute la logique aléatoire (objets, Magicien Raté, mutateurs) passe par un RNG à seed (shared/rng.ts, mulberry32 ou équivalent) côté serveur. Math.random() est interdit dans shared/ et server/.
Il n'y a pas de barre de vie. Un coup fait reculer et peut assommer brièvement. On meurt uniquement en tombant dans la crème ou sous un bloc qui s'écrase.
4 manches. Le score total décide du vainqueur.
Rattrapage : avant chaque manche, le dernier du classement choisit la carte (parmi la rotation, §6) et impose un personnage au premier.
Écran de fin avec trophées : Le Glouton, Le Kamikaze, Le Promoteur Immobilier, Pas Sa Faute, etc. Ils sont calculés à partir des stats de la partie (server/stats.ts).
Ils sont définis dans assets/materials/materials.json. L'id 0 correspond au vide.
| Id | Matériau | Résistance | Particularité |
|---|---|---|---|
| 1 | Génoise | faible | Matériau de base |
| 2 | Chocolat | forte | Résiste aux explosions |
| 3 | Chantilly | très faible | Rebondissante, amortit les chutes |
| 4 | Gelée | moyenne | Glissante, transmet les secousses |
| 5 | Biscuit | moyenne | Cassant : se détache en gros blocs |
| 6 | Caramel | forte | Collant : ralentit les déplacements |
| 7 | Laine | faible | Créée par Mamie Tricot, inflammable |
| 8 | Génoise détrempée | très faible | Créée par Bébé Pompier, s'affaisse lentement |
| 255 | Assiette | indestructible | Point d'ancrage (§5) |
Chaque matériau a les champs suivants : hardness, friction, restitution, density, flammable, color, texture, particles (type de miettes), sfx.
Ils sont définis dans src/shared/characters/ : une classe par personnage qui étend Character, avec les stats dans assets/characters/<id>/character.json.
| Personnage | Attaque | Spécial | Défaut |
|---|---|---|---|
| Castor Glouton | Coup de dents | Mange le décor en avançant (creuse) | Grossit en mangeant, jusqu'à passer à travers le sol |
| Mamie Tricot | Aiguilles | Lance des pelotes qui créent ponts et filets (matériau Laine) | Lente, et ses constructions brûlent |
| Pyromanchot | Pétards | Glissade sur le ventre avec explosion à l'arrivée | 1 pétard sur 3 lui explose dessus |
| Gégé le Ver | Coup de tête | Nage dans le terrain et surgit sous les pieds | Lent et vulnérable à découvert |
| Bébé Pompier | Jet d'eau (repousse) | Détrempe le terrain (Génoise → Génoise détrempée) | Le recul le propulse en arrière |
| Captain Prout | Coup de cape | Jetpack par petites poussées, laisse un nuage toxique (vision floue + toux) | Chaque poussée s'entend |
| Le Chat | Coup de patte | Pousse blocs, objets et joueurs assommés du bord | Se lèche la patte au hasard (1 s) |
| Magicien Raté | Baguette | Sort aléatoire : terrain en gelée, échange de position, lapin géant, « pfft » | Tirage pondéré : plus il est en tête, plus ses sorts ratent |
API commune : onAttack(), onSpecial(), onTick(dt), onHit(), et les stats (speed, jump, mass, size, cooldowns).
Uint8Array de taille width × height (une carte typique fait 2048 × 1024 px).dirty.Côté client : chaque morceau modifié reconstruit sa texture Pixi. La texture du matériau est échantillonnée en coordonnées monde, et une passe de contour donne l'effet feutre.
Toutes les modifications passent par des opérations déterministes en arithmétique entière définies dans shared/terrain/ops.ts :
type TerrainOp =
| { kind: 'carve'; x: number; y: number; r: number; power: number } // explosion, morsure
| { kind: 'fill'; x: number; y: number; r: number; material: number } // laine de Mamie
| { kind: 'convert'; x: number; y: number; r: number; from: number; to: number } // détrempage
power est comparé à la hardness de chaque matériau. Le chocolat résiste à un petit pétard.Ne jamais introduire de flottant dans l'application d'une op.
Règle : tout pixel solide qui n'est plus relié (4-connexité) à un pixel Assiette (ancre) tombe.
Après chaque op, le serveur lance un flood-fill à partir des pixels voisins de la zone modifiée pour trouver les composantes non ancrées.
Chaque composante détachée est retirée du bitmap et devient un corps dynamique planck (FallingChunk) avec son masque de pixels. Le serveur diffuse un événement detach { id, x, y, w, h, mask } (masque compressé en RLE). Les composantes minuscules (MIN_CHUNK_PIXELS) s'émiettent (debris) sans corps physique.
Les clients retirent les mêmes pixels et affichent le bloc comme un sprite. La position et la rotation du bloc arrivent dans les snapshots.
Quand le bloc s'immobilise (vitesse quasi nulle pendant 0,5 s), le serveur le rastérise dans le bitmap et diffuse merge { id, x, y, w, h, patch }. Le patch est le résultat pixel par pixel, pour qu'aucun client n'ait à recalculer la rotation. Il comble jusqu'à CHUNK_GLUE_PX pixels sous le bloc pour le raccrocher au sol. Si le résultat ne serait pas ancré (bloc posé sur un joueur ou sur un autre bloc), la réintégration est retentée plus tard.
Les ops, detach et merge forment un seul flux d'événements séquencés (shared/terrain/events.ts) : c'est ce flux, rejoué dans l'ordre, qui garantit un bitmap identique au bit près sur tous les clients (vérifié par les tests).
Côté physique, un bloc est approché par des boîtes alignées sur une grille (Box2D ne fait pas collisionner deux chaînes entre elles).
Un bloc qui tombe et percute un joueur avec assez d'énergie l'écrase (élimination). Les blocs mous (laine, chantilly : densité < CRUSH_MIN_DENSITY) n'écrasent pas. Un bloc qui tombe dans la crème disparaît.
Une op avec flood-fill et régénération des colliders doit tenir en 2 ms côté serveur. Si ce n'est pas le cas, on optimise : flood-fill borné, bitsets, cache des composantes.
60 FPS sur un PC milieu de gamme avec 8 joueurs et 20 blocs en chute.
Écrire un benchmark vitest pour les ops et le flood-fill dès la première étape.
Mesurer sous Node (npm run bench:node). Les benchmarks vitest sont pessimistes sur les boucles chaudes : le lanceur de tests transforme chaque lecture d'une constante importée en accès de propriété. Dans les modules de terrain, les boucles chaudes lisent donc des copies locales des constantes (const EMPTY = MATERIAL_EMPTY).
État mesuré (Node, JIT chaud) :
Une carte = un dossier dans assets/maps/<id>/. Ajouter une carte ne demande aucun code.
assets/maps/gateau-etages/
├── map.json # métadonnées et règles
├── terrain.png # masque des matériaux (une couleur exacte = un matériau)
├── background.png # décor de fond (non destructible, parallaxe possible)
└── thumb.png # vignette pour le menu
map.json{
"id": "gateau-etages",
"name": "Le Gâteau à Étages",
"size": [2048, 1024],
"materialPalette": { // couleur du PNG -> id de matériau
"#F2C46D": 1, "#5A3A22": 2, "#FFFFFF": 3, "#D93A5B": 4,
"#C8964B": 5, "#000000": 0, "#8899AA": 255
},
"spawns": [[300, 200], [1700, 200], [600, 400], [1400, 400]],
"cherrySpawn": [1024, 80],
"custard": { "startY": 1000, "speed": 2.5, "finalRushSpeed": 8 },
"items": { "interval": [6, 12], "weights": { "candle": 3, "smarties": 2, "whisk": 1 } },
"music": "birthday-flute",
"modifiers": [] // règles spéciales de la carte (voir ci-dessous)
}
shared/maps/loader.ts valide chaque carte au chargement :
| Id | Nom | Particularité |
|---|---|---|
gateau-etages |
Le Gâteau à Étages | Carte de référence : étages de génoise et chocolat, cerise au sommet |
mille-feuille |
Le Mille-Feuille | Couches horizontales alternées dures et molles : on creuse vers le bas facilement, sur le côté difficilement |
roule |
Le Roulé | Spirale de génoise et de confiture (gelée) : on tourne en rond, ça s'effondre en anneaux |
piece-montee |
La Pièce Montée | Pyramide de choux reliés par du caramel collant. Retirer un chou fait tomber tous ceux du dessus |
tarte-fruits |
La Tarte aux Fruits | Fond en biscuit cassant et fruits en gelée. Modificateur : le fond se fissure au hasard |
crepes |
La Pile de Crêpes | Couches très fines superposées, et une pile qui penche. Modificateur : vent latéral |
modifiers est une liste d'ids de règles spéciales implémentées dans server/modifiers/ (par exemple random-cracks, wind). Une carte peut en combiner plusieurs.devicePixelRatio, plafonnée à MAX_RESOLUTION). Le terrain est dessiné en sur-échantillonnage (TERRAIN_SUPERSAMPLE texels par pixel) à partir d'un champ de distance signée calculé autour de chaque morceau : les bords sont des courbes lissées et anticrénelées, jamais l'escalier du bitmap. Le bitmap et les ops restent au pixel près (seul l'affichage change). Les fonds sont générés en 2× et anticrénelés par npm run gen:maps ; le client déduit leur résolution de leur taille.renderer/characters/painters.ts) leur donne du volume (ombre propre, reflet, éclat, hachures au crayon), des yeux expressifs (sourcils selon l'action, clignements, spirales quand assommé), des joues roses, une ombre de contact au sol et une inclinaison dans la course.
---renderer/i18n/fr.ts.strict: true, pas de any sans commentaire justificatif.shared/ dès que possible : c'est ce qui se teste.shared/terrain/ et shared/maps/ est couverte par des tests vitest.shared/constants.ts ou dans les données (JSON).feat(terrain): ..., fix(net): ..., chore: ....contextIsolation: true, nodeIntegration: false. Le preload expose une API minimale et typée (window.lan.discover(), window.lan.host(), etc.).
---Chaque étape doit être jouable avant de passer à la suivante.
gateau-etages (avec un PNG de test).carve).Fait en avance (choix de l'équipe) : le serveur tourne déjà dans un utilityProcess avec WebSocket, msgpackr et interpolation (le renderer ne doit pas porter la simulation, §3). Le Castor Glouton (M5) est jouable, avec un écran titre minimal (pseudo et choix du personnage), une première passe de DA procédurale (textures de matériaux, contour feutre, intérieur des trous, persos animés, onomatopées) et un décor de cuisine.
Flood-fill vers l'ancre Assiette, FallingChunk, réintégration du bloc immobile, écrasement.
Terminé quand : creuser sous un étage le fait tomber et l'étage se pose proprement.
État : implémenté et testé (détachement, chute, réintégration, écrasement, cohérence serveur/clients). Recherche en trois phases (largeur, profondeur vers le bas, puis remplissage par lignes), une seule recherche par tick, mémoire de travail partagée : voir §5 pour les mesures. Le décrochage d'un étage entier reste à ~5 ms par tick, au-dessus des 2 ms.
Serveur dans un utilityProcess, mode headless, WebSocket, protocole msgpackr.
Découverte UDP, lobby (liste des parties, choix du pseudo, prêt).
Inputs vers le serveur, snapshots et ops vers les clients, interpolation.
Test de cohérence : hash du bitmap comparé entre serveur et clients toutes les 5 secondes, avec un log d'erreur en cas d'écart.
Terminé quand : 4 PC en LAN creusent le même gâteau sans aucune différence.
État : implémenté. Le serveur s'annonce sur chaque sous-réseau et en local. L'écran « Les goûters du coin » liste les parties, permet d'en créer une ou d'en rejoindre une par adresse. Pendant l'échauffement, chacun se déclare prêt ; quand tout le monde l'est, un compte à rebours de 3 s lance la partie sur une carte neuve. Le hash est vérifié toutes les 5 s et un écart est loggé côté serveur. Testé avec plusieurs clients en tests automatiques et avec 2 instances sur le même PC. Reste à valider sur 4 vrais PC (pare-feu Windows à autoriser au premier lancement).
Cerise, score, crème qui monte, cuillère de réapparition, objets, manches, partie, écran de score.
Terminé quand : une partie complète se joue du lobby aux trophées.
État : implémenté.
server/stats.ts) : Le Glouton, Le Kamikaze, Le Promoteur Immobilier, Pas Sa Faute, L'Artificier, Le Cueilleur.Reste en M7 : le rattrapage (le dernier choisit la carte et impose un personnage).
Castor Glouton (déjà jouable depuis M1), Mamie Tricot, Pyromanchot (le trio creuser / construire / exploser).
Écran de sélection des personnages.
État : implémenté.
WOOL_BURN_RADIUS_FACTOR fois son rayon.Écran de sélection : une fiche par personnage (attaque, spécial, défaut) avec des barres Vitesse / Saut / Poids lues dans character.json.
Loader validé, rotation configurable dans le lobby, choix par le dernier, modifiers.
Cartes mille-feuille et piece-montee en plus de gateau-etages.
État : implémenté.
assets/maps/, modificateurs inconnus compris. Une carte invalide est écartée avec un message explicite dans le journal.server/rotation.ts). Sans choix, la rotation décide.server/modifiers/) : wind (rafales latérales, indicateur dans le bandeau) et random-cracks (fissures en zigzag dans le biscuit). Ils ne jouent que pendant les manches. Depuis M7, random-cracks est sur la Tarte aux Fruits et wind sur la Pile de Crêpes, comme prévu au §6.Cartes : générées par npm run gen:maps. Sur la Pièce Montée, chaque chou n'est collé qu'à celui du dessous, donc en retirer un fait tomber toute la colonne au-dessus.
Les 5 autres personnages, les 3 autres cartes, le rattrapage complet, les trophées, la DA finale, l'audio, l'éditeur de cartes, l'installeur Windows.
Fait :
random-cracks) et la Pile de Crêpes (fines couches, pile qui penche, wind).SOAK_SAG_TIME_S.cue commun, prêt pour l'audio.shared/ ou server/, lance npm test et npm run lint.