first commit
This commit is contained in:
commit
e9f8de4b90
7 changed files with 2129 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
bin/
|
||||||
|
SharedBin/
|
||||||
|
TheRuneMaker-main/
|
||||||
|
TopDownVoxelsEngine-master/
|
||||||
|
Pratiques de code.sln
|
||||||
150
README.md
Normal file
150
README.md
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# Programmation: Philosophie et pratiques universelles
|
||||||
|
|
||||||
|
Ce fichier est le point d'entrée pour comprendre mon approche de la programmation. Il explicite la philosophie qui guide mes décisions de code, les réflexes qui s'appliquent partout, et renvoie vers les fichiers spécialisés pour les détails.
|
||||||
|
|
||||||
|
Contexte : je suis développeur full-stack et développeur de jeux vidéo. Ces pratiques se sont forgées sur des projets commerciaux (Neoproxima chez Lonestone Studio, City Invaders, Manitou Lift and Drift), des projets indés (A Time Paradox, TheRuneMaker), du modding long-terme (mod Minecraft « Capsule », 8.6M+ téléchargements sur CurseForge) et sur les enseignements que je donne en parallèle (back-end, front-end, jeu vidéo, algorithmique). Des articles plus détaillés sur la plupart de ces sujets vivent sur [samuel-bouchet.fr](https://samuel-bouchet.fr) et les cours associés sur [teachings.samuel-bouchet.fr](https://teachings.samuel-bouchet.fr).
|
||||||
|
|
||||||
|
## Index des pratiques spécifiques
|
||||||
|
|
||||||
|
- [`csharp.md`](csharp.md) — idiomes C#, nullable reference types, async, naming, serialisation
|
||||||
|
- [`unity.md`](unity.md) — MonoBehaviour, asmdef, séparation Engine/Client, cycle de vie, éditeur
|
||||||
|
- [`reactive-state.md`](reactive-state.md) — Signal/Observable, ConnectedBehaviour, event sourcing, GameEvent
|
||||||
|
- [`code-review.md`](code-review.md) — revues, pull requests, commits, collaboration
|
||||||
|
- [`a-trier.md`](a-trier.md) — sujets qui ne sont pas encore rangés ailleurs (tests, Docker, documentation LLM-friendly, networking, etc.)
|
||||||
|
|
||||||
|
## Philosophie en une page
|
||||||
|
|
||||||
|
### 1. L'architecture est un outil de testabilité, pas une fin en soi
|
||||||
|
|
||||||
|
Une bonne architecture, c'est une architecture qui rend le code **testable sans ouvrir l'éditeur**. Concrètement, je sépare la logique pure de la présentation :
|
||||||
|
|
||||||
|
- La logique métier (`Engine/`) est un projet C# pur, sans aucune dépendance à l'éditeur (Unity, Godot, UE). Elle peut être compilée en netstandard, exécutée en ligne de commande, testée avec NUnit ou xUnit.
|
||||||
|
- La présentation (`Client/`) est éditeur (Unity, Godot, UE, CLI), réactive, passive. Elle lit l'état et émet des intentions, elle n'en mute jamais (sinon des états de pure présentation).
|
||||||
|
- Les tests ne se lancent pas dans l'éditeur, ils se lancent avec `dotnet test`. Si un bug n'est pas reproductible sans l'éditeur, c'est que la séparation est cassée.
|
||||||
|
|
||||||
|
Cette séparation permet de faire tourner un bot, un agent LLM, un replay, un serveur autoritaire, le tout avec exactement le même code de logique. Une logique qui doit booter un éditeur éditeur (Unity, Godot, UE) pour être exécutée est prisonnière et lente.
|
||||||
|
|
||||||
|
### 2. Source de vérité unique, explicite, documentée
|
||||||
|
|
||||||
|
Pour chaque donnée, je désigne une source de vérité :
|
||||||
|
|
||||||
|
- Les données statiques (définitions de runes, de blocs, de donjons) vivent dans des bases de données statiques ou des factories dans le code directement. (Unity only) J'évite les ScriptableObjects car ils ne sont pas utilisables dans un environnement hors éditeur (serveur, tests).
|
||||||
|
- L'état dynamique vit dans un `GameState` minimal (pas de redondance ou de donnée calculée), serialisable, monolithique, éventuellement observable (selon besoins du projet, Cf. `reactive-state.md`).
|
||||||
|
- Les données dérivées (UI, graphes rendus, animations) sont calculées à partir de l'état, jamais stockées à côté, et invalidées gràce à un système d'observabilité.
|
||||||
|
|
||||||
|
|
||||||
|
### 3. Mutations canalisées : command pattern + event sourcing
|
||||||
|
|
||||||
|
Mon approche rejoint le pattern **Black Box Sim** formalisé par Brian Cronin : la simulation ne sait rien de l'extérieur, elle reçoit des `WorldCommand`, mute son état, et émet des `WorldEvent`. Input → WorldCommand → Sim → WorldEvent → Présentation. Je l'utilisais déjà (Neoproxima, Manitou Lift and Drift, TheRuneMaker) mais le vocabulaire clarifie les discussions.
|
||||||
|
|
||||||
|
Je ne laisse jamais un composant muter l'état directement. Toute mutation passe par un événement typé `WorldCommand` qui :
|
||||||
|
|
||||||
|
- peut être validé avant application (`AssertApplicationConditions`),
|
||||||
|
- produit une trace (`WorldEvent` ou modification du state observable) qui doit contenir exhaustivement les infos nécessaires à la présentation,
|
||||||
|
- est atomique (un `lock` empêche d'appeler un `WorldCommand` dans un `WorldCommand`),
|
||||||
|
- est serialisable et donc transportable sur le réseau ou dans un save.
|
||||||
|
|
||||||
|
Si je peux écrire `state.Gold -= 10` depuis une UI, c'est que j'ai raté quelque chose. Ça doit être un `BuyFromShopWorldCommand` appliqué via `state.ApplyEvent(...)`. Voir [`reactive-state.md`](reactive-state.md) pour le détail.
|
||||||
|
|
||||||
|
|
||||||
|
### 4. Sécurité par défaut : le compilateur est mon allié
|
||||||
|
|
||||||
|
- **Nullable reference types activés partout** (`<Nullable>enable</Nullable>` ou `csc.rsp` avec `-nullable`). Chaque `?` et chaque `!` est une décision consciente, pas un oubli.
|
||||||
|
- **Readonly / immutable quand possible** : `readonly` sur les champs, `init` sur les propriétés, records pour les value objects.
|
||||||
|
- **Interfaces comme contrats** : `IGameEvent`, `INetworkMessage`, `IUpdatable<T>` ne sont pas là pour faire beau, elles sont là pour que le compilateur vérifie l'usage.
|
||||||
|
- **Attributs de validation runtime** pour ce que le compilateur ne peut pas vérifier : `[Required]` (Odin/Artificetoolkit) sur les références injectées via inspecteur, Odin Validator pour les scènes.
|
||||||
|
|
||||||
|
Je préfère une erreur à la compilation qu'une erreur au lancement, et une erreur au lancement qu'une erreur en production.
|
||||||
|
|
||||||
|
### 5. Explicite > implicite
|
||||||
|
|
||||||
|
C'est une règle générale qui se décline partout :
|
||||||
|
|
||||||
|
- **Nommer pour être lu** : un nom long et clair vaut mieux qu'un nom court et cryptique. `PickNecklaceStringGameEvent` est verbeux mais ambigu pour personne.
|
||||||
|
- **Conventions strictes et visuelles** : public `CamelCased`, privé `_camelCased`. L'underscore est un signal visuel de "interne, ne pas toucher depuis l'extérieur".
|
||||||
|
- **Documenter les intentions, pas les mécaniques** : le code dit *quoi*, le commentaire dit *pourquoi*. XML doc (`///`) sur les APIs publiques, commentaires en prose sur la logique métier non triviale.
|
||||||
|
- **Pas de magic numbers, pas de magic strings** : `const` nommées, enums, factories.
|
||||||
|
- **État explicitement observable** : un champ `Signal<T>` est plus clair qu'un champ + un événement `OnChanged` à câbler à la main.
|
||||||
|
|
||||||
|
### 6. Lisibilité et simplicité priment
|
||||||
|
|
||||||
|
Extrait de mes propres guidelines : *"Code should be as simple as possible, performant, robust, and readable."* Dans cet ordre, avec une subtilité : **simple et lisible d'abord**, performant quand c'est mesuré, robuste par construction.
|
||||||
|
|
||||||
|
Concrètement :
|
||||||
|
|
||||||
|
- Je préfère `public string Name;` à `public string Name { get; set; }` quand les accessors n'apportent rien.
|
||||||
|
- J'évite les abstractions prématurées. Une interface qui n'a qu'une seule implémentation est une interface à supprimer, sauf si elle existe pour la testabilité ou le mock.
|
||||||
|
- J'évite les patterns "à la Java" (abstract factory abstract builder, etc.). Un constructor et un `static readonly` vont plus loin 90% du temps.
|
||||||
|
- Je garde les fichiers courts, les classes responsables d'une chose, les méthodes qui tiennent à l'écran.
|
||||||
|
|
||||||
|
### 7. Déterminisme pour pouvoir déboguer
|
||||||
|
|
||||||
|
Un bug qu'on ne peut pas reproduire est un bug qu'on ne peut pas corriger. Je construis mes systèmes pour qu'à entrée identique, la sortie soit identique :
|
||||||
|
|
||||||
|
- **RNG explicite et seedée** : le RNG est passé dans les fonctions, jamais un `Random.Range` global. Les logs et les replays exposent la seed.
|
||||||
|
- **Ordre de résolution déterministe** : tri topologique d'un DAG (runes), tick ordonné (combat), pipeline à étapes nommées (`RECEIVE → GENERATE → TRANSFORM → AMPLIFY`).
|
||||||
|
- **Event sourcing** : rejouer la liste d'événements depuis le début doit reproduire exactement le même état.
|
||||||
|
- **MessagePack avec Union** pour le versioning : une sauvegarde d'il y a 6 mois doit pouvoir être rechargée sans perdre de données.
|
||||||
|
|
||||||
|
### 8. Le bon outil pour la bonne couche
|
||||||
|
|
||||||
|
Je ne suis pas dogmatique sur la techno, mais j'ai des choix qui s'enchaînent bien :
|
||||||
|
|
||||||
|
- **UniTask** plutôt que `Task` côté Unity — zéro-allocation, intégration propre avec le PlayerLoop, cancellation via `GetCancellationTokenOnDestroy()`.
|
||||||
|
- **MessagePack** pour la sérialisation — binaire, rapide, supporte Union pour le versioning.
|
||||||
|
- **TinkStateSharp** (`Signal`, `SignalList`, `SignalDictionary`) pour l'état réactif — observables typées, `AutoRun` pour auto-subscribe.
|
||||||
|
- **DOTween Pro** pour le tweening d'animations, combiné à TinkStateSharp dans le pattern « animation queue » décrit dans [`reactive-state.md`](reactive-state.md).
|
||||||
|
- **Moq + Castle.Core** pour les mocks en tests unitaires (voir [`csharp.md`](csharp.md) — c'est le setup que j'utilise pour Neoproxima).
|
||||||
|
- **Odin / Artificetoolkit** pour la validation éditeur.
|
||||||
|
- **Docker** pour déploiement serveur, EntityFramework Core pour la DB, .NET 6+ pour le serveur, netstandard 2.1 pour la couche partagée.
|
||||||
|
- **Unity Cloud Build + fastlane** quand iOS est une cible (évite le Mac dédié) — voir [`a-trier.md`](a-trier.md).
|
||||||
|
- **Rider** comme IDE — les `.DotSettings` versionnés encodent les conventions d'équipe.
|
||||||
|
|
||||||
|
Le point commun : chaque outil gère bien son rôle et s'arrête là.
|
||||||
|
|
||||||
|
### 9. Documentation qui suit le code
|
||||||
|
|
||||||
|
Je documente **dans le dépôt**, pas ailleurs. Typiquement :
|
||||||
|
|
||||||
|
- Un `README.md` pour le démarrage et la structure.
|
||||||
|
- Un `CLAUDE.md` qui décrit l'architecture pour un agent (humain ou LLM) qui débarque : où sont les sources de vérité, quels sont les patterns clefs, comment lancer les tests, quelles sont les conventions. Ce fichier est aussi de la doc pour moi dans 6 mois.
|
||||||
|
- Un `GDD.md` / `GAME_DESIGN.md` pour les décisions de gameplay.
|
||||||
|
- Des diagrammes Mermaid inline pour les state machines et les flux — ils vivent dans le markdown, ils se mettent à jour quand le code change.
|
||||||
|
- Des specs de protocole (CLI LLM, format de messages) écrites noir sur blanc.
|
||||||
|
|
||||||
|
**Principe** : si un nouveau développeur (ou LLM) ne peut pas comprendre l'architecture en 15 minutes avec le `CLAUDE.md`, c'est le `CLAUDE.md` qui est à revoir, pas le développeur.
|
||||||
|
|
||||||
|
### 10. Penser accessibilité par les agents
|
||||||
|
|
||||||
|
Depuis quelques projets, je conçois en gardant en tête qu'un LLM peut avoir à lire, modifier, ou piloter mon code :
|
||||||
|
|
||||||
|
- Les `CLAUDE.md` sont écrits pour les agents autant que pour les humains.
|
||||||
|
- Quand un jeu peut tourner en headless (`LlmRunner`), je l'expose via un protocole stdin/stdout bien délimité (`---STATE---`, `---ACTIONS---`, `---PROMPT---`).
|
||||||
|
- Les logs sont structurés et utiles, pas du spam.
|
||||||
|
- Les seeds, les timeouts, les limites d'actions sont explicites pour que l'exécution soit reproductible et bornée.
|
||||||
|
|
||||||
|
C'est un bon forçage : ce qui est lisible par un LLM est lisible par un humain.
|
||||||
|
|
||||||
|
## Les réflexes au quotidien
|
||||||
|
|
||||||
|
Quelques questions que je me pose avant de commit :
|
||||||
|
|
||||||
|
- **Est-ce que ça passe sans Unity ?** Si la logique dépend d'un `GameObject`, est-ce qu'elle devrait ?
|
||||||
|
- **Est-ce que ça mute l'état directement ?** Si oui, ça devrait être un GameEvent.
|
||||||
|
- **Est-ce qu'il y a un nullable warning supprimé sans raison ?** Si oui, c'est une dette.
|
||||||
|
- **Est-ce que le nom reflète l'intention ?** Si j'hésite à le lire à voix haute, c'est mauvais signe.
|
||||||
|
- **Est-ce que je duplique une source de vérité ?** Un `state.Foo` plus un champ local qui le cache = bug potentiel.
|
||||||
|
- **Est-ce qu'il y a un test qui prouve que ça marche ?** Si non, est-ce que je peux en écrire un sans lancer Unity ?
|
||||||
|
- **Est-ce qu'un LLM pourrait comprendre ce fichier tout seul ?** Si non, manque-t-il un commentaire, un lien, un doc ?
|
||||||
|
|
||||||
|
## Les anti-patterns que je refuse
|
||||||
|
|
||||||
|
- Les mutations d'état depuis la couche présentation.
|
||||||
|
- Les singletons statiques mutables (hors DI/factories contrôlées).
|
||||||
|
- Les `Find*` appelés en boucle dans `Update()`.
|
||||||
|
- Les tests qui dépendent de l'éditeur Unity quand ils pourraient être purement C#.
|
||||||
|
- Les `#if UNITY_EDITOR` dans la logique métier (tolérés dans du code de debug/assertion).
|
||||||
|
- Les commentaires qui paraphrasent le code au lieu d'expliquer le pourquoi.
|
||||||
|
- Les classes "Manager" fourre-tout.
|
||||||
|
- Les tâches async sans cancellation token quand elles survivent à un lifecycle.
|
||||||
|
- Les breaking changes silencieux sur un format sérialisé (saves, réseau) — il faut versionner.
|
||||||
513
a-trier.md
Normal file
513
a-trier.md
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
# a-trier.md — Sujets à redistribuer plus tard
|
||||||
|
|
||||||
|
Ce fichier est volontairement un dépotoir organisé. Il contient des pratiques qui n'ont pas encore trouvé leur place dans [`coding.md`](coding.md), [`csharp.md`](csharp.md), [`unity.md`](unity.md), [`reactive-state.md`](reactive-state.md) ou [`code-review.md`](code-review.md), mais qui méritent d'être formalisées.
|
||||||
|
|
||||||
|
Quand un paragraphe devient trop gros ici, il est temps de le promouvoir dans son propre markdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests : philosophie et organisation
|
||||||
|
|
||||||
|
### Ce qu'on teste, ce qu'on ne teste pas
|
||||||
|
|
||||||
|
Je teste par ordre de priorité :
|
||||||
|
|
||||||
|
1. **La logique métier pure** (Engine, Shared) — obligatoire. Chaque GameEvent a au moins un test. Chaque règle de balance non triviale a son test. Les calculs déterministes (pipeline de bonus, résolution de combat, tri topologique) ont leurs tests.
|
||||||
|
2. **Les invariants d'état** — qu'un GameState après N events reste cohérent. Tests de fixtures partagées qui valident qu'on ne peut pas finir dans un état interdit.
|
||||||
|
3. **Les formats de sérialisation** — un round-trip `Serialize → Deserialize` sur des samples représentatifs, plus des tests de rétrocompatibilité pour les Union versions.
|
||||||
|
4. **Le code de networking** — validation serveur (que les événements impossibles sont rejetés), rollback client.
|
||||||
|
|
||||||
|
Je teste **peu ou pas** :
|
||||||
|
|
||||||
|
- Les Display Unity (couverts implicitement par le fait que l'état est testé et que l'UI est passive).
|
||||||
|
- Les getters/setters triviaux.
|
||||||
|
- Le code généré (MessagePack formatters, migrations EF).
|
||||||
|
- Le code tiers.
|
||||||
|
|
||||||
|
### Structure de test
|
||||||
|
|
||||||
|
- Projet `Tests/` séparé, référence l'Engine/Shared en tant que projet C# pur.
|
||||||
|
- Lancement en `dotnet test`, jamais via Unity Test Runner pour la logique métier.
|
||||||
|
- Fixtures partagées dans une classe `Fixtures` statique : `CreateInitialState`, `TestRunes`, `ShallowPool`.
|
||||||
|
- Convention de nommage : `MethodOrEvent_Scenario_ExpectedResult`.
|
||||||
|
- Structure AAA : Arrange, Act, Assert séparés par commentaires.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[TestFixture]
|
||||||
|
public class GameEventTests {
|
||||||
|
[Test]
|
||||||
|
public void PickLootGameEvent_AddsGoldToPlayer() {
|
||||||
|
// Arrange
|
||||||
|
var state = Fixtures.CreateInitialState(Fixtures.ShallowPool, Fixtures.TestRunes);
|
||||||
|
state.CurrentRun.CurrentPhase = Phase.Encounter;
|
||||||
|
var loot = new Loot(50, false);
|
||||||
|
state.CurrentRun.LootBox.Add(loot);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
new PickLootGameEvent(loot).Apply(state, null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(state.CurrentRun.Gold, Is.EqualTo(initialGold + 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtres de test
|
||||||
|
|
||||||
|
Pour itérer rapidement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test --filter "FullyQualifiedName~CombatTests"
|
||||||
|
dotnet test --filter "FullyQualifiedName~CombatTests.TestBasicCombat_HeroVsTwoEnemies"
|
||||||
|
```
|
||||||
|
|
||||||
|
Je profite de `TestCase` et `TestCaseSource` (NUnit) pour grouper les variations.
|
||||||
|
|
||||||
|
### Tests Unity Play Mode
|
||||||
|
|
||||||
|
Rares. Uniquement pour ce qui est intrinsèquement Unity (animations chainées, physics, prefabs). Un test Play Mode qui pourrait être un test NUnit est un test mal rangé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation projet : README, CLAUDE.md, GDD
|
||||||
|
|
||||||
|
Je distingue trois types de docs :
|
||||||
|
|
||||||
|
### `README.md`
|
||||||
|
|
||||||
|
- Pitch du projet en 2-3 lignes.
|
||||||
|
- Prérequis techniques (.NET, Unity, Node, outils CLI).
|
||||||
|
- Comment cloner et lancer (`git config core.symlinks`, `NuGet > Restore`, `dotnet tool install`, `mpc -i ...`).
|
||||||
|
- Structure de dossiers haut niveau.
|
||||||
|
- Liens vers les docs plus détaillées.
|
||||||
|
|
||||||
|
### `CLAUDE.md` (ou équivalent agent)
|
||||||
|
|
||||||
|
Écrit pour qu'un agent (humain ou LLM) débarquant sur le projet puisse contribuer. Contient :
|
||||||
|
|
||||||
|
- **Project Overview** : une ou deux phrases sur ce que fait le projet.
|
||||||
|
- **Commands** : les commandes clés (build, test, run, generation).
|
||||||
|
- **Architecture** : la structure de dossiers annotée, les couches, les sources de vérité.
|
||||||
|
- **Core Patterns** : les patterns centraux (GameEvent, ConnectedBehaviour, pipeline de bonus, etc.).
|
||||||
|
- **Workflow diagrams** : mermaid state diagrams pour les machines d'état complexes.
|
||||||
|
- **Coding Standards** : les conventions spécifiques au projet (naming, attributs, composition).
|
||||||
|
- **Key Dependencies** : la liste des libs et pourquoi elles sont là.
|
||||||
|
|
||||||
|
Une règle : si un agent ne peut pas comprendre l'architecture en 15 minutes via ce fichier, c'est le fichier qui est à revoir.
|
||||||
|
|
||||||
|
### `GDD.md` / `GAME_DESIGN.md` (pour un jeu)
|
||||||
|
|
||||||
|
- Mécaniques de gameplay (combat, progression, économie).
|
||||||
|
- Balance (formules, courbes, constantes).
|
||||||
|
- Systèmes thématiques (arcanes, classes, factions).
|
||||||
|
- Musique / rythme / animation specs quand c'est intégré au gameplay (comme les 120 BPM ternaires de TheRuneMaker).
|
||||||
|
|
||||||
|
### Diagrammes Mermaid
|
||||||
|
|
||||||
|
J'inline les diagrammes dans le markdown. Ils sont :
|
||||||
|
|
||||||
|
- **versionés avec le code** — pas sur Miro ou LucidChart.
|
||||||
|
- **diffables** — un changement de state diagram apparaît clairement en PR.
|
||||||
|
- **mis à jour par celui qui casse l'invariant** — si tu modifies la state machine, tu modifies le diagramme.
|
||||||
|
|
||||||
|
Exemple type pour une state machine de jeu :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Build : StartRunGameEvent
|
||||||
|
state Build {
|
||||||
|
[*] --> Deckbuilding
|
||||||
|
Deckbuilding --> Deckbuilding : RerollShopGE / BuyFromShopGE / MoveSlotGE
|
||||||
|
Deckbuilding --> PickSpec : PendingSpecialisationLoot?
|
||||||
|
}
|
||||||
|
Build --> Encounter : StartCombatGE
|
||||||
|
```
|
||||||
|
|
||||||
|
Autre usage : flux client/serveur, pipeline de build, hiérarchie de classes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocoles CLI LLM-friendly
|
||||||
|
|
||||||
|
Quand un jeu / système peut tourner en headless, je l'expose via un protocole stdin/stdout structuré pour qu'un LLM (ou un test automatisé) puisse le piloter.
|
||||||
|
|
||||||
|
### Forme typique
|
||||||
|
|
||||||
|
Blocs délimités explicitement :
|
||||||
|
|
||||||
|
```
|
||||||
|
---STATE---
|
||||||
|
Phase: Build
|
||||||
|
String: 0 Encounter: 0
|
||||||
|
Lives: 3 Gold: 15
|
||||||
|
Hero: HP=100/100 ATK=10
|
||||||
|
Rune Graph:
|
||||||
|
[slot0] FireRune (action=Attack, arcane=Fire, rarity=1) -> [slot3:ActionRune]
|
||||||
|
Effects: +10 Power
|
||||||
|
Bench (2 runes, 3 empty):
|
||||||
|
[b0] EarthRune (action=Value, arcane=Earth, rarity=1)
|
||||||
|
Shop:
|
||||||
|
[0] FrostRune (cost=2, action=Value, arcane=Frost)
|
||||||
|
---END_STATE---
|
||||||
|
|
||||||
|
---ACTIONS---
|
||||||
|
[0] BUY FrostRune (cost=2)
|
||||||
|
[1] PLACE EarthRune -> slot3
|
||||||
|
[2] REROLL (cost=10)
|
||||||
|
[3] FIGHT
|
||||||
|
---END_ACTIONS---
|
||||||
|
|
||||||
|
---PROMPT---
|
||||||
|
Build action
|
||||||
|
Enter a number [0-3]:
|
||||||
|
---END_PROMPT---
|
||||||
|
```
|
||||||
|
|
||||||
|
L'agent répond par un **entier** sur stdin. Simple, testable, loggable.
|
||||||
|
|
||||||
|
### Garde-fous
|
||||||
|
|
||||||
|
Un protocole doit avoir des limites dures :
|
||||||
|
|
||||||
|
- **Timeout** : 60 secondes par run.
|
||||||
|
- **Max actions** : 2000 décisions avant exit forcé.
|
||||||
|
- **Output cap** : 500 KB pour éviter les loops qui spamment.
|
||||||
|
- **Défaut sur EOF** : si stdin ferme, l'agent prend automatiquement l'action `0`.
|
||||||
|
|
||||||
|
Documenté dans le `CLAUDE.md` du projet à côté du protocole.
|
||||||
|
|
||||||
|
### Pourquoi ça vaut le coup
|
||||||
|
|
||||||
|
- Tests de régression automatisés sur des runs complètes.
|
||||||
|
- Démo rapide sans client graphique.
|
||||||
|
- Intégration avec des bots d'évaluation (balance auto-testée).
|
||||||
|
- Forçage architectural : pour qu'un jeu soit pilotable en CLI, il faut que la logique soit déjà séparée du rendu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Networking et multijoueur
|
||||||
|
|
||||||
|
### Strategy de base
|
||||||
|
|
||||||
|
Référence que je suis : [State Synchronization — Gaffer On Games](https://gafferongames.com/post/state_synchronization/). Résumé :
|
||||||
|
|
||||||
|
- Le serveur est l'autorité ultime sur l'état.
|
||||||
|
- Les clients appliquent localement leurs actions **optimistiquement**.
|
||||||
|
- Le serveur valide et re-broadcast ; en cas de désaccord, client rollback + rejoue.
|
||||||
|
- La latence est masquée par l'optimistic update, les divergences sont corrigées discrètement.
|
||||||
|
|
||||||
|
### Types de messages
|
||||||
|
|
||||||
|
Trois familles, toutes serialisées en MessagePack, toutes implémentent `INetworkMessage` :
|
||||||
|
|
||||||
|
- **GameEvent** : mutation bidirectionnelle (peut venir du client ou du serveur), validée par le serveur, applicable optimistiquement côté client.
|
||||||
|
- **Command** : client → serveur, demande d'opération (ex. login, demande de blueprint). Serveur répond par un `AckResponse`.
|
||||||
|
- **Query** : client → serveur, demande de lecture sans side-effect. Serveur répond par la donnée.
|
||||||
|
- **Response** : serveur → client, réponse à une Query.
|
||||||
|
|
||||||
|
Tous les messages dans `Shared/Net/Messages/`. Un seul endroit, serializable, partagé entre client et serveur.
|
||||||
|
|
||||||
|
### Architecture serveur
|
||||||
|
|
||||||
|
Stack typique :
|
||||||
|
|
||||||
|
- **ASP.NET Core** en .NET 6+ (pour .NET Core Web, pour le DI, pour les middlewares).
|
||||||
|
- **EntityFramework Core** pour la DB (SQLite en dev, Postgres en prod).
|
||||||
|
- **MessagePack** côté transport.
|
||||||
|
- **Services** injectés via `IServiceScopeFactory` pour les scopes par requête / par session.
|
||||||
|
- **`UserSessionData` par utilisateur connecté**, indexé par `ushort` short ID.
|
||||||
|
- **Queues thread-safe** (`ConcurrentQueue<InputMessage>`, `ConcurrentQueue<OutputMessage>`) entre la couche réseau et la boucle de jeu.
|
||||||
|
- **PeriodicTimer** pour le tick réseau (1ms) et le backup périodique (5s).
|
||||||
|
- **ServerClock** pour la référence temporelle authoritative.
|
||||||
|
|
||||||
|
Extrait illustratif :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class VoxelsEngineServer {
|
||||||
|
private readonly GameState _state = new();
|
||||||
|
private readonly GameState _stateBackup = new();
|
||||||
|
private readonly ConcurrentDictionary<ushort, UserSessionData> _userSessionData = new();
|
||||||
|
private readonly ConcurrentQueue<InputMessage> _inbox = new();
|
||||||
|
private readonly ConcurrentQueue<OutputMessage> _outbox = new();
|
||||||
|
private readonly ConcurrentDictionary<ChunkKey, Chunk> _dirtyChunks = new();
|
||||||
|
private PeriodicTimer networkingTime = new(TimeSpan.FromMilliseconds(1));
|
||||||
|
private PeriodicTimer backupTimer = new(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versioning des messages
|
||||||
|
|
||||||
|
Obligatoire. Un client ancien doit pouvoir coexister avec un serveur récent, ou au minimum recevoir un message d'erreur clair plutôt qu'une désérialisation cassée. Voir la section MessagePack dans [`csharp.md`](csharp.md) pour le pattern Union.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker et déploiement
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
|
||||||
|
Pour un serveur .NET type, un Dockerfile multi-stage (build + runtime) :
|
||||||
|
|
||||||
|
- Stage 1 : image `sdk` pour restore + build + publish.
|
||||||
|
- Stage 2 : image `aspnet` (runtime seule) avec juste les artefacts et les assets nécessaires.
|
||||||
|
|
||||||
|
Objectif : image finale légère (~100-200 MB), pas de SDK dans l'image de prod.
|
||||||
|
|
||||||
|
### docker-compose
|
||||||
|
|
||||||
|
J'ai au minimum deux fichiers séparés :
|
||||||
|
|
||||||
|
- `docker_compose_xxx.devtest.yml` : pour le staging interne (test, dev partagé).
|
||||||
|
- `docker_compose_xxx.prod.yml` : pour la prod.
|
||||||
|
|
||||||
|
Différences typiques : volumes persistants (saves, DB), variables d'environnement (connection strings, ports), replicas, healthchecks.
|
||||||
|
|
||||||
|
Les secrets ne sont **jamais dans le docker-compose** versionné — variables d'environnement, fichiers `.env` non-commit, ou secret manager.
|
||||||
|
|
||||||
|
### Déploiement client web
|
||||||
|
|
||||||
|
Un build Unity WebGPU + un script `.bat` qui rsync/scp vers le serveur de staging :
|
||||||
|
|
||||||
|
```bat
|
||||||
|
deploy_web_build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Versionné, relu, pas un script bash à part dans le wiki. Si c'est reproductible, c'est dans le repo.
|
||||||
|
|
||||||
|
### Unity Cloud Build pour iOS
|
||||||
|
|
||||||
|
Quand iOS est une cible et que je ne veux pas maintenir un Mac de build :
|
||||||
|
|
||||||
|
- **Unity Cloud Build (Build Automation)** configure un target iOS qui tourne sur les machines mac fournies par Unity.
|
||||||
|
- Clé **App Store Connect API** (Key ID + Issuer ID + `.p8`) convertie en JSON fastlane-compatible et stockée en variable d'environnement Unity Cloud.
|
||||||
|
- Script **post-build** qui appelle `fastlane deliver` pour pousser l'IPA vers App Store Connect.
|
||||||
|
- Piège : Unity réécrit les `\n` de la clé privée en espaces à l'injection, il faut restaurer les newlines dans le script avant de passer la clé à fastlane.
|
||||||
|
- Gestion de version : un `BuildVersion` ScriptableObject dérive `Major.Minor.Patch` depuis les tags git (`v1.0`), et le build number depuis le nombre de commits.
|
||||||
|
- Coût : environ 1 €/build iOS — OK pour une cadence humaine (quelques pushs par semaine).
|
||||||
|
|
||||||
|
Voir [samuel-bouchet.fr/posts/2025-03-24-unity-automated-appstoreconnect-upload](https://samuel-bouchet.fr/posts/2025-03-24-unity-automated-appstoreconnect-upload/) pour le détail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Serialization : au-delà de MessagePack
|
||||||
|
|
||||||
|
Même si MessagePack est mon défaut, je choisis selon le contexte :
|
||||||
|
|
||||||
|
- **MessagePack binaire** : réseau, saves, AOT-compatible, versioning via Union. Défaut pour presque tout.
|
||||||
|
- **JSON** : config humaine éditable (`appsettings.json`), API externe, debug. Newtonsoft pour Unity, System.Text.Json côté .NET pur.
|
||||||
|
- **`.bytes` files** : données pré-calculées (layouts de grid) serializées en binaire, chargées via `ResourceLoader` ou `File.ReadAllBytes`. Plus rapides que JSON, moins lisibles — uniquement pour des données générées.
|
||||||
|
- **`.dblog` / `.dbconf`** : formats spécifiques à un outil (DebugView++), pas du code maison.
|
||||||
|
|
||||||
|
Règle : chaque format sérialisé a sa raison documentée dans le `CLAUDE.md` du projet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DebugView++ et logs structurés
|
||||||
|
|
||||||
|
Sur les projets serveur ou multijoueur, je configure **DebugView++** avec un `.dblog` versionné pour visualiser les logs en temps réel sur Windows. Le `.dbconf` contient la configuration des filtres et colonnes.
|
||||||
|
|
||||||
|
Les logs eux-mêmes suivent quelques règles :
|
||||||
|
|
||||||
|
- **Préfixes systématiques** : `[Server]`, `[Client]`, `[Net]`, `[GameEvent]` — permet le filtrage rapide.
|
||||||
|
- **Niveaux explicites** : `Debug`, `Info`, `Warn`, `Error` — jamais de `Console.WriteLine` anonyme en prod.
|
||||||
|
- **Contexte dans le message** : IDs, counters, valeurs observées. Un log "something went wrong" n'a aucune valeur.
|
||||||
|
|
||||||
|
Côté Unity, `Debug.Log` + un filtre custom de niveau dans la console. Je désactive le spam avec `[Conditional("UNITY_EDITOR")]` ou via niveaux runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outillage : Rider, DotSettings, hot-reload
|
||||||
|
|
||||||
|
### Rider comme IDE
|
||||||
|
|
||||||
|
- `.DotSettings` versionnés : conventions de format, nommage, inspections désactivées, patterns de refactor.
|
||||||
|
- Un fichier `.sln.DotSettings` pour les prefs de la solution.
|
||||||
|
- Des `.csproj.DotSettings` par projet quand il y a des règles spécifiques.
|
||||||
|
|
||||||
|
Bénéfice : n'importe qui clone et a les bonnes règles dès le premier `Ctrl+Alt+L`. Pas de débat de formatage en PR.
|
||||||
|
|
||||||
|
### Unity Hot Reload
|
||||||
|
|
||||||
|
Quand possible. Permet de modifier du code pendant le play mode sans recompile complète. Utile pour itérer rapidement sur la balance ou l'UI.
|
||||||
|
|
||||||
|
### Commandes récurrentes
|
||||||
|
|
||||||
|
Je garde à portée dans le `README.md` / `CLAUDE.md` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build et test serveur
|
||||||
|
dotnet build
|
||||||
|
dotnet test
|
||||||
|
dotnet test --filter "FullyQualifiedName~CombatTests"
|
||||||
|
|
||||||
|
# MessagePack AOT generation
|
||||||
|
mpc -i D:\projets\TheRuneMaker\Assets -o "Assets/Client/Scripts/MessagePackGenerated.cs"
|
||||||
|
|
||||||
|
# EF migrations
|
||||||
|
dotnet tool install --global dotnet-ef
|
||||||
|
dotnet ef migrations add <Name>
|
||||||
|
dotnet ef database update
|
||||||
|
dotnet ef database drop
|
||||||
|
|
||||||
|
# LLM runner
|
||||||
|
cd LlmRunner
|
||||||
|
dotnet run -- [seed]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patterns de performance
|
||||||
|
|
||||||
|
Dans l'ordre : **mesurer, optimiser le hot path, laisser le reste lisible**.
|
||||||
|
|
||||||
|
### Mesurer d'abord
|
||||||
|
|
||||||
|
- `Stopwatch` autour des sections suspectes avant de toucher au code.
|
||||||
|
- Unity Profiler pour les frames hiccups.
|
||||||
|
- `BenchmarkDotNet` côté .NET pur quand un algo a vraiment besoin d'être micro-optimisé.
|
||||||
|
|
||||||
|
### Anti-allocations
|
||||||
|
|
||||||
|
Quand c'est un hot path identifié :
|
||||||
|
|
||||||
|
- `for` plutôt que `foreach` sur les collections concrètes.
|
||||||
|
- `List<T>.GetEnumerator()` ne boxe pas, mais `IEnumerable<T>.GetEnumerator()` si.
|
||||||
|
- ZLinq ou LINQ manuel quand c'est critique.
|
||||||
|
- Pool d'objets pour les struct fréquentes.
|
||||||
|
- `Span<T>` / `Memory<T>` / `stackalloc` pour les buffers temporaires.
|
||||||
|
- `StringBuilder` pour les concaténations en boucle.
|
||||||
|
|
||||||
|
### Mais : ne pas optimiser avant mesure
|
||||||
|
|
||||||
|
95% du code n'est pas hot-path. La lisibilité et la testabilité priment jusqu'au moment où le profiler montre un problème réel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cancellation et gestion de durée de vie
|
||||||
|
|
||||||
|
Pattern récurrent que je réutilise :
|
||||||
|
|
||||||
|
- `CancellationTokenSource` au niveau d'un lifecycle (GameObject, session, session réseau).
|
||||||
|
- `CancellationToken` passé à toutes les tâches async qui peuvent dépasser ce lifecycle.
|
||||||
|
- `cancellationToken.Register(disposable.Dispose)` pour chainer des disposables sur le cancel.
|
||||||
|
- `gameObject.GetCancellationTokenOnDestroy()` pour lier une tâche au destroy Unity.
|
||||||
|
- `GetCancellationTokenOnEnable()` / `GetCancellationTokenOnDisable()` pour les toggles actifs/inactifs.
|
||||||
|
|
||||||
|
Helper `DisposeOnReset(IDisposable disposable)` dans `ConnectedBehaviour` pour inscrire automatiquement un cleanup au prochain reset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Snippets utiles que je réutilise
|
||||||
|
|
||||||
|
### Throttle
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static Action CreateThrottled(Action action, UniTask delayTask) {
|
||||||
|
bool throttling = false;
|
||||||
|
async void ThrottledEventHandler() {
|
||||||
|
if (throttling) return;
|
||||||
|
action();
|
||||||
|
throttling = true;
|
||||||
|
await delayTask.SuppressCancellationThrow();
|
||||||
|
throttling = false;
|
||||||
|
}
|
||||||
|
return ThrottledEventHandler;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic EventBus dispatcher
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public EventDispatcher<T> For<T>() {
|
||||||
|
if (_all.TryGetValue(typeof(T), out EventDispatcher obs))
|
||||||
|
return (EventDispatcher<T>) obs;
|
||||||
|
var newObs = new EventDispatcher<T>();
|
||||||
|
_all.Add(typeof(T), newObs);
|
||||||
|
return newObs;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Broadcast filtré (côté serveur)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void BroadcastBut<T>(Func<T, bool> shouldSkip, T message)
|
||||||
|
where T : INetworkMessage {
|
||||||
|
foreach (var session in _userSessionData.Values) {
|
||||||
|
if (shouldSkip(message)) continue;
|
||||||
|
_outbox.Enqueue(new OutputMessage(session.ShortId, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VFX fullscreen URP : distortion, secondary camera, RenderTexture
|
||||||
|
|
||||||
|
Pattern utilisé sur *Powered by Geometry* (GMTK 2024) pour un effet de distortion de projectiles, et réutilisable pour tout effet fullscreen en URP (Unity 6).
|
||||||
|
|
||||||
|
### Le problème
|
||||||
|
|
||||||
|
Depuis URP, `OnRenderImage` n'existe plus. Les effets fullscreen passent par un **FullScreenPassRendererFeature** configuré dans l'asset Renderer (`Renderer2DData` en 2D, équivalent en 3D).
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
- Ajouter un `FullScreenPassRendererFeature` sur le Renderer asset.
|
||||||
|
- **Injection Point** : `Before Rendering Post Processing` — en amont du bloom, sinon la distortion produit des artefacts dans les zones bloomées.
|
||||||
|
- **Fetch Color Buffer** : activé pour accéder aux pixels déjà rendus.
|
||||||
|
- **Pass Material** : un material shader qui consomme le `URP Sample Buffer` (node `BlitSource` comme entrée).
|
||||||
|
|
||||||
|
### Pattern « Secondary Camera + RenderTexture »
|
||||||
|
|
||||||
|
Pour localiser l'effet (distortion seulement autour des projectiles, pas sur toute l'image) :
|
||||||
|
|
||||||
|
1. Créer un **layer** `Secondary`.
|
||||||
|
2. Une **caméra secondaire** configurée avec un Renderer dédié sans post-processing, qui ne voit que le layer `Secondary`, et qui sort vers un `RenderTexture` asset.
|
||||||
|
3. La main camera exclut `Secondary` de son culling mask.
|
||||||
|
4. Le shader de distortion consomme ce `RenderTexture` en paramètre `_Secondary`.
|
||||||
|
|
||||||
|
### Encodage de la distortion
|
||||||
|
|
||||||
|
Chaque projectile sur le layer `Secondary` a deux éléments :
|
||||||
|
|
||||||
|
- **ProjectileMask** : sprite noir de la taille du projectile (→ pas de distortion là-dessus).
|
||||||
|
- **Shrink** : sprite de distortion, ~3x plus large que le projectile, qui encode les offsets UV dans les canaux R/G :
|
||||||
|
- `R = 0.5` et `G = 0.5` = pas de distortion.
|
||||||
|
- `< 0.5` ou `> 0.5` = offset UV négatif / positif.
|
||||||
|
- Le shader soustrait 0.5 pour normaliser en `[-0.5 ; 0.5]`, multiplie par un `DistortionStrength` paramétrable.
|
||||||
|
|
||||||
|
Particle emitters avec couleurs inversées pour les effets de « push » d'explosion. Article détaillé : [samuel-bouchet.fr/posts/unity-6-wave-distortion-fullscreen-vfx](https://samuel-bouchet.fr/posts/unity-6-wave-distortion-fullscreen-vfx/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Orthogonal design : combinaisons > additions
|
||||||
|
|
||||||
|
Concept repris de vidéos de game design (Dishonored, Left 4 Dead) : au lieu d'empiler des features indépendantes, cibler des mécaniques **orthogonales** qui se combinent entre elles pour générer de la diversité émergente. N mécaniques orthogonales → N² situations ; N mécaniques additives → N situations.
|
||||||
|
|
||||||
|
Règle pragma : quand je conçois un nouveau système, je me demande « avec quelles autres mécaniques déjà en place va-t-il interagir, et ces interactions produiront-elles des situations intéressantes ? ». Si la réponse est « aucune », soit la mécanique est prématurée, soit l'architecture est trop silotée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Déterminisme en physique 2D (Box2D)
|
||||||
|
|
||||||
|
Pour les jeux multijoueurs ou replays où la physique doit être identique sur toutes les machines :
|
||||||
|
|
||||||
|
- **Désactiver fast-math** dans les options du compilateur.
|
||||||
|
- **Désactiver les instructions FMA** (Fused Multiply-Add) — elles diffèrent entre CPU et cassent le déterminisme cross-platform.
|
||||||
|
- Fixer le timestep, éviter les `deltaTime` variables dans les équations de physique.
|
||||||
|
|
||||||
|
Ce n'est pas gratuit (~ perte de perf mesurable), à activer uniquement sur les builds où c'est nécessaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pistes à formaliser plus tard
|
||||||
|
|
||||||
|
Quand ces sujets auront accumulé suffisamment de matière, ils mériteront leur propre fichier :
|
||||||
|
|
||||||
|
- **`testing.md`** — tests, fixtures, test data builders, philosophy TDD/BDD.
|
||||||
|
- **`networking.md`** — INetworkMessage, protocole serveur, state sync, latency handling.
|
||||||
|
- **`llm-workflow.md`** — comment je designe pour l'accessibilité LLM, CLAUDE.md templates, protocoles CLI, garde-fous.
|
||||||
|
- **`serialization.md`** — MessagePack détaillé, Union versioning, AOT, migrations.
|
||||||
|
- **`deployment.md`** — Docker, EF migrations, déploiement client web, secrets.
|
||||||
|
- **`documentation.md`** — structure README/CLAUDE.md/GDD, mermaid, commits liés à la doc.
|
||||||
|
- **`debugging.md`** — DebugView++, Unity Profiler, logs structurés, reproductibilité.
|
||||||
|
- **`performance.md`** — profiling, allocations, hot paths, quand optimiser.
|
||||||
|
- **`editor-tooling.md`** — Odin, Artificetoolkit, custom editors, validators, Rider DotSettings.
|
||||||
275
code-review.md
Normal file
275
code-review.md
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
# code-review.md — Revues, pull requests, collaboration
|
||||||
|
|
||||||
|
Ce fichier couvre la partie humaine du code : comment je lis le code des autres, comment je soumets le mien, comment un diff devient du merge propre.
|
||||||
|
|
||||||
|
La posture s'est aussi construite côté **enseignement** : je donne des cours à teachings.samuel-bouchet.fr (back-end, front-end, jeu vidéo, algorithmique, JavaScript) et j'y évalue du code régulièrement. Reviewer en mentor et reviewer en pair sont deux postures différentes, mais la même rigueur s'applique — et le même respect.
|
||||||
|
|
||||||
|
## Posture générale
|
||||||
|
|
||||||
|
Une revue de code n'est pas un tribunal. C'est :
|
||||||
|
|
||||||
|
- **un transfert de contexte** : le relecteur apprend ce qui a changé, et pourquoi ;
|
||||||
|
- **une seconde paire d'yeux** : certains bugs ne se voient que depuis un autre cerveau ;
|
||||||
|
- **un alignement sur les conventions** : on grandit ensemble comme équipe ;
|
||||||
|
- **une occasion de mentorat dans les deux sens** — le senior apprend aussi des juniors.
|
||||||
|
|
||||||
|
Je pars du principe que l'auteur a fait de son mieux, que mes remarques sont des propositions, et que le dernier mot revient à l'auteur et à la conversation — pas au relecteur par défaut.
|
||||||
|
|
||||||
|
## Ma checklist mentale en revue
|
||||||
|
|
||||||
|
Quand j'ouvre un diff, je lis dans cet ordre :
|
||||||
|
|
||||||
|
### 1. Est-ce que je comprends l'intention ?
|
||||||
|
|
||||||
|
Avant de lire le code, je lis **la description de la PR**. Si elle n'explique pas :
|
||||||
|
|
||||||
|
- pourquoi ce changement existe,
|
||||||
|
- ce qu'il fait à haut niveau,
|
||||||
|
- ce qu'il ne fait pas (scope),
|
||||||
|
|
||||||
|
… je demande une reformulation avant d'aller plus loin. Lire du code sans connaître l'intention, c'est chercher des bugs invisibles.
|
||||||
|
|
||||||
|
### 2. Le scope est-il raisonnable ?
|
||||||
|
|
||||||
|
- Une PR qui fait trois choses non liées est plus dure à reviewer que trois PR d'une chose chacune. Je demande un split quand c'est pertinent.
|
||||||
|
- Un refactor massif qui traîne sous un fix de bug ? Le refactor doit être extrait dans sa propre PR.
|
||||||
|
- Un changement de convention global qui modifie 40 fichiers ? Mérite sa propre PR, documentée, pour que le review ne se noie pas dedans.
|
||||||
|
|
||||||
|
### 3. Est-ce que l'architecture tient ?
|
||||||
|
|
||||||
|
- **Séparation des couches** : la logique métier ne fuit-elle pas dans la présentation ? Unity n'est-il pas importé dans `Engine/` ? (Voir [`coding.md`](coding.md) et [`unity.md`](unity.md).)
|
||||||
|
- **Sources de vérité** : un nouveau champ est-il déjà calculable depuis un autre ? Un nouveau Signal est-il vraiment nécessaire ?
|
||||||
|
- **Event sourcing** : les mutations passent-elles toutes par des GameEvent ? (Voir [`reactive-state.md`](reactive-state.md).)
|
||||||
|
- **Nullable** : les `!` sont-ils justifiés, ou est-ce qu'on étouffe un warning ?
|
||||||
|
|
||||||
|
### 4. Est-ce que c'est lisible ?
|
||||||
|
|
||||||
|
- Le nom des classes, méthodes, champs, dit-il ce qu'il fait ?
|
||||||
|
- Est-ce qu'un lecteur qui arrive sans contexte comprend le fichier en une lecture ?
|
||||||
|
- Y a-t-il des commentaires nécessaires (pourquoi complexe, pourquoi ce choix), et pas de commentaires inutiles (paraphrase du code) ?
|
||||||
|
|
||||||
|
### 5. Est-ce que c'est testé ?
|
||||||
|
|
||||||
|
- Tests unitaires sur la logique métier (Engine) ? Si non, pourquoi pas ?
|
||||||
|
- Un test qui reproduirait le bug corrigé ?
|
||||||
|
- Un test qui documente le nouveau comportement ?
|
||||||
|
- Les tests suivent-ils la convention `MethodOrEvent_Scenario_ExpectedResult` et la structure Arrange/Act/Assert ?
|
||||||
|
|
||||||
|
### 6. Y a-t-il des pièges techniques ?
|
||||||
|
|
||||||
|
- Allocations inutiles en hot-path ?
|
||||||
|
- Tâches async sans cancellation token ?
|
||||||
|
- `Find*` dans un `Update()` ?
|
||||||
|
- Mutation d'une collection pendant son itération ?
|
||||||
|
- Sérialisation cassée : un champ `[Key(N)]` renuméroté sans versioning ?
|
||||||
|
- Assertions en éditeur qui masquent un bug en prod ?
|
||||||
|
|
||||||
|
### 7. Doc à jour ?
|
||||||
|
|
||||||
|
- Le `CLAUDE.md` / `README.md` reflète-t-il toujours la réalité ?
|
||||||
|
- Si la PR change un pattern documenté, la doc doit suivre.
|
||||||
|
- Un nouveau GameEvent non documenté dans une state diagram mérite une mise à jour du mermaid.
|
||||||
|
|
||||||
|
## Écrire un commentaire de review utile
|
||||||
|
|
||||||
|
Les règles que je respecte (et que j'espère qu'on me rend) :
|
||||||
|
|
||||||
|
- **Être précis** : pointer un numéro de ligne, citer le code, proposer une alternative concrète quand c'est possible.
|
||||||
|
- **Séparer "blocker" de "suggestion"** : préfixer `blocker:`, `suggestion:`, `nit:`, `question:`, `praise:` pour que l'auteur sache ce qui est un vrai retour et ce qui est juste une pensée.
|
||||||
|
- **blocker** : ne doit pas merger sans ça.
|
||||||
|
- **suggestion** : pense à le faire si possible, mais merge OK sinon.
|
||||||
|
- **nit** (nitpick) : esthétique, optionnel.
|
||||||
|
- **question** : je veux comprendre, pas un retour.
|
||||||
|
- **praise** : ça me plaît et je veux le dire.
|
||||||
|
- **Commenter le code, pas la personne**. "Ce nommage est ambigu" plutôt que "tu as mal nommé".
|
||||||
|
- **Proposer, pas imposer**. Un "et si on faisait X ?" lance une conversation ; un "fais X" ferme le dialogue.
|
||||||
|
- **Accepter qu'on puisse avoir tort**. Si l'auteur explique pourquoi son choix tient, je peux revenir sur mon retour sans drame.
|
||||||
|
|
||||||
|
## Soumettre une PR qu'on a envie de review
|
||||||
|
|
||||||
|
### Le titre
|
||||||
|
|
||||||
|
- Court (< 70 caractères), au présent, descriptif.
|
||||||
|
- Convention informelle : `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`, `perf:`.
|
||||||
|
- Pas de numéro de ticket en début (en fin si pertinent).
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
- `feat: add rune selling from bench`
|
||||||
|
- `fix: gold underflow when buying then refunding`
|
||||||
|
- `refactor: extract combat resolution into RuneCombat`
|
||||||
|
- `docs: update CLAUDE.md for new GameEvent types`
|
||||||
|
|
||||||
|
### La description
|
||||||
|
|
||||||
|
Structure type :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
- 1 à 3 bullets sur ce qui change, le pourquoi.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
Contexte. Qu'est-ce qui a motivé ? Bug rapporté, feature demandée, refactor préparatoire ?
|
||||||
|
|
||||||
|
## What
|
||||||
|
Ce qui bouge concrètement. Les fichiers/classes clés touchés, les décisions d'implémentation.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
- [ ] Tests unitaires passent
|
||||||
|
- [ ] Scénario X testé en play mode
|
||||||
|
- [ ] Pas de régression sur Y
|
||||||
|
|
||||||
|
## Screenshots / videos
|
||||||
|
(si UI)
|
||||||
|
|
||||||
|
## Notes pour la review
|
||||||
|
Points d'attention particuliers, choix discutables, alternatives envisagées.
|
||||||
|
```
|
||||||
|
|
||||||
|
Une PR sans description est une PR qui prend deux fois plus de temps à review.
|
||||||
|
|
||||||
|
### La taille
|
||||||
|
|
||||||
|
Petite autant que possible. Mes repères :
|
||||||
|
|
||||||
|
- **< 100 lignes changées** : review en 5 minutes, merge rapide.
|
||||||
|
- **100-400 lignes** : review sérieuse, à prendre avec un café, mais faisable.
|
||||||
|
- **> 400 lignes** : si ce n'est pas un refactor mécanique, je soupçonne un problème de scope. Je demande un split.
|
||||||
|
|
||||||
|
Quand une feature mérite une grosse PR, je la découpe en PR empilées : une première qui pose les fondations, une seconde qui ajoute la logique, une troisième qui branche l'UI. Chacune mergeable indépendamment.
|
||||||
|
|
||||||
|
### Le self-review
|
||||||
|
|
||||||
|
Avant de demander une review, je relis **mon propre diff** dans l'UI de la plateforme (pas dans l'IDE). Je trouve à chaque fois :
|
||||||
|
|
||||||
|
- des `Console.WriteLine` oubliés,
|
||||||
|
- des imports inutiles,
|
||||||
|
- des commentaires de debug,
|
||||||
|
- du code mort commenté "au cas où",
|
||||||
|
- des typos dans les messages d'erreur.
|
||||||
|
|
||||||
|
Mieux vaut que je les trouve que le relecteur.
|
||||||
|
|
||||||
|
## Commits : granularité et messages
|
||||||
|
|
||||||
|
### Granularité
|
||||||
|
|
||||||
|
- **Un commit = une idée cohérente**. Pas "fin de journée" ou "wip", sauf en local temporairement.
|
||||||
|
- **Atomiques** : chaque commit doit passer les tests et compiler. Sinon `git bisect` devient impossible.
|
||||||
|
- Je rebase/squash avant de pusher quand mes commits locaux sont du brouillon ("fix", "typo", "retry"). Le dépôt reçoit des commits propres.
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
Format que j'utilise :
|
||||||
|
|
||||||
|
```
|
||||||
|
<verbe au présent> <ce qui change>
|
||||||
|
|
||||||
|
<paragraphe optionnel expliquant le pourquoi et le contexte>
|
||||||
|
|
||||||
|
<refs optionnelles : fixes #123, relates to #456>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```
|
||||||
|
Add sell-from-bench action to deckbuilding
|
||||||
|
|
||||||
|
Players can now sell runes from the bench during the build phase,
|
||||||
|
mirroring the existing shop sell action. The refund is 50% of cost
|
||||||
|
rounded down, same formula as shop.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Fix gold underflow when cancelling a shop purchase
|
||||||
|
|
||||||
|
RefundShopPurchase was applying the refund before validating the
|
||||||
|
stock state, leading to negative gold when the purchase had already
|
||||||
|
been partially consumed. Now the refund only applies if the rune
|
||||||
|
is still on the bench.
|
||||||
|
|
||||||
|
Fixes #127
|
||||||
|
```
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- **Verbe au présent actif** : "Add", "Fix", "Refactor", "Remove" — comme si c'était une instruction à git.
|
||||||
|
- **Majuscule au début, pas de point final au titre** (convention usuelle).
|
||||||
|
- **Titre ≤ 72 caractères** pour l'affichage dans `git log --oneline`.
|
||||||
|
- **Ligne vide** entre titre et corps.
|
||||||
|
- **Corps en prose** qui explique le pourquoi, pas le quoi. Le quoi est dans le diff.
|
||||||
|
- **Pas de smiley**, pas de markdown dans les messages de commit (sauf listes simples si utile).
|
||||||
|
|
||||||
|
### Ce qu'un message de commit ne doit pas être
|
||||||
|
|
||||||
|
- `update file`
|
||||||
|
- `fix stuff`
|
||||||
|
- `wip`
|
||||||
|
- `.`
|
||||||
|
|
||||||
|
Quand je vois ça dans l'historique, je sais que quelqu'un s'est dépêché et qu'il faudra ouvrir le diff pour comprendre.
|
||||||
|
|
||||||
|
## Reviewer comme auteur : répondre aux commentaires
|
||||||
|
|
||||||
|
- **Répondre à tous les commentaires**, même si c'est juste "done" ou "good point, changed".
|
||||||
|
- **Ne pas prendre les retours personnellement**. Un "pourquoi ce choix ?" n'est pas un "tu as tort".
|
||||||
|
- **Pousser les changements en commits séparés** pendant la phase de review, pour que le relecteur voie ce qui a bougé. Le squash viendra au merge.
|
||||||
|
- **Rouvrir la discussion** si on n'est pas d'accord : argumenter, proposer, écouter. Pas capituler par politesse.
|
||||||
|
- **Demander une relecture explicitement** quand j'ai poussé des fixes, pour que la PR ne reste pas dans les limbes.
|
||||||
|
|
||||||
|
## Au merge
|
||||||
|
|
||||||
|
- **Squash par défaut** pour les PRs feature, pour que `main` ait un commit par feature avec un message propre.
|
||||||
|
- **Merge commit** pour les branches longues où l'historique fin mérite d'être conservé.
|
||||||
|
- **Rebase** pour les PR d'un seul commit propre — un merge fast-forward.
|
||||||
|
|
||||||
|
Je choisis au cas par cas, pas une politique rigide — mais dans la majorité des cas, squash sur une petite PR donne l'historique le plus lisible.
|
||||||
|
|
||||||
|
## Git en local : hygiène
|
||||||
|
|
||||||
|
- **Branches courtes et bien nommées** : `feat/rune-sell`, `fix/gold-underflow`, `refactor/combat-resolver`.
|
||||||
|
- **`main` jamais modifié directement** sur les projets partagés. Toujours PR.
|
||||||
|
- **Rebase sur `main`** avant de push pour éviter les merges inutiles.
|
||||||
|
- **Éviter `--force-push`** sauf sur sa propre branche et après communication. Jamais sur `main`.
|
||||||
|
- **Pas de commits générés par l'IDE ou le formatter** seuls dans l'historique — intégrés au changement fonctionnel qui les a touchés.
|
||||||
|
|
||||||
|
## Conflits de merge
|
||||||
|
|
||||||
|
Quand ils arrivent :
|
||||||
|
|
||||||
|
- Les résoudre **localement** avec le diff sous les yeux, pas dans l'UI web qui cache du contexte.
|
||||||
|
- Relancer les tests après résolution. Un conflit résolu qui ne compile plus est courant.
|
||||||
|
- Documenter en commentaire de PR si la résolution a changé le comportement initial d'une des deux branches.
|
||||||
|
|
||||||
|
## Demander de l'aide / débloquer
|
||||||
|
|
||||||
|
Quand une PR traîne :
|
||||||
|
|
||||||
|
- **Ping explicite au relecteur** après 2-3 jours de silence, poliment.
|
||||||
|
- **Proposer une session pair review** si les retours sont flous. Souvent plus rapide qu'un ping-pong asynchrone.
|
||||||
|
- **Si une PR bloque une autre**, je le dis au relecteur — c'est un signal pour prioriser.
|
||||||
|
- **Accepter de fermer une PR** qui n'aboutit pas, plutôt que la laisser pourrir. Repartir sur des bases claires est parfois plus rapide.
|
||||||
|
|
||||||
|
## CI et checks automatiques
|
||||||
|
|
||||||
|
- **Tests qui tournent à chaque PR** : unitaires d'Engine, build Unity, lint.
|
||||||
|
- **Pas de merge si la CI est rouge**. Tolérer un red build banalise le signal.
|
||||||
|
- **Checks obligatoires** pour les conventions (format, nommage) quand possible — ça évite les reviews "la virgule est mal placée".
|
||||||
|
- **Hooks pre-commit** pour le formatage et les lint rapides, pour ne pas polluer la CI avec des erreurs triviales.
|
||||||
|
|
||||||
|
## Review en solo (pair programming avec soi-même)
|
||||||
|
|
||||||
|
Quand je bosse seul sur un projet, je me force à :
|
||||||
|
|
||||||
|
- **Ouvrir une PR même pour moi-même** sur les changements significatifs — ça oblige à écrire la description, à relire, à laisser décanter.
|
||||||
|
- **Attendre 24h** avant le merge d'une grosse PR solo. Un diff relu le lendemain révèle 80% de ses défauts.
|
||||||
|
- **Me parler comme un relecteur externe** dans la description — qu'est-ce que mon moi-futur voudra savoir dans 6 mois ?
|
||||||
|
|
||||||
|
## Anti-patterns que je refuse en revue
|
||||||
|
|
||||||
|
- **Le "nit" qui bloque** — un nit est optionnel par définition.
|
||||||
|
- **La revue "LGTM" en 30 secondes sur 400 lignes** — ce n'est pas une revue, c'est un tampon.
|
||||||
|
- **Le gatekeeping** : refuser un merge parce que le code ne correspond pas exactement à la préférence personnelle du relecteur, sans argument technique.
|
||||||
|
- **La revue rétroactive** : commenter un fichier qui n'a pas changé dans la PR. À faire dans une autre PR ou un ticket.
|
||||||
|
- **Les discussions interminables sur un détail** : après 3 échanges sans convergence, soit on tranche en visio, soit on tranche au plus gradé présent, et on avance.
|
||||||
|
- **Le "ah et aussi…"** en bout de review qui réveille un scope oublié. Je préfère écrire tous mes retours en une passe et les poster ensemble.
|
||||||
|
- **Le commit "code review feedback" fourre-tout** — je préfère plusieurs petits commits thématiques que le relecteur peut suivre.
|
||||||
384
csharp.md
Normal file
384
csharp.md
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
# csharp.md — Idiomes et conventions C#
|
||||||
|
|
||||||
|
Ce fichier rassemble mes pratiques C# transverses, indépendantes de Unity (qui a son propre fichier [`unity.md`](unity.md)). Il couvre aussi ce qui est spécifique à .NET : serialisation MessagePack, async/await, organisation de solution.
|
||||||
|
|
||||||
|
## Version et configuration
|
||||||
|
|
||||||
|
- **C# 9 minimum** sur tous mes projets actuels, .NET 6+ côté serveur, netstandard 2.1 pour la couche partagée (compatibilité Unity Mono).
|
||||||
|
- **Nullable reference types activés partout.** C'est non négociable :
|
||||||
|
- Côté .NET (serveur, tests, shared hors Unity) via le `.csproj` :
|
||||||
|
```xml
|
||||||
|
<PropertyGroup>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
```
|
||||||
|
- Côté Unity, via un `csc.rsp` à côté de chaque `.asmdef` :
|
||||||
|
```
|
||||||
|
-nullable
|
||||||
|
```
|
||||||
|
- Pour les dossiers qui intègrent du code tiers non nullable-compliant, un `Directory.Build.props` local désactive proprement :
|
||||||
|
```xml
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Nullable>disabled</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
- Pour les fichiers isolés : `#nullable disable` en première ligne.
|
||||||
|
|
||||||
|
- **Solution multi-projets** : chaque concern a son projet (`Server/`, `Shared/`, `Tests/`, `Client Unity`). La couche partagée est compilée deux fois (une par Unity Mono, une par .NET Core) pour garantir la compatibilité.
|
||||||
|
|
||||||
|
## Conventions de nommage
|
||||||
|
|
||||||
|
Les règles sont strictes et visuelles pour qu'on lise la visibilité d'un coup d'œil.
|
||||||
|
|
||||||
|
- **Publique** : `CamelCased` sans underscore. Ça vaut pour les classes, méthodes, propriétés, et champs publics.
|
||||||
|
- **Privé** : `_camelCased` avec underscore en préfixe. Signal fort : *ne pas toucher de l'extérieur, interne à la classe*.
|
||||||
|
- **Interfaces** : préfixe `I` (`IGameEvent`, `INetworkMessage`, `IUpdatable<T>`).
|
||||||
|
- **Génériques** : `T`, `TKey`, `TValue`, `TSelf` — clarté plutôt qu'originalité.
|
||||||
|
- **Constantes** : `PascalCase` aussi, pas de `SCREAMING_SNAKE`.
|
||||||
|
- **Booléens** : formes `IsXxx`, `HasXxx`, `CanXxx`, `ShouldXxx`. Jamais de double négation.
|
||||||
|
- **Events (au sens `.NET`)** : `OnXxx` pour les callbacks déclenchés.
|
||||||
|
|
||||||
|
Exemple typique :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public abstract class ConnectedBehaviour : MonoBehaviour {
|
||||||
|
protected GameManager GameManager { get; /* ... */ }
|
||||||
|
private GameManager? _gameManager = null;
|
||||||
|
private bool _isSetup;
|
||||||
|
internal CancellationTokenSource? _resetSource;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contraste visuel entre `GameManager` (propriété publique) et `_gameManager` (champ backing privé) rend la lecture sans ambiguïté.
|
||||||
|
|
||||||
|
## Nullable : comment je m'en sers
|
||||||
|
|
||||||
|
Le nullable est un système d'intentions. Je m'en sers pour communiquer avec le futur lecteur (moi compris) :
|
||||||
|
|
||||||
|
- **`T?`** : cette référence peut légitimement être nulle, tu dois gérer le cas.
|
||||||
|
- **`T`** : cette référence ne devrait jamais être nulle, si elle l'est, c'est un bug.
|
||||||
|
- **`null!`** : je te promets que cette valeur sera initialisée avant usage (typiquement par l'inspecteur Unity ou un framework DI).
|
||||||
|
- **`x!`** : j'assume, à cet endroit précis, que `x` n'est pas null — par exemple après un `await UniTask.WaitUntil(() => x != null, ...)`.
|
||||||
|
- **`#nullable disable`** : uniquement pour les fichiers tiers ou legacy incompatibles.
|
||||||
|
|
||||||
|
Je ne mets **jamais** un `!` pour faire taire un warning sans raison valable. Chaque `!` est un contrat : si plus tard il se révèle faux, c'est moi qui aurai menti au compilateur.
|
||||||
|
|
||||||
|
Pattern classique pour les références Unity injectées :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Required]
|
||||||
|
public TextMeshProUGUI StageText = null!;
|
||||||
|
```
|
||||||
|
|
||||||
|
`null!` parce que l'attribut `[Required]` (Odin/Artificetoolkit) + la validation éditeur garantissent que le champ est affecté avant l'exécution. C'est une promesse documentée, pas un contournement.
|
||||||
|
|
||||||
|
## Interfaces et abstractions
|
||||||
|
|
||||||
|
Une interface existe pour une raison précise. Je distingue :
|
||||||
|
|
||||||
|
- **Interfaces de contrat** — pour décrire ce qu'un type sait faire, utilisées pour le polymorphisme :
|
||||||
|
```csharp
|
||||||
|
public interface IGameEvent {
|
||||||
|
Guid GetId();
|
||||||
|
void Apply(GameState gameState, EventBus? sideEffectManager);
|
||||||
|
List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Interfaces de testabilité / inversion** — pour pouvoir injecter un mock. Mais seulement si le besoin existe ; pas par principe.
|
||||||
|
- **Interfaces marker + Union** — pour le versioning via MessagePack (voir plus bas).
|
||||||
|
|
||||||
|
**Ce que je refuse** : l'interface d'une seule implémentation créée "par précaution". Si je trouve `IThing` avec uniquement `Thing`, je supprime l'interface. Le refactor inverse est facile, l'inverse pas.
|
||||||
|
|
||||||
|
## Génériques et contraintes
|
||||||
|
|
||||||
|
J'utilise les génériques largement quand ils apportent de la clarté et du type-safety :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public abstract class ConnectedBehaviour<T> : ConnectedBehaviour {
|
||||||
|
public T? Data { get; private set; }
|
||||||
|
public void Set(T data) { ... }
|
||||||
|
protected abstract void OnSet(GameState state, T element);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IUpdatable<in T> {
|
||||||
|
void UpdateFrom(T source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BroadcastBut<T>(Func<T, bool> shouldSkip, T message)
|
||||||
|
where T : INetworkMessage { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Contraintes : `where T : INetworkMessage`, `where T : class`, `where T : struct, new()`. Je déclare les contraintes les plus fortes possibles — plus le compilateur en sait, moins on a de bugs.
|
||||||
|
|
||||||
|
## Immutabilité
|
||||||
|
|
||||||
|
Immutable par défaut quand c'est raisonnable :
|
||||||
|
|
||||||
|
- `readonly` sur les champs instanciés une fois en constructor.
|
||||||
|
- `init` sur les propriétés (C# 9+) quand on veut permettre l'initialisation par objet-initializer sans setter public.
|
||||||
|
- `record` pour les value objects qui méritent equality par valeur.
|
||||||
|
- `struct` pour les petites données purement valeur (coordonnées, timings, IDs).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public struct BeatTiming {
|
||||||
|
public readonly int Beat;
|
||||||
|
public readonly float Offset;
|
||||||
|
public BeatTiming(int beat, float offset) { Beat = beat; Offset = offset; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Attention : une collection `readonly` n'empêche pas ses éléments de muter, elle empêche juste le conteneur d'être réaffecté. Quand c'est important, je passe par `IReadOnlyList<T>` en exposition publique.
|
||||||
|
|
||||||
|
## Async / await
|
||||||
|
|
||||||
|
Règles stables :
|
||||||
|
|
||||||
|
- **UniTask** côté Unity et côté partagé qui tourne en Unity (zéro-allocation, PlayerLoop-aware).
|
||||||
|
- **Task** côté serveur .NET pur.
|
||||||
|
- **Toujours passer un `CancellationToken`** quand la tâche peut survivre à un lifecycle (GameObject, connexion, requête HTTP). Pas par pureté, par pragmatisme : les tâches orphelines causent des leaks.
|
||||||
|
- **`Forget()`** pour les fire-and-forget UniTask explicites (typiquement un `Setup()` appelé depuis `OnEnable`). Je ne laisse pas un `async void` traîner.
|
||||||
|
- **`SuppressCancellationThrow()`** quand je ne veux pas qu'une cancellation remonte en exception.
|
||||||
|
- **Pas de `.Result` ni `.Wait()`** — jamais. Deadlock garanti un jour.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected virtual void OnEnable() {
|
||||||
|
Setup().Forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async UniTask Setup() {
|
||||||
|
if (!_isSetup) {
|
||||||
|
if (GameManager == null) {
|
||||||
|
await UniTask.WaitUntil(
|
||||||
|
() => GameManager != null,
|
||||||
|
PlayerLoopTiming.Update,
|
||||||
|
gameObject.GetCancellationTokenOnDestroy());
|
||||||
|
}
|
||||||
|
_isSetup = true;
|
||||||
|
OnSetup(GameManager!.State);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le pattern "utility async throttled" que je réutilise souvent :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static Action CreateThrottled(Action action, UniTask delayTask) {
|
||||||
|
bool throttling = false;
|
||||||
|
async void ThrottledEventHandler() {
|
||||||
|
if (throttling) return;
|
||||||
|
action();
|
||||||
|
throttling = true;
|
||||||
|
await delayTask.SuppressCancellationThrow();
|
||||||
|
throttling = false;
|
||||||
|
}
|
||||||
|
return ThrottledEventHandler;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sérialisation : MessagePack + Union
|
||||||
|
|
||||||
|
Pour tout ce qui doit persister ou transiter (saves, réseau), j'utilise **MessagePack for C#** :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[MessagePackObject]
|
||||||
|
public class CharacterMoveGameEvent : GameEvent {
|
||||||
|
[Key(0)] public int Id;
|
||||||
|
[Key(1)] public ushort CharacterShortId;
|
||||||
|
[Key(2)] public Vector3 Position;
|
||||||
|
|
||||||
|
[SerializationConstructor]
|
||||||
|
public CharacterMoveGameEvent(int id, ushort characterShortId, Vector3 position) {
|
||||||
|
Id = id;
|
||||||
|
CharacterShortId = characterShortId;
|
||||||
|
Position = position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes importantes :
|
||||||
|
|
||||||
|
- **`[MessagePackObject(true)]`** pour un mapping automatique par nom, **`[MessagePackObject]` + `[Key(N)]`** explicite pour les types qui vont vraiment en réseau ou en save (contrôle de schema, stabilité).
|
||||||
|
- **`[SerializationConstructor]`** pour lever l'ambiguïté quand plusieurs constructeurs existent.
|
||||||
|
- **AOT** : côté Unity IL2CPP, il faut générer les formatters via `mpc` :
|
||||||
|
```bash
|
||||||
|
mpc -i D:\projets\TheRuneMaker\Assets -o "Assets/Client/Scripts/MessagePackGenerated.cs"
|
||||||
|
```
|
||||||
|
À refaire systématiquement après ajout d'un nouveau type serialisé.
|
||||||
|
- **Versioning par Union** : jamais de breaking change silencieux sur un format sérialisé.
|
||||||
|
```csharp
|
||||||
|
[Union(0, typeof(GameState))]
|
||||||
|
[Union(1, typeof(GameStateV2))]
|
||||||
|
public interface IGameState {
|
||||||
|
byte[] Serialize();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Si un champ doit changer de type ou de nom, je crée un `V2` et je laisse le `V0/V1` chargeable. Les saves ne se cassent pas, le serveur peut accepter plusieurs versions simultanément.
|
||||||
|
|
||||||
|
## LINQ et collections
|
||||||
|
|
||||||
|
J'utilise LINQ largement pour l'expressivité déclarative :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var queue = new Queue<RuneSlotState>(
|
||||||
|
Edges.Keys.Where(node => inDegree[node] == 0)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- LINQ est bon pour la lisibilité, pas pour la performance hot-path. Dans une boucle de tick à 120 BPM, je profile et j'inline si besoin.
|
||||||
|
- `.ToList()` explicite quand je matérialise — une énumération différée consommée deux fois, c'est deux exécutions.
|
||||||
|
- Dans les hot-paths, ZLinq ou itérations manuelles (`for` plutôt que `foreach`) pour éviter les allocations.
|
||||||
|
|
||||||
|
Collections préférées :
|
||||||
|
|
||||||
|
- `List<T>` pour les séquences mutables ordonnées.
|
||||||
|
- `Dictionary<TKey, TValue>` pour les lookups.
|
||||||
|
- `ConcurrentDictionary<TKey, TValue>` et `ConcurrentQueue<T>` pour les échanges inter-thread (typiquement serveur ↔ réseau).
|
||||||
|
- `IReadOnlyList<T>` / `IReadOnlyDictionary<TKey, TValue>` en exposition publique quand je veux empêcher la mutation externe.
|
||||||
|
- `HashSet<T>` pour les appartenances rapides.
|
||||||
|
|
||||||
|
## Gestion d'erreurs
|
||||||
|
|
||||||
|
- **`Exception` pour les vraies erreurs** — état corrompu, invariant violé, input invalide côté API publique. Je préfère faire péter tôt et bruyamment.
|
||||||
|
- **Exceptions custom typées** quand je veux permettre un catch spécifique : `ApplicationException`, types dédiés au domaine.
|
||||||
|
- **Pas d'exceptions pour le contrôle de flux** — un cas "normal mais alternatif" est un retour explicite (`bool TryXxx(out T result)`, `T?`, tuple `(bool ok, T value)`, ou un type Result dédié si le projet l'introduit).
|
||||||
|
- **Assertions en éditeur** : `AssertApplicationConditions(gameState)` que je n'appelle qu'en `#if UNITY_EDITOR` pour ne pas payer le coût en prod.
|
||||||
|
- **Log d'erreur = message actionnable** : contexte, valeurs attendues vs observées, pas juste `"error"`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void ApplyEvent(Action<GameState, EventBus?> apply, EventBus? eventBus) {
|
||||||
|
lock (_lockObject) {
|
||||||
|
if (_isApplyingEvent)
|
||||||
|
throw new ApplicationException(
|
||||||
|
"Nested ApplyEvent detected — events must not be applied from within another event's Apply.");
|
||||||
|
_isApplyingEvent = true;
|
||||||
|
try { apply(this, eventBus); OnEventApplied(eventBus); }
|
||||||
|
finally { _isApplyingEvent = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commentaires et documentation
|
||||||
|
|
||||||
|
- **XMLdoc sur les APIs publiques** (`///`). Paramètres, valeur de retour, remarques pertinentes. Pas besoin de tout documenter si le nom suffit, mais dès qu'il y a une subtilité d'usage, je documente.
|
||||||
|
- **Commentaires en prose** pour les logiques métier complexes — un paragraphe au-dessus de la méthode ou de la classe explique le *pourquoi*.
|
||||||
|
- **Pas de paraphrase** : un commentaire qui dit "incremente i" au-dessus de `i++` est du bruit. Le commentaire sert à dire ce que le code ne peut pas dire.
|
||||||
|
- **HTML dans les XMLdoc** pour les descriptions riches (listes, paragraphes) destinées à l'outillage ou aux tooltips in-game :
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// <b>Arcane de feu</b>
|
||||||
|
/// <para>Mécanique de flammes, basée sur l'utilisation de stacks...</para>
|
||||||
|
/// </summary>
|
||||||
|
public static readonly ArcaneDef Fire = new("Fire");
|
||||||
|
```
|
||||||
|
- **`TODO`, `FIXME`, `HACK`** explicites et signés — et reliés à un ticket si possible.
|
||||||
|
|
||||||
|
## Organisation de code
|
||||||
|
|
||||||
|
- **Un fichier = un type public**, même nom. Les types imbriqués ou privés peuvent partager le fichier de leur parent.
|
||||||
|
- **`namespace` qui reflète la structure de dossier**, sans sur-ingéniérie. `Runemaker.Engine`, `Runemaker.Client`, `Shared.Net.Messages`.
|
||||||
|
- **`using` triés et minimaux** — l'IDE le fait, je laisse faire Rider avec ses `.DotSettings` versionnés.
|
||||||
|
- **Méthodes courtes** — quand une méthode dépasse ~40 lignes ou a plus de 2 niveaux d'indentation profonde, je me demande si elle doit être split.
|
||||||
|
- **Régions `#region`** uniquement pour grouper par thème dans les classes massives où ça aide vraiment (tests). Pas pour cacher du code qu'on devrait déplacer.
|
||||||
|
|
||||||
|
## Patterns C# modernes que j'utilise
|
||||||
|
|
||||||
|
- **Expression-bodied members** pour les one-liners : `public Guid GetId() => _id;`
|
||||||
|
- **Pattern matching** pour la lisibilité :
|
||||||
|
```csharp
|
||||||
|
var combatEntries = changelist.Where(e =>
|
||||||
|
e is CombatStepEntry or CombatTargetEntry or CombatActionEntry
|
||||||
|
).ToList();
|
||||||
|
```
|
||||||
|
- **`switch expression`** plutôt que des if-else en cascade.
|
||||||
|
- **`nameof()`** à la place des strings littéraux pour les messages d'erreur et le binding.
|
||||||
|
- **`is null` / `is not null`** plutôt que `== null` (évite les surcharges d'opérateur surprenantes).
|
||||||
|
- **Target-typed `new()`** (C# 9) quand le type est évident à gauche : `Dictionary<string, int> scores = new();`
|
||||||
|
- **`record` pour les DTOs et value objects** — equality par valeur gratuite, immutabilité par défaut.
|
||||||
|
|
||||||
|
## IDE et tooling
|
||||||
|
|
||||||
|
- **Rider** (JetBrains) comme IDE principal, versioné via les fichiers `.DotSettings` :
|
||||||
|
- `TheRuneMaker.sln.DotSettings` / `.csproj.DotSettings` à la racine du projet
|
||||||
|
- Ces fichiers encodent les conventions de format, de nommage, de style, les analyses désactivées, etc.
|
||||||
|
- **Les conventions d'équipe vivent dans ces fichiers**, pas dans une page Confluence oubliée.
|
||||||
|
- **Hot reload** côté serveur, **Edit-mode reload** + Unity Hot Reload côté client quand possible.
|
||||||
|
- **`dotnet` CLI** pour les tâches répétitives :
|
||||||
|
```bash
|
||||||
|
dotnet build
|
||||||
|
dotnet test
|
||||||
|
dotnet test --filter "FullyQualifiedName~CombatTests"
|
||||||
|
dotnet test --filter "FullyQualifiedName~CombatTests.TestBasicCombat_HeroVsTwoEnemies"
|
||||||
|
dotnet ef migrations add InitialCreate
|
||||||
|
dotnet ef database update
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests et mocks : Moq côté Unity / C#
|
||||||
|
|
||||||
|
Quand je dois mocker une dépendance (par exemple un accès PlayerPrefs, un wrapper d'I/O, un service réseau), j'utilise **Moq** avec **Castle.Core**. Le setup côté Unity :
|
||||||
|
|
||||||
|
- DLLs dans `Plugins/` ou via NuGetForUnity : `Moq.dll`, `Castle.Core.dll`, `System.Diagnostics.EventLog.dll`.
|
||||||
|
- Toutes ces DLLs taguées **Editor-only** dans l'inspecteur — elles n'ont rien à faire dans un build runtime.
|
||||||
|
- Le `.asmdef` de tests référence ces DLLs via `precompiledReferences`.
|
||||||
|
|
||||||
|
Le pattern clé : je ne teste jamais directement contre l'API Unity (PlayerPrefs, Time, `Application.persistentDataPath`). Je passe par une interface abstraite injectée dans le constructeur :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IPlayerPrefs {
|
||||||
|
void SetString(string key, string value);
|
||||||
|
int GetInt(string key);
|
||||||
|
bool HasKey(string key);
|
||||||
|
void Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PersistantState {
|
||||||
|
private readonly IPlayerPrefs _playerPrefs;
|
||||||
|
public PersistantState(IPlayerPrefs playerPrefs) {
|
||||||
|
_playerPrefs = playerPrefs;
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le test injecte un `Mock<IPlayerPrefs>` et **vérifie les interactions** (pas les side-effects) — c'est du behavioral testing :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Test]
|
||||||
|
public void SetLevelScore_SavesToDisk() {
|
||||||
|
var mock = new Mock<IPlayerPrefs>();
|
||||||
|
var state = new PersistantState(mock.Object);
|
||||||
|
|
||||||
|
state.SetLevelScore("level1", 100);
|
||||||
|
|
||||||
|
mock.Verify(x => x.SetString(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
|
||||||
|
mock.Verify(x => x.Save(), Times.Once);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bénéfice : les tests tournent en `dotnet test` en quelques millisecondes, sans ouvrir Unity, sans PlayerPrefs réels, sans fichiers à nettoyer. L'article détaillé est sur [samuel-bouchet.fr/posts/2024-10-25-unity-test-mocks](https://samuel-bouchet.fr/posts/2024-10-25-unity-test-mocks/).
|
||||||
|
|
||||||
|
## EntityFramework (côté serveur)
|
||||||
|
|
||||||
|
Quand un serveur a une DB, je passe par EF Core avec migrations :
|
||||||
|
|
||||||
|
- Installation des tools : `dotnet tool install --global dotnet-ef`
|
||||||
|
- Créer une migration : `dotnet ef migrations add <MigrationName>`
|
||||||
|
- Appliquer : `dotnet ef database update`
|
||||||
|
- Reset local propre : `dotnet ef database drop && dotnet ef migrations add InitialCreate && dotnet ef database update`
|
||||||
|
|
||||||
|
Les migrations sont des fichiers versionnés dans le repo, pas des scripts manuels. Chaque changement de schéma = une migration nommée explicitement.
|
||||||
|
|
||||||
|
## Anti-patterns que je refuse
|
||||||
|
|
||||||
|
- `.Result` et `.Wait()` sur des `Task` — deadlock garanti un jour.
|
||||||
|
- `catch (Exception e) { }` — un catch silencieux est un bug qu'on cache.
|
||||||
|
- `!` en cascade pour étouffer les warnings nullables sans réfléchir.
|
||||||
|
- Les propriétés auto-implémentées quand un champ public suffit (verbosité gratuite).
|
||||||
|
- Les DTOs avec un setter public sur tout quand un constructor + init ferait l'affaire.
|
||||||
|
- Les méthodes statiques qui cachent un état global mutable.
|
||||||
|
- Les interfaces d'une seule implémentation créées "au cas où".
|
||||||
|
- Les `throw new NotImplementedException()` qu'on oublie de remplacer.
|
||||||
|
- Le `var` quand le type n'est pas évident à la lecture — dans ce cas je le tape explicitement.
|
||||||
415
reactive-state.md
Normal file
415
reactive-state.md
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
# reactive-state.md — État réactif et event sourcing
|
||||||
|
|
||||||
|
Ce fichier couvre ma façon de gérer **l'état** dans une application : comment il est stocké, comment il se mute, comment l'UI se synchronise dessus, comment les effets réseau s'intègrent. C'est la mécanique centrale de presque tous mes projets.
|
||||||
|
|
||||||
|
Le sujet recoupe deux traditions que j'utilise ensemble : la UI réactive (TinkStateSharp, signals, AutoRun), et l'architecture **Black Box Sim** formalisée par Brian Cronin (Roguelike Celebration 2024) — commande in, événements out, sim isolée de la présentation. J'ai présenté un état précoce de ce pattern à **ADDON 2022** sous l'angle « Rendre réactive sa UI Unity », et je l'ai éprouvé sur Neoproxima, Manitou Lift and Drift, TheRuneMaker, et plusieurs prototypes. Article de référence : [samuel-bouchet.fr/posts/2026-04-08-black-box-sim](https://samuel-bouchet.fr/posts/2026-04-08-black-box-sim/).
|
||||||
|
|
||||||
|
Trois couches travaillent ensemble :
|
||||||
|
|
||||||
|
1. **L'état observable** — `Signal<T>`, `SignalList<T>`, `SignalDictionary<K,V>` (TinkStateSharp). Source de vérité.
|
||||||
|
2. **Les mutations canalisées** — `GameEvent` / command pattern. Toute écriture passe par là.
|
||||||
|
3. **Les souscriptions passives** — `AutoRun`, `ConnectedBehaviour`, `EventBus`. L'UI et les side-effects réagissent aux changements.
|
||||||
|
|
||||||
|
## Pourquoi ce modèle
|
||||||
|
|
||||||
|
J'ai adopté ce pattern après avoir souffert de problèmes classiques :
|
||||||
|
|
||||||
|
- **État dupliqué** qui se désynchronise entre composants.
|
||||||
|
- **Mutations sauvages** qui rendent un bug impossible à reproduire.
|
||||||
|
- **UI en décalage** parce qu'un appel à `Refresh()` est oublié quelque part.
|
||||||
|
- **Multijoueur impossible** parce qu'il n'y a pas de format canonique pour une action.
|
||||||
|
- **Replay impossible** parce qu'on ne sait pas ce qui s'est passé.
|
||||||
|
|
||||||
|
Le pattern réactif + event-sourcé résout les cinq d'un coup :
|
||||||
|
|
||||||
|
- **Un seul état**, observable. Pas de cache, pas de copie.
|
||||||
|
- **Toutes les mutations passent par des GameEvent typés**. Elles sont validables, traçables, rejouables.
|
||||||
|
- **L'UI est passive**, elle réagit automatiquement aux changements via des souscriptions.
|
||||||
|
- **Un GameEvent est serialisable** (MessagePack) : il transite en réseau, se sauvegarde, se rejoue.
|
||||||
|
- **Un changelist produit une trace** des mutations, qui sert à animer, auditer, synchroniser.
|
||||||
|
|
||||||
|
## L'état observable : Signal, SignalList, SignalDictionary
|
||||||
|
|
||||||
|
J'utilise **TinkStateSharp** (port C# de `tink_state` de Haxe). Les primitives clés :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public readonly Signal<string?> Level = new(null);
|
||||||
|
public readonly Signal<byte> SelectedTool = new(0);
|
||||||
|
public readonly SignalDictionary<BlockId, int> BlocsInventory = new();
|
||||||
|
public readonly SignalList<Rune> Inventory = new();
|
||||||
|
```
|
||||||
|
|
||||||
|
Un `Signal<T>` est une référence mutable observable. On l'utilise comme une variable, mais tout lecteur abonné est notifié à chaque changement. Idem pour `SignalList<T>` (ajout/suppression/réorganisation) et `SignalDictionary<K,V>` (ajout/suppression de clés, mutation de valeurs).
|
||||||
|
|
||||||
|
Règles que je m'impose :
|
||||||
|
|
||||||
|
- **Signals immuables structurellement** : je les déclare `readonly` pour que la référence ne change jamais. Seul leur contenu évolue. Côté ConnectedBehaviour, `Observable<T>` expose la version read-only.
|
||||||
|
```csharp
|
||||||
|
private Signal<LoadingStage> _currentLoadingStage = new(LoadingStage.NotStarted);
|
||||||
|
public Observable<LoadingStage> CurrentLoadingStage => _currentLoadingStage;
|
||||||
|
```
|
||||||
|
- **Pas de `Signal<T>` dans les payloads des GameEvent** — un GameEvent est un message serialisable, pas un conteneur réactif.
|
||||||
|
- **Pas d'abonnement hors lifecycle** — chaque souscription est enregistrée dans un `CancellationToken` qui se déclenche au reset ou au destroy.
|
||||||
|
|
||||||
|
## AutoRun : la souscription qui se gère toute seule
|
||||||
|
|
||||||
|
`AutoRun` est le pattern clé : on passe un `Action` qui lit des Signal, et tink_state track automatiquement les dépendances. Dès qu'un Signal lu change, l'action est rejouée.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
this.AutoRun(() => {
|
||||||
|
StageText.text = GetStageDescription(CurrentLoadingStage.Value);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
C'est l'équivalent d'un `useEffect` React sans avoir à déclarer les dépendances. Le tracking est automatique, le cleanup est géré par l'owner (`this` = un `ConnectedBehaviour`).
|
||||||
|
|
||||||
|
Pattern typique dans un Display :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected override void OnSetup(GameState state) {
|
||||||
|
this.AutoRun(() => {
|
||||||
|
var gold = state.CurrentRun.Gold.Value;
|
||||||
|
GoldText.text = gold.ToString();
|
||||||
|
GoldIcon.SetActive(gold > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.AutoRun(() => {
|
||||||
|
var phase = state.CurrentRun.CurrentPhase;
|
||||||
|
ShopPanel.SetActive(phase == Phase.Build);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque `AutoRun` est indépendant, ne lit que ce qui l'intéresse, et se réexécute uniquement quand ses dépendances changent. Le couplage UI ↔ état est totalement déclaratif.
|
||||||
|
|
||||||
|
## ConnectedBehaviour : le glue côté Unity
|
||||||
|
|
||||||
|
Côté Unity, j'ai une classe de base qui encapsule la plomberie :
|
||||||
|
|
||||||
|
- découverte du `GameManager` (et donc du `GameState` et de l'`EventBus`) ;
|
||||||
|
- gestion d'un `CancellationToken` reset-able ;
|
||||||
|
- API `ListenEvent<T>`, `TriggerEvent<T>`, `DisposeOnReset` ;
|
||||||
|
- `OnSetup(GameState)` abstrait : point d'entrée pour poser les `AutoRun`.
|
||||||
|
|
||||||
|
Version générique `ConnectedBehaviour<T>` pour les composants configurés de l'extérieur :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public abstract class ConnectedBehaviour<T> : ConnectedBehaviour {
|
||||||
|
public T? Data { get; private set; }
|
||||||
|
|
||||||
|
public void Set(T data) {
|
||||||
|
Data = data;
|
||||||
|
_resetSource?.Cancel(false);
|
||||||
|
_resetSource = new CancellationTokenSource();
|
||||||
|
OnSet(GameManager.State, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract void OnSet(GameState state, T element);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque appel à `.Set(data)` cancel les anciennes souscriptions et en crée de nouvelles. Un `RuneDisplay` reçoit une `Rune` via `.Set(rune)`, s'abonne aux Signals de cette rune, et quand on le re-Set avec une autre rune, tout se reconnecte proprement.
|
||||||
|
|
||||||
|
Voir [`unity.md`](unity.md) pour le détail du cycle de vie Unity de `ConnectedBehaviour`.
|
||||||
|
|
||||||
|
## GameEvent : les mutations canalisées
|
||||||
|
|
||||||
|
Chaque mutation d'état est un **objet** qui implémente `IGameEvent` :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IGameEvent {
|
||||||
|
Guid GetId();
|
||||||
|
void Apply(GameState gameState, EventBus? sideEffectManager);
|
||||||
|
List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Une classe abstraite `GameEvent` s'occupe de la plomberie :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public abstract class GameEvent : IGameEvent {
|
||||||
|
private readonly Guid _id = Guid.NewGuid();
|
||||||
|
public Guid GetId() => _id;
|
||||||
|
|
||||||
|
public void Apply(GameState gameState, EventBus? eventBus) {
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
AssertApplicationConditions(gameState);
|
||||||
|
#endif
|
||||||
|
gameState.ApplyEvent(DoApply, eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus) {
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
AssertApplicationConditions(gameState);
|
||||||
|
#endif
|
||||||
|
var changelist = new List<ChangeEntry>();
|
||||||
|
gameState.ApplyEventWithChangelist(
|
||||||
|
(gs, cl) => DoApplyWithChangelist(gs, cl),
|
||||||
|
changelist,
|
||||||
|
eventBus);
|
||||||
|
return changelist;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract void DoApply(GameState gameState, EventBus? eventBus);
|
||||||
|
|
||||||
|
protected virtual void DoApplyWithChangelist(GameState gameState, List<ChangeEntry> changelist) {
|
||||||
|
DoApply(gameState, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract void AssertApplicationConditions(in GameState gameState);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Les composants ne font que créer un event et appeler `.Apply(state)` ou passer par l'EventBus. Ils ne mutent jamais `state` directement.
|
||||||
|
|
||||||
|
### Pourquoi cette abstraction paye
|
||||||
|
|
||||||
|
- **`AssertApplicationConditions`** valide en amont que l'event est applicable (assez d'or, phase correcte, rune connectée, etc.). En `#if UNITY_EDITOR` pour ne pas payer en prod, mais ça attrape les bugs tôt.
|
||||||
|
- **`DoApply` est le seul endroit qui mute l'état**. Si un bug d'état apparaît, je sais où chercher : dans les `DoApply` des events pertinents.
|
||||||
|
- **Le Guid** identifie l'event, utile pour le reporting, le debug, la déduplication en réseau.
|
||||||
|
- **Le changelist** produit une trace structurée des mutations pour l'animation / sync.
|
||||||
|
|
||||||
|
### Conventions de nommage
|
||||||
|
|
||||||
|
- **Suffixe `GameEvent`** pour chaque classe : `PickLootGameEvent`, `MoveSlotGameEvent`, `BuyFromShopGameEvent`, `BackToTitleGameEvent`. Verbeux mais jamais ambigu.
|
||||||
|
- **Un verbe à l'impératif** au début du nom : `Pick`, `Buy`, `Move`, `Apply`, `Quit`. C'est une commande.
|
||||||
|
- **Paramètres immuables** : champs `readonly` ou propriétés `init`. Un event une fois créé ne change pas.
|
||||||
|
|
||||||
|
## ApplyEvent : atomicité et side-effects
|
||||||
|
|
||||||
|
`GameState.ApplyEvent(...)` est le point central d'écriture. Il garantit :
|
||||||
|
|
||||||
|
- **pas de réentrance** — un GameEvent ne peut pas en appliquer un autre pendant son `DoApply` ;
|
||||||
|
- **un lock** sur la mutation, pour multithread safe ;
|
||||||
|
- **un callback `OnEventApplied`** qui déclenche les side-effects via l'EventBus, après la mutation.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void ApplyEvent(Action<GameState, EventBus?> apply, EventBus? eventBus) {
|
||||||
|
lock (_lockObject) {
|
||||||
|
if (_isApplyingEvent)
|
||||||
|
throw new ApplicationException("ApplyEvent cannot be nested");
|
||||||
|
_isApplyingEvent = true;
|
||||||
|
try {
|
||||||
|
apply(this, eventBus);
|
||||||
|
OnEventApplied(eventBus);
|
||||||
|
} finally {
|
||||||
|
_isApplyingEvent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Règle que ce pattern impose : **pas de logique métier dans les souscriptions aux Signals**. Si un changement d'état doit déclencher une autre mutation, c'est un GameEvent qui le fait au niveau supérieur, pas une réaction en chaîne dans un `AutoRun`.
|
||||||
|
|
||||||
|
## Changelist : la trace rejouable
|
||||||
|
|
||||||
|
Un GameEvent peut produire un **changelist** : une liste d'entrées typées décrivant ce qui s'est passé. Exemples d'entrées : `GoldChangeEntry`, `RuneMovedEntry`, `CombatStepEntry`, `CombatTargetEntry`, `CombatActionEntry`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected override void DoApplyWithChangelist(GameState state, List<ChangeEntry> changelist) {
|
||||||
|
var before = state.CurrentRun.Gold;
|
||||||
|
state.CurrentRun.Gold -= Cost;
|
||||||
|
changelist.Add(new GoldChangeEntry(before, state.CurrentRun.Gold, reason: "BuyFromShop"));
|
||||||
|
// ... autres mutations et entrées
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pourquoi c'est utile :
|
||||||
|
|
||||||
|
- **Animation** : le `GameManager` récupère le changelist et orchestre les animations synchrones au beat. Un combat entier est résolu instantanément côté logique, puis rejoué visuellement.
|
||||||
|
```csharp
|
||||||
|
private void HandleChangelist(IReadOnlyList<ChangeEntry> changelist) {
|
||||||
|
var combatEntries = changelist
|
||||||
|
.Where(e => e is CombatStepEntry or CombatTargetEntry or CombatActionEntry or ...)
|
||||||
|
.ToList();
|
||||||
|
if (combatEntries.Count > 0)
|
||||||
|
StartCoroutine(Orchestrate(combatEntries, token));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Synchronisation réseau** : le serveur applique, produit le changelist, le broadcast aux clients.
|
||||||
|
- **Debug et replay** : on peut rejouer une partie en appliquant les events dans l'ordre, ou inspecter les changelist pour comprendre un état final.
|
||||||
|
- **UI diff** : au lieu de tout redessiner, on peut n'animer que ce qui a changé.
|
||||||
|
|
||||||
|
## EventBus : side-effects découplés
|
||||||
|
|
||||||
|
`EventBus` est un dispatcher typé :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public EventDispatcher<T> For<T>() {
|
||||||
|
if (_all.TryGetValue(typeof(T), out EventDispatcher obs))
|
||||||
|
return (EventDispatcher<T>) obs;
|
||||||
|
var newObs = new EventDispatcher<T>();
|
||||||
|
_all.Add(typeof(T), newObs);
|
||||||
|
return newObs;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Dans un ConnectedBehaviour
|
||||||
|
ListenEvent<CombatStartedEvent>(e => PlayCombatMusic(e.EncounterIndex));
|
||||||
|
|
||||||
|
// Depuis un GameEvent, pendant DoApply
|
||||||
|
eventBus?.For<CombatStartedEvent>().Trigger(new CombatStartedEvent(encounter.Index));
|
||||||
|
```
|
||||||
|
|
||||||
|
Distinction avec les Signals :
|
||||||
|
|
||||||
|
- **Signal** = état continu, lu à tout moment, déclenche des réactions quand il change.
|
||||||
|
- **EventBus** = événement ponctuel, n'a pas d'état persistant, déclenche des side-effects one-shot (son, particule, notification).
|
||||||
|
|
||||||
|
Un `CharacterDied` est un événement (EventBus). `HP` est un état (Signal). On ne joue pas un son à chaque tick de HP, on en joue un au `CharacterDied`.
|
||||||
|
|
||||||
|
## Sources de vérité : ce qui vit où
|
||||||
|
|
||||||
|
Discipline que je m'impose et que je documente en tête de chaque projet :
|
||||||
|
|
||||||
|
- **Données statiques** (définitions, balance) → factories C# pures (`RuneFactory`, `RuneDungeon`, `BlockConfigJson` registry). Immuables.
|
||||||
|
- **État dynamique** → `GameState` (serialisable, observable via Signals). Source unique.
|
||||||
|
- **État client local** (caméra, sélection UI, préférences) → `ClientState` séparé, non-serialisé. Ne doit jamais contenir de donnée dérivable du GameState.
|
||||||
|
- **Vues** (UI, rendu) → dérivées au runtime depuis les Signals. Jamais stockées à part.
|
||||||
|
|
||||||
|
Une règle concrète qui émerge : **si tu peux te passer d'un champ parce qu'il est déjà calculable depuis un autre, supprime-le**. Une source de vérité dupliquée est un bug à venir.
|
||||||
|
|
||||||
|
## State machine : stricte ou lâche
|
||||||
|
|
||||||
|
Selon le domaine, j'implémente la machine d'état de deux façons :
|
||||||
|
|
||||||
|
### State machine stricte
|
||||||
|
|
||||||
|
Pour les transitions bien définies (connexion réseau, chargement de jeu, cycle de vie session) : un enum d'états + une méthode `Transition(State from, State to)` qui valide explicitement.
|
||||||
|
|
||||||
|
### State machine lâche
|
||||||
|
|
||||||
|
Pour les phases de gameplay complexes avec des exceptions et des branchements conditionnels : une `Phase` enum **indicative** + des flags implicites (`PendingSpecialisationLoot`, `NecklaceStringBox`, `LootBox`). Les transitions sont déclenchées par des GameEvents ; le client/UI enforce le sequencing, pas l'engine.
|
||||||
|
|
||||||
|
Exemple documenté dans le CLAUDE.md de TheRuneMaker :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Build : StartRunGameEvent
|
||||||
|
state Build {
|
||||||
|
Deckbuilding --> Deckbuilding : RerollShopGE / BuyFromShopGE / MoveSlotGE
|
||||||
|
Deckbuilding --> PickSpec : PendingSpecialisationLoot?
|
||||||
|
}
|
||||||
|
Build --> Encounter : StartCombatGE
|
||||||
|
state Encounter {
|
||||||
|
Loot --> Loot : PickLootGE (items remain)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Les "guards" réels sont les flags d'état, pas la Phase elle-même. La Phase aide à la compréhension, mais **c'est `RunData.Value = null` qui signale "partie finie"**, pas la valeur de Phase.
|
||||||
|
|
||||||
|
Ce choix lâche évite la combinatoire des enums de phase mais demande plus de rigueur à la documentation. Je l'assume quand ça reflète fidèlement la complexité du domaine.
|
||||||
|
|
||||||
|
## Pipelines de calcul : étapes explicites
|
||||||
|
|
||||||
|
Quand un calcul combine plusieurs effets (bonus, modifiers, multipliers), je pipeline explicitement en étapes nommées. Exemple : le 4-stage bonus pipeline de TheRuneMaker :
|
||||||
|
|
||||||
|
1. **RECEIVE** (baseValue = 0) : collecte des bonus entrants des runes contributrices.
|
||||||
|
2. **GENERATE** (baseValue = ReceivedValue) : additions flat (+10 Power), mods reçus (+50% Burn).
|
||||||
|
3. **TRANSFORM** (baseValue = GeneratedValue) : effets multiplicatifs (Efficiency %), critical rolls.
|
||||||
|
4. **AMPLIFY** (baseValue = TransformedValue) : multiplicateurs finaux.
|
||||||
|
|
||||||
|
Chaque étape opère sur la `baseValue` produite par la précédente. L'ordre est documenté, testable, explicable à un designer.
|
||||||
|
|
||||||
|
Pour les DAG de dépendances (graphe de runes), **tri topologique** pour garantir l'ordre de résolution :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var queue = new Queue<RuneSlotState>(
|
||||||
|
Edges.Keys.Where(node => inDegree[node] == 0));
|
||||||
|
var topoOrder = new List<RuneSlotState>();
|
||||||
|
// ... process queue, decrementing inDegree of neighbors
|
||||||
|
```
|
||||||
|
|
||||||
|
Le déterminisme du tri + le pipeline nommé = débuggabilité totale.
|
||||||
|
|
||||||
|
## Animer une UI réactive : le pattern « animation queue »
|
||||||
|
|
||||||
|
Une UI purement réactive bindée en `AutoRun` brille tant que les changements sont ponctuels et indépendants. Dès qu'il faut **animer** les transitions — a fortiori une séquence "damage → heal → damage" enchaînée dans un même tick — le binding naïf ne suffit plus. Trois techniques, par complexité croissante :
|
||||||
|
|
||||||
|
### 1. Transition depuis la valeur courante
|
||||||
|
|
||||||
|
Cas simple : on accepte que la valeur finale soit la seule « vraie », l'animation est une transition entre l'état précédent et le nouvel état. Kill l'ancienne animation avant de lancer la nouvelle :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
this.AutoRun(() => {
|
||||||
|
var hp = state.Hero.Hp.Value;
|
||||||
|
DOTween.Kill(this);
|
||||||
|
DOTween.Sequence()
|
||||||
|
.Append(HealthFill.DOPunchScale(Vector3.one * 0.1f, 0.15f))
|
||||||
|
.Append(HealthFill.DOAnchorMax(new Vector2(hp / maxHp, 1f), 0.3f));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Ça couvre 70% des besoins d'UI feedback.
|
||||||
|
|
||||||
|
### 2. Animation queue + WorldEvents (changelist)
|
||||||
|
|
||||||
|
Cas multi-étapes : le GameEvent produit une **suite ordonnée** de changements qu'on veut jouer dans le temps, pas collapser vers l'état final. Le GameEvent écrit dans un changelist (`CombatStepEntry`, `DamageChange`, `HealChange`) ; le `GameManager` orchestre le rejeu :
|
||||||
|
|
||||||
|
- Le changelist est **déjà matérialisé** (combat entièrement résolu instantanément côté engine).
|
||||||
|
- Les components s'abonnent via l'EventBus aux entries qui les concernent.
|
||||||
|
- L'orchestrateur avance pas à pas, attendant la fin de l'animation la plus lente à chaque step (les subscribers rendent un `UniTask` terminant quand leur animation est finie).
|
||||||
|
- Les animations au sein d'un step sont parallèles entre components ; les steps sont séquentiels.
|
||||||
|
|
||||||
|
C'est le pattern nécessaire dès qu'il y a une temporalité dans les mutations (combat au tour par tour, chaînes de dégâts, effets déclenchés). Article : [samuel-bouchet.fr/posts/2024-12-10-animating-reactive-ui](https://samuel-bouchet.fr/posts/2024-12-10-animating-reactive-ui/).
|
||||||
|
|
||||||
|
### 3. Merge strategies et cancellation
|
||||||
|
|
||||||
|
Améliorations que j'ajoute quand le besoin apparaît :
|
||||||
|
|
||||||
|
- **Merge** : deux animations ciblant la même propriété dans le même tick sont fusionnées (ou la plus récente gagne, avec un petit DOTween `.Kill()`).
|
||||||
|
- **Groupement visuel** : des entries liées (tous les dégâts d'un coup critique) peuvent être jouées simultanément plutôt que séquentiellement.
|
||||||
|
- **CancellationToken propagé** : un rush du joueur (« passer l'animation ») cancel toute la queue et skippe à l'état final.
|
||||||
|
|
||||||
|
### Observable seul vs WorldEvents : quand choisir quoi
|
||||||
|
|
||||||
|
| Besoin | Observable (Signal) | WorldEvent / changelist |
|
||||||
|
|---|---|---|
|
||||||
|
| UI bindée 1:1 sur un état courant | ✅ | inutilement lourd |
|
||||||
|
| Animation entre deux valeurs | ✅ (kill + tween) | overkill |
|
||||||
|
| Séquence temporelle multi-étapes | ❌ (perd les étapes intermédiaires) | ✅ |
|
||||||
|
| Replay, networking, bots | ❌ | ✅ |
|
||||||
|
| Chargement d'un snapshot | ✅ | nécessite rejouer depuis 0 |
|
||||||
|
|
||||||
|
Règle pratique : **les Signals décrivent « où on en est », les WorldEvents décrivent « ce qui vient de se passer »**. Les deux coexistent dans mes projets, avec des responsabilités claires.
|
||||||
|
|
||||||
|
## Intégration réseau (client-serveur)
|
||||||
|
|
||||||
|
Le même event-sourcing fonctionne en multijoueur :
|
||||||
|
|
||||||
|
- Les GameEvent sont serialisés en MessagePack et broadcastés.
|
||||||
|
- Le client applique **optimistiquement** ses events et les envoie au serveur.
|
||||||
|
- Le serveur **valide** via `AssertApplicationConditions` puis re-broadcast.
|
||||||
|
- En cas de désaccord, le client **rollback + rejoue** dans l'ordre.
|
||||||
|
|
||||||
|
Trois types de messages (documentés dans `INetworkMessage`) :
|
||||||
|
|
||||||
|
- **GameEvent** : mutation bidirectionnelle (client ou serveur), validée par serveur, optimistiquement appliquée côté client.
|
||||||
|
- **Command** : client → serveur, demande d'opération. Serveur répond par `AckResponse`.
|
||||||
|
- **Query** : client → serveur, demande de donnée pure (pas de side-effect). Serveur répond par la response appropriée.
|
||||||
|
|
||||||
|
Référence : [State Synchronization - Gaffer On Games](https://gafferongames.com/post/state_synchronization/).
|
||||||
|
|
||||||
|
Voir aussi [`a-trier.md`](a-trier.md) pour les détails de la couche `InputMessage`/`OutputMessage` et la structure multijoueur.
|
||||||
|
|
||||||
|
## Règles d'usage au quotidien
|
||||||
|
|
||||||
|
- **Écrire dans l'état = créer un GameEvent**. Jamais d'affectation directe depuis un Display, un Editor script, un bouton UI.
|
||||||
|
- **Lire l'état = souscrire via AutoRun** (ou consultation ponctuelle si on est sûr qu'on n'a pas besoin de réactivité).
|
||||||
|
- **Chaque Signal a une raison d'être observable**. Si personne ne s'abonne jamais, c'est un champ normal.
|
||||||
|
- **Un `ConnectedBehaviour` par concept affiché**, pas par écran. Composition > centralisation.
|
||||||
|
- **Toutes les souscriptions sont cancellées** au reset ou au destroy. Jamais de leak.
|
||||||
|
- **Un GameEvent est pur** dans son `DoApply` : il ne fait pas d'I/O, pas d'attente, pas de `Random` non-seedé.
|
||||||
|
|
||||||
|
## Anti-patterns à éviter
|
||||||
|
|
||||||
|
- Abonner un callback Signal sans `CancellationToken` ou equivalent — leak garanti.
|
||||||
|
- Muter un Signal depuis un `AutoRun` qui observe ce même Signal — boucle infinie.
|
||||||
|
- Dupliquer une valeur de Signal dans un champ local "pour optimiser" — les deux se désynchronisent.
|
||||||
|
- Appliquer un GameEvent depuis un autre GameEvent's `DoApply` — l'atomicité est cassée, l'exception saute.
|
||||||
|
- Mettre de la logique de balance ou de règles dans une callback EventBus — c'est un side-effect, pas une mutation.
|
||||||
|
- Créer un Signal par valeur dérivable d'un autre — préférer `Observable.Transform` ou un AutoRun qui met à jour un Signal "vue".
|
||||||
|
- Passer des `Signal<T>` comme payload d'un GameEvent — les events sont serialisables, pas les signals.
|
||||||
387
unity.md
Normal file
387
unity.md
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
# unity.md — Pratiques Unity
|
||||||
|
|
||||||
|
Ce fichier couvre mes usages Unity : structure de projet, MonoBehaviour, cycle de vie, intégration avec la logique pure, éditeur. Pour les idiomes C# généraux, voir [`csharp.md`](csharp.md). Pour l'état réactif (`Signal`, `ConnectedBehaviour`, GameEvent), voir [`reactive-state.md`](reactive-state.md).
|
||||||
|
|
||||||
|
## Principe directeur : Unity n'est qu'une couche de présentation
|
||||||
|
|
||||||
|
La règle fondamentale, qui guide tout le reste : **Unity n'est qu'une couche de présentation**. La logique du jeu vit ailleurs, dans du C# pur, testable sans l'éditeur. Unity :
|
||||||
|
|
||||||
|
- lit l'état pour le rendre (composants `*Display`, UI, animations) ;
|
||||||
|
- capte les entrées utilisateur pour les transformer en intentions (GameEvent à appliquer) ;
|
||||||
|
- orchestre le timing (sync musique, animations, frames) ;
|
||||||
|
- n'écrit jamais directement dans l'état métier.
|
||||||
|
|
||||||
|
Cette discipline se paie au début (un peu plus de plomberie), mais rapporte énormément : tests rapides, replay, bots, serveur headless, agents LLM — tout devient accessible.
|
||||||
|
|
||||||
|
## Structure de dossiers
|
||||||
|
|
||||||
|
Structure type que je réutilise :
|
||||||
|
|
||||||
|
```
|
||||||
|
Assets/
|
||||||
|
├── Engine/ # C# pur, zéro dépendance Unity. Compilable en netstandard.
|
||||||
|
│ ├── State/ # État observable (GameState, sous-états)
|
||||||
|
│ ├── GameEvents/ # Commands typées (IGameEvent + implémentations)
|
||||||
|
│ └── Tools/ # Utils pur-C# (EventBus, helpers)
|
||||||
|
│
|
||||||
|
├── Client/ # Couche Unity, réactive, passive.
|
||||||
|
│ ├── GameManager.cs # Orchestrateur principal
|
||||||
|
│ ├── Scripts/ # Composants *Display, UI
|
||||||
|
│ ├── ConnectedBehaviour.cs # Base réactive
|
||||||
|
│ └── Editor/ # Tools éditeur
|
||||||
|
│
|
||||||
|
├── Data/ # ScriptableObjects, assets
|
||||||
|
├── Scenes/ # Scènes (MainScene, CreationScene, LoadingScene)
|
||||||
|
├── Settings/ # URP, audio mixer, input actions
|
||||||
|
├── UI Toolkit/ # UXML + USS si UI Toolkit utilisé
|
||||||
|
├── Resources/ # Chargement dynamique (à minimiser)
|
||||||
|
├── Plugins/ # DLLs et SDKs tiers
|
||||||
|
└── 3rdParty/ # Packages gérés hors Package Manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Côté serveur/multijoueur, j'ajoute un projet partagé monté comme dossier dans `Assets/` (via un lien symbolique ou un submodule) :
|
||||||
|
|
||||||
|
```
|
||||||
|
TopDownVoxelsEngineUnity/
|
||||||
|
└── Assets/
|
||||||
|
└── Shared/ # Même code source que /Server/Shared
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette astuce permet d'avoir **le même code compilé deux fois** : par Unity en Mono (netstandard 2.1) et par le serveur en .NET 6+. La synchronisation client/serveur est alors garantie à la compilation.
|
||||||
|
|
||||||
|
## Assemblies et `.asmdef`
|
||||||
|
|
||||||
|
Chaque couche a son `.asmdef`. Bénéfices :
|
||||||
|
|
||||||
|
- **Dépendances explicites** : Client dépend d'Engine, pas l'inverse. Impossible de contaminer le code pur avec des imports Unity.
|
||||||
|
- **Temps de compilation** réduits : un changement dans Client ne recompile pas Engine.
|
||||||
|
- **Nullable activé par fichier** : je pose un `csc.rsp` à côté du `.asmdef` :
|
||||||
|
```
|
||||||
|
-nullable
|
||||||
|
```
|
||||||
|
- **Références explicites** aux packages et DLLs : MessagePack, TinkStateSharp, UniTask, etc. On voit qui utilise quoi.
|
||||||
|
|
||||||
|
Structure type :
|
||||||
|
|
||||||
|
```
|
||||||
|
Assets/Engine/Runemaker.Engine.asmdef
|
||||||
|
Assets/Engine/csc.rsp
|
||||||
|
Assets/Client/Runemaker.Client.asmdef
|
||||||
|
Assets/Client/csc.rsp
|
||||||
|
Assets/Client/Editor/Runemaker.Client.Editor.asmdef
|
||||||
|
```
|
||||||
|
|
||||||
|
Règle de cycle : **aucune dépendance circulaire tolérée**. Si Client a besoin d'Engine, OK ; si Engine a besoin de Client, refactor obligatoire.
|
||||||
|
|
||||||
|
## MonoBehaviour : conventions
|
||||||
|
|
||||||
|
### Références injectées via l'inspecteur
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Required]
|
||||||
|
public TextMeshProUGUI StageText = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public AudioClip RuneTransmission = null!;
|
||||||
|
|
||||||
|
[SerializeField]
|
||||||
|
private Animator _animator = null!;
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`[Required]`** (Odin ou Artificetoolkit) pour que la validation éditeur refuse de lancer la scène si le champ est oublié.
|
||||||
|
- **`null!`** pour signaler au compilateur "oui c'est null à la compilation, non c'est pas null à l'exécution — j'assume".
|
||||||
|
- **`public` si l'inspecteur doit binder et que le champ est consultable de l'extérieur**, **`[SerializeField] private`** sinon (je garde l'encapsulation par défaut).
|
||||||
|
|
||||||
|
### Cycle de vie
|
||||||
|
|
||||||
|
Ordre et rôle de chaque callback :
|
||||||
|
|
||||||
|
- **`Awake`** : initialisation qui ne dépend de **rien d'autre** (pas de `FindObjectOfType`, pas de subscribe). Juste `new()`, init de champs.
|
||||||
|
- **`OnEnable`** : souscriptions, démarrage de tâches async. Je réassigne proprement les cancellation tokens ici.
|
||||||
|
- **`Start`** : une seule fois, après que tous les `Awake` ont été appelés. Utile si je dois attendre qu'autre chose existe.
|
||||||
|
- **`OnDisable`** : cleanup réversible (unsubscribe des events globaux, arrêt de coroutines).
|
||||||
|
- **`OnDestroy`** : cleanup définitif. C'est là que je dispose les `CancellationTokenSource` et que je libère les ressources non-Unity.
|
||||||
|
|
||||||
|
Je préfère systématiquement le duo `OnEnable` / `OnDisable` à `Start` / `OnDestroy` pour les abonnements — ça gère naturellement les cas de désactivation temporaire d'un GameObject.
|
||||||
|
|
||||||
|
### `ConnectedBehaviour` : mon pattern de base
|
||||||
|
|
||||||
|
Presque tous mes composants de présentation héritent d'une base `ConnectedBehaviour` (ou `ConnectedBehaviour<T>` pour les composants paramétrés). Elle :
|
||||||
|
|
||||||
|
- résout et cache `GameManager` automatiquement (via `GetComponentInParent` puis `FindFirstObjectByType`) ;
|
||||||
|
- lance un `Setup()` async qui attend que le `GameManager` soit prêt ;
|
||||||
|
- gère un `CancellationTokenSource` réinitialisé à chaque reset, permettant aux souscriptions auto-run de se cleanup proprement ;
|
||||||
|
- expose des helpers `SubscribeEventBus<T>`, `TriggerEvent<T>`, `DisposeOnReset` ;
|
||||||
|
- force un `OnSetup(GameState state)` abstrait où l'enfant fait ses `AutoRun(...)`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public abstract class ConnectedBehaviour : MonoBehaviour, IDisposable {
|
||||||
|
protected GameManager GameManager { get; /* auto-resolved */ }
|
||||||
|
protected EventBus EventBus => GameManager.EventBus;
|
||||||
|
protected CancellationToken ResetToken { get; }
|
||||||
|
|
||||||
|
protected virtual void OnEnable() => Setup().Forget();
|
||||||
|
private async UniTask Setup() {
|
||||||
|
await UniTask.WaitUntil(() => GameManager != null, ...);
|
||||||
|
OnSetup(GameManager!.State);
|
||||||
|
}
|
||||||
|
protected virtual void OnDestroy() => Dispose();
|
||||||
|
|
||||||
|
protected abstract void OnSetup(GameState state);
|
||||||
|
|
||||||
|
public void ListenEvent<T>(Action<T> action) =>
|
||||||
|
ResetToken.Register(EventBus.For<T>().StartListening(action));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Détails plus complets dans [`reactive-state.md`](reactive-state.md).
|
||||||
|
|
||||||
|
### Convention "Display"
|
||||||
|
|
||||||
|
Les composants dont le job est de représenter une donnée portent le **suffixe `Display`** : `RuneDisplay`, `ComboDisplay`, `HealthBarDisplay`, `InventoryDisplay`.
|
||||||
|
|
||||||
|
Règle associée : **un Display n'écrit jamais dans l'état**. Il lit, il affiche, il anime, il peut émettre un GameEvent via une action utilisateur — mais il ne mute rien.
|
||||||
|
|
||||||
|
## Accès aux références : je réduis `Find*` à zéro
|
||||||
|
|
||||||
|
Les `FindObjectOfType` et autres `GameObject.Find` coûtent cher et rendent le code implicite. Je m'interdis leur appel dans un `Update()`, et je les réduis au maximum ailleurs.
|
||||||
|
|
||||||
|
Stratégies, par ordre de préférence :
|
||||||
|
|
||||||
|
1. **Référence explicite via l'inspecteur** (`[SerializeField]`, `[Required]`) — meilleure option pour 80% des cas.
|
||||||
|
2. **`GetComponentInParent<T>`** pour les composants groupés sur une hiérarchie (un `GameManager` à la racine par exemple).
|
||||||
|
3. **`FindFirstObjectByType<T>(FindObjectsInactive.Include)`** en dernier recours, **mis en cache** dans un champ privé.
|
||||||
|
4. **Dependency injection légère** : le parent appelle `.Set(data)` sur l'enfant.
|
||||||
|
|
||||||
|
Le pattern typique que j'utilise dans `ConnectedBehaviour` :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private GameManager? _gameManager = null;
|
||||||
|
protected GameManager GameManager {
|
||||||
|
get {
|
||||||
|
if (_gameManager is null) _gameManager = GetComponentInParent<GameManager>(true);
|
||||||
|
if (_gameManager is null) _gameManager = FindFirstObjectByType<GameManager>(FindObjectsInactive.Include);
|
||||||
|
return _gameManager;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
C'est une DI de pauvre, mais elle suffit. Pas besoin de Zenject ou VContainer pour mes projets.
|
||||||
|
|
||||||
|
## Prefabs et scènes
|
||||||
|
|
||||||
|
- **Prefabs variants** pour les familles d'objets similaires (runes, blocs, ennemis).
|
||||||
|
- **Une scène principale** (`MainScene`) avec un `GameManager` persistant. Les scènes secondaires (`LoadingScene`, `CreationScene`) sont chargées additivement si besoin.
|
||||||
|
- **Scènes validées** via Odin Validator côté éditeur — une scène qui ne passe pas le validator ne se commit pas.
|
||||||
|
- **Pas d'état métier dans la scène**. Le `GameState` est sérialisé à part, pas dans les prefabs.
|
||||||
|
|
||||||
|
### Nested prefabs : à éviter en profondeur
|
||||||
|
|
||||||
|
Je limite la profondeur d'imbrication des prefabs à **2 niveaux maximum**, et idéalement à **1 seul** pour tout prefab contenant de la logique métier. La raison est conceptuelle, pas technique : chaque édition force à se demander à quel niveau la donnée doit vivre. Est-ce que je modifie la scène ? Le prefab parent ? Le prefab enfant ? Avec trois niveaux, debug un override devient un exercice de fouille. Repères :
|
||||||
|
|
||||||
|
- **1 niveau (idéal)** : un prefab complet, configuration dans un seul endroit.
|
||||||
|
- **2 niveaux (toléré)** : des composants UI basiques (boutons, champs texte, barres) réutilisés comme briques dans des prefabs spécialisés.
|
||||||
|
- **3+ niveaux (à refuser)** : des prefabs avec leur propre logique métier empilés. La complexité cognitive explose, le bénéfice de modularité s'évapore.
|
||||||
|
|
||||||
|
Article détaillé : [samuel-bouchet.fr/posts/2025-04-07-unity-prefabs-nested-complexity](https://samuel-bouchet.fr/posts/2025-04-07-unity-prefabs-nested-complexity/).
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Selon le projet :
|
||||||
|
|
||||||
|
- **UI Toolkit (UXML + USS)** pour les UIs riches, les menus structurés, les inspecteurs custom. `UI Toolkit/` contient les `.uxml` et `.uss`.
|
||||||
|
- **UGUI** pour les HUD rapides, les overlays simples, les cas où l'intégration visuelle 3D prime.
|
||||||
|
|
||||||
|
Règles communes :
|
||||||
|
|
||||||
|
- **Binding réactif** : chaque champ de texte / barre / toggle est setter depuis un `AutoRun` qui observe un `Signal<T>`. L'UI est dérivée, pas source.
|
||||||
|
- **Pas de logique dans les callbacks UI**. Un clic = un `TriggerEvent` ou un GameEvent appliqué, la logique vit dans l'Engine.
|
||||||
|
- **Animations** pilotées par le changelist : un GameEvent produit une suite de `ChangeEntry`, `GameManager.Orchestrate(...)` les rejoue synchronisées à la musique via PrimeTween ou DOTween.
|
||||||
|
|
||||||
|
### Canvas strategies (UGUI multi-résolution)
|
||||||
|
|
||||||
|
Pour supporter plusieurs ratios d'écran sans ré-implémenter du responsive partout, deux stratégies que je réutilise (choisir par projet, pas mélanger) :
|
||||||
|
|
||||||
|
- **Fixed Layout avec Expand** : `Canvas Scaler` en `Scale With Screen Size` + Screen Match Mode = Expand. Le contenu est ancré au centre, la référence est une résolution cible (typiquement 1920×1080 ou 1080×1920 selon mobile/desktop). Letterboxing accepté, aucune logique responsive à écrire. Parfait pour menus et HUDs secondaires.
|
||||||
|
- **Fluid Layout avec Expand** : mêmes réglages canvas, mais contenu stretché aux bords de l'écran. L'Expand garantit qu'aucune dimension ne tombe sous la résolution de référence. Bon compromis pour les écrans de gameplay qui doivent remplir l'espace.
|
||||||
|
|
||||||
|
Mode de canvas préféré : **Screen Space - Overlay** pour 80% des cas. **Screen Space - Camera** uniquement quand je veux des effets de caméra (tilts, shaders fullscreen post-UI). Article détaillé : [samuel-bouchet.fr/posts/2024-10-11-unity-canvas-strategies](https://samuel-bouchet.fr/posts/2024-10-11-unity-canvas-strategies/unity-canvas-strategies/).
|
||||||
|
|
||||||
|
### Click Occlusion Debugger
|
||||||
|
|
||||||
|
Outil maison pour déboguer les « clics qui ne marchent pas » : un composant qui fait un `EventSystem.RaycastAll` à la position du pointeur et affiche la hiérarchie complète du GameObject topmost dans un `TextMeshProUGUI`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var pointer = new PointerEventData(EventSystem.current) {
|
||||||
|
position = Input.mousePosition
|
||||||
|
};
|
||||||
|
var results = new List<RaycastResult>();
|
||||||
|
EventSystem.current.RaycastAll(pointer, results);
|
||||||
|
```
|
||||||
|
|
||||||
|
Piège classique : oublier d'ajouter un `GraphicRaycaster` sur le Canvas rend tous les raycasts vides. Le composant est tagué `EditorOnly` pour ne pas être shippé. Article : [samuel-bouchet.fr/posts/2025-01-21-ClickOcclusionDebugger](https://samuel-bouchet.fr/posts/2025-01-21-ClickOcclusionDebugger/).
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
**Unity Input System** (nouveau), fichier `.inputactions` pour les mappings. Je préfère les `PlayerInput` en mode "Send Messages" ou "Invoke C# Events" selon la densité — pas de polling `Input.GetKey` dans un `Update()` s'il existe une alternative événementielle.
|
||||||
|
|
||||||
|
### Workflow « Actions Asset » (Neoproxima)
|
||||||
|
|
||||||
|
Pour un projet avec gamepad + clavier/souris + UI, j'utilise le workflow **Actions Asset** (et sa **code generation**, pas les callbacks runtime). L'asset `InputControls.inputactions` contient des action maps dédiées :
|
||||||
|
|
||||||
|
- `All` — raccourcis globaux (pause, screenshot).
|
||||||
|
- `Exploration` — contrôles véhicule / personnage, actions contextuelles de gameplay.
|
||||||
|
- `UI` — branchée sur l'EventSystem d'Unity (Submit, Cancel, Navigate).
|
||||||
|
- `Debug` — toggles et raccourcis dev only.
|
||||||
|
|
||||||
|
Un wrapper singleton (`Inputs.cs`) expose l'instance générée ; les composants gameplay héritent d'un `ConnectedMonoBehaviour` qui fournit des helpers typés :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
OnInputActionPerformed(Inputs.Exploration.ResetLoop, _ => ResetLoop());
|
||||||
|
```
|
||||||
|
|
||||||
|
La souscription est liée au cancellation token du lifecycle, avec throttle optionnel pour éviter le spam (double-click involontaire sur gamepad).
|
||||||
|
|
||||||
|
Une classe `InputSchema` observe le dernier device utilisé (gamepad vs clavier/souris) et un `InputActionToVisual` swap dynamiquement les sprites de prompts contextuels (icônes gamepad ↔ icônes clavier). Article détaillé : [samuel-bouchet.fr/posts/2024-09-24-neoproxima-highlight-input-system](https://samuel-bouchet.fr/posts/2024-09-24-neoproxima-highlight-input-system/).
|
||||||
|
|
||||||
|
Pourquoi pas Rewired : l'Input System officiel (1.6+) est désormais assez robuste, la code generation rend le binding refactor-safe, et un outil maintenu par Unity évite la dette externe à long terme.
|
||||||
|
|
||||||
|
## Audio
|
||||||
|
|
||||||
|
Audio Mixer central (`Main.mixer`) avec des groupes routés (Music, SFX, UI, Master). Les volumes sont exposés comme paramètres et bindés à un Signal dans les options.
|
||||||
|
|
||||||
|
Sync musique/gameplay : quand c'est thématique (ex. Rune Maker — combat sur 120 BPM ternaire 3/4), le GameManager expose une référence temporelle au beat. Les animations et les feedback sonores alignent leur timing dessus. Voir `GDD.md` du projet concerné pour les specs précises.
|
||||||
|
|
||||||
|
## ScriptableObjects
|
||||||
|
|
||||||
|
J'en utilise peu. Ma préférence va à des **factories C# pures** (`RuneFactory`, `RuneDungeon`) où la donnée est du code versionné, typé, testable, refactorable par l'IDE.
|
||||||
|
|
||||||
|
ScriptableObject seulement quand :
|
||||||
|
|
||||||
|
- les game designers non-devs doivent éditer les valeurs dans l'éditeur (rare sur mes projets solos / petite équipe) ;
|
||||||
|
- l'asset doit être référencé par plusieurs prefabs de façon partagée (config globale type volume profile URP) ;
|
||||||
|
- l'asset contient des références à d'autres assets Unity (sprites, prefabs, clips) — du code C# ne peut pas les tenir.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Deux niveaux :
|
||||||
|
|
||||||
|
- **Tests d'Engine**, lancés en `dotnet test` depuis un projet `Tests/` séparé qui référence l'Engine comme DLL ou dossier partagé. Pas de Unity, pas de Play Mode, juste NUnit :
|
||||||
|
```bash
|
||||||
|
cd Tests
|
||||||
|
dotnet build
|
||||||
|
dotnet test
|
||||||
|
dotnet test --filter "FullyQualifiedName~CombatTests"
|
||||||
|
dotnet test --filter "FullyQualifiedName~CombatTests.TestBasicCombat_HeroVsTwoEnemies"
|
||||||
|
```
|
||||||
|
- **Unity Test Framework** (Edit Mode + Play Mode) uniquement pour ce qui dépend vraiment d'Unity (coroutines, prefabs, physics). Beaucoup moins nombreux que les tests d'Engine.
|
||||||
|
|
||||||
|
Le couple **`Fixtures.CreateInitialState(...)`** + **tests Arrange/Act/Assert** nommés explicitement :
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[TestFixture]
|
||||||
|
public class GameEventTests {
|
||||||
|
[Test]
|
||||||
|
public void PickLootGameEvent_AddsGoldToPlayer() {
|
||||||
|
// Arrange
|
||||||
|
var state = Fixtures.CreateInitialState(Fixtures.ShallowPool, Fixtures.TestRunes);
|
||||||
|
state.CurrentRun.CurrentPhase = Phase.Encounter;
|
||||||
|
var loot = new Loot(50, false);
|
||||||
|
state.CurrentRun.LootBox.Add(loot);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var evt = new PickLootGameEvent(loot);
|
||||||
|
evt.Apply(state, null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(state.CurrentRun.Gold, Is.EqualTo(initialGold + 50));
|
||||||
|
Assert.That(state.CurrentRun.LootBox.Count, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Convention de nommage : `MethodOrEvent_Scenario_ExpectedResult`. Plus verbeux qu'un test "snake_case", mais très lisible dans le runner.
|
||||||
|
|
||||||
|
## Packages et NuGet
|
||||||
|
|
||||||
|
- **Unity Package Manager** pour les packages officiels Unity.
|
||||||
|
- **NugetForUnity** (avec `NuGet.config` versionné dans `Assets/`) pour les librairies NuGet consommées côté Unity : MessagePack, Artificetoolkit, etc.
|
||||||
|
- **`.csproj` Directory.Build.props** pour les projets .NET purs.
|
||||||
|
|
||||||
|
Je versionne le `NuGet.config` et le `packages.config` pour que le bootstrap d'un clone frais soit reproductible.
|
||||||
|
|
||||||
|
## Editor : validation et outillage
|
||||||
|
|
||||||
|
- **Odin Validator** scanne les scènes/prefabs et refuse les `[Required]` non remplis, les configurations invalides. J'intègre cette validation en pré-commit quand c'est possible.
|
||||||
|
- **Custom editors** pour les composants où l'inspecteur par défaut est pénible (`GameManagerEditor`, `RuneGraphLayoutEditor`). Je ne les écris que si j'y gagne vraiment en workflow.
|
||||||
|
- **`[ContextMenu]`** pour les actions de debug rapides :
|
||||||
|
```csharp
|
||||||
|
[ContextMenu("Force ConnectedBehaviour Reset")]
|
||||||
|
private void ForceRefresh() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intégration avec le code partagé / multijoueur
|
||||||
|
|
||||||
|
Quand un projet est multi (client Unity + serveur .NET), la règle est : **le code `Shared/` doit compiler sur les deux cibles**. Conséquences pratiques :
|
||||||
|
|
||||||
|
- Pas d'API Unity (`UnityEngine.*`) dans `Shared/`.
|
||||||
|
- Les attributs Sirenix/Odin sont référencés via une DLL dédiée côté serveur pour que `Shared/` puisse les porter :
|
||||||
|
```xml
|
||||||
|
<Reference Include="Sirenix.OdinInspector.Attributes, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
|
||||||
|
<HintPath>..\Plugins\Sirenix\Assemblies\Sirenix.OdinInspector.Attributes.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
```
|
||||||
|
- Un `Directory.Build.props` côté serveur fait copier les artefacts `bin/` / `obj/` en dehors de `Assets/` pour qu'Unity ne les ingère pas.
|
||||||
|
- Nullable activé partout, mais un `Directory.Build.props` local permet de désactiver là où du tiers non-compliant est inclus.
|
||||||
|
|
||||||
|
Voir le `README.md` du projet pour le détail des .csproj et pièges de compilation.
|
||||||
|
|
||||||
|
## Setup projet reproductible
|
||||||
|
|
||||||
|
Pour un nouveau clone, j'écris systématiquement les commandes de bootstrap dans le `CLAUDE.md` ou `README.md` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --global core.symlinks true
|
||||||
|
# Dans Unity : NuGet > Restore packages
|
||||||
|
dotnet tool install -g MessagePack.Generator
|
||||||
|
dotnet tool install --global dotnet-ef # si DB
|
||||||
|
```
|
||||||
|
|
||||||
|
Et après ajout de nouveaux types sérialisés :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mpc -i D:\projets\TheRuneMaker\Assets -o "Assets/Client/Scripts/MessagePackGenerated.cs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Si un nouveau développeur ne peut pas cloner + lancer en 15 minutes, le README est à refaire.
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
Côté client WebGPU :
|
||||||
|
|
||||||
|
- Build standard Unity.
|
||||||
|
- Script batch `deploy_web_build.bat` qui fait le rsync/scp vers le serveur de staging/prod. Pas de magie, pas de CI externalisée dans les petits projets — un `.bat` versionné et c'est réglé.
|
||||||
|
|
||||||
|
Côté iOS / App Store Connect :
|
||||||
|
|
||||||
|
- **Unity Cloud Build** (Build Automation) pour éviter d'avoir un Mac dédié.
|
||||||
|
- Clé API App Store Connect convertie en JSON fastlane-compatible (`key_id`, `issuer_id`, `key`).
|
||||||
|
- Script de post-build déclenche `fastlane deliver` pour uploader l'IPA.
|
||||||
|
- Piège classique : Unity remplace les `\n` de la clé privée par des espaces, un pre-step dans le script restaure les retours à la ligne avant de passer la clé à fastlane.
|
||||||
|
- Un `BuildVersion` ScriptableObject dérive automatiquement `Major.Minor.Patch+build` depuis les tags git (`v1.0`), calcule le build number depuis l'historique.
|
||||||
|
- Coût : ~1 €/build iOS chez Unity Cloud Build — négligeable sur un projet à cadence humaine.
|
||||||
|
|
||||||
|
Article détaillé : [samuel-bouchet.fr/posts/2025-03-24-unity-automated-appstoreconnect-upload](https://samuel-bouchet.fr/posts/2025-03-24-unity-automated-appstoreconnect-upload/).
|
||||||
|
|
||||||
|
Côté serveur, voir [`a-trier.md`](a-trier.md) pour la section Docker.
|
||||||
|
|
||||||
|
## Anti-patterns Unity que je refuse
|
||||||
|
|
||||||
|
- Toucher au `GameState` (ou équivalent métier) depuis un MonoBehaviour autrement que via un GameEvent.
|
||||||
|
- `FindObjectOfType` dans `Update()`.
|
||||||
|
- `Coroutine` pour de l'asynchrone structuré (les `UniTask` sont plus propres, annulables, composables).
|
||||||
|
- `OnGUI` dans du code runtime (sauf pour debug).
|
||||||
|
- Des prefabs qui se référencent l'un l'autre en dur pour des données métier (passer par la factory).
|
||||||
|
- `DontDestroyOnLoad` utilisé comme un singleton global magique — je préfère un GameManager explicite en racine.
|
||||||
|
- Des scripts dans `Assets/Resources/` juste "pour pouvoir les charger par nom".
|
||||||
|
- Des `.meta` modifiés sans raison qui polluent les diffs.
|
||||||
Loading…
Add table
Reference in a new issue