# 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` 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`, `TriggerEvent`, `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(Action action) => ResetToken.Register(EventBus.For().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`** pour les composants groupés sur une hiérarchie (un `GameManager` à la racine par exemple). 3. **`FindFirstObjectByType(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(true); if (_gameManager is null) _gameManager = FindFirstObjectByType(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`. 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(); 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 ..\Plugins\Sirenix\Assemblies\Sirenix.OdinInspector.Attributes.dll ``` - 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.