21 KiB
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. Pour l'état réactif (Signal, ConnectedBehaviour, GameEvent), voir 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
[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".publicsi l'inspecteur doit binder et que le champ est consultable de l'extérieur,[SerializeField] privatesinon (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 deFindObjectOfType, pas de subscribe). Justenew(), 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 lesAwakeont é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 lesCancellationTokenSourceet 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
GameManagerautomatiquement (viaGetComponentInParentpuisFindFirstObjectByType) ; - lance un
Setup()async qui attend que leGameManagersoit prêt ; - gère un
CancellationTokenSourceré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 sesAutoRun(...).
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.
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 :
- Référence explicite via l'inspecteur (
[SerializeField],[Required]) — meilleure option pour 80% des cas. GetComponentInParent<T>pour les composants groupés sur une hiérarchie (unGameManagerà la racine par exemple).FindFirstObjectByType<T>(FindObjectsInactive.Include)en dernier recours, mis en cache dans un champ privé.- Dependency injection légère : le parent appelle
.Set(data)sur l'enfant.
Le pattern typique que j'utilise dans ConnectedBehaviour :
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 unGameManagerpersistant. 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
GameStateest 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.
UI
Selon le projet :
- UI Toolkit (UXML + USS) pour les UIs riches, les menus structurés, les inspecteurs custom.
UI Toolkit/contient les.uxmlet.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
AutoRunqui observe unSignal<T>. L'UI est dérivée, pas source. - Pas de logique dans les callbacks UI. Un clic = un
TriggerEventou 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 ScalerenScale 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.
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.
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.
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 :
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.
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 testdepuis un projetTests/séparé qui référence l'Engine comme DLL ou dossier partagé. Pas de Unity, pas de Play Mode, juste NUnit :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 :
[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.configversionné dansAssets/) pour les librairies NuGet consommées côté Unity : MessagePack, Artificetoolkit, etc. .csprojDirectory.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 :[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.*) dansShared/. - Les attributs Sirenix/Odin sont référencés via une DLL dédiée côté serveur pour que
Shared/puisse les porter :<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.propscôté serveur fait copier les artefactsbin//obj/en dehors deAssets/pour qu'Unity ne les ingère pas. - Nullable activé partout, mais un
Directory.Build.propslocal 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 :
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 :
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.batqui fait le rsync/scp vers le serveur de staging/prod. Pas de magie, pas de CI externalisée dans les petits projets — un.batversionné 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 deliverpour uploader l'IPA. - Piège classique : Unity remplace les
\nde 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
BuildVersionScriptableObject dérive automatiquementMajor.Minor.Patch+builddepuis 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.
Côté serveur, voir 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. FindObjectOfTypedansUpdate().Coroutinepour de l'asynchrone structuré (lesUniTasksont plus propres, annulables, composables).OnGUIdans 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).
DontDestroyOnLoadutilisé 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
.metamodifiés sans raison qui polluent les diffs.