13 KiB
name, description, metadata
| name | description | metadata | ||
|---|---|---|---|---|
| multiplayer-migration | Migration du jeu solo vers le multijoueur via FishNet + Steam (en cours, démarré juin 2026) |
|
Le jeu de survie Ashwild passe en multijoueur coop. Stack réseau choisie :
- FishNet 4.7.2 (déjà installé dans
Assets/FishNet/) - Steamworks.NET (wrapper API Steam) — choisi plutôt que Facepunch.Steamworks
- FishySteamworks (transport FishNet, utilise SteamNetworkingSockets → relais Steam, pas de port forwarding)
Objectif matchmaking : lobby Steam offrant à la fois
- un code de session partageable, et
- les invitations Steam natives (overlay « Inviter », clic droit → « Rejoindre » dans la liste d'amis, lancement via invitation).
Contraintes projet : bêta-testeurs dans ~2 semaines → priorité stabilité. Tout passe par le bus PlayerEvents (voir CLAUDE.md).
App IDs Steam (dans steam_appid.txt à la racine) :
- App ID actif =
4282850= vrai jeu « Aetheris » (app privée/incomplète sur Steamworks). Choisi pour les tests internes : le user préfère tester sur son propre jeu Steam. Chaque testeur doit avoir accès à l'app (collaborateur Steamworks ou app en bibliothèque), sinonSteamAPI.Init()échoue chez lui. 480(Spacewar) = alternative de dev (accès gratuit pour tous) si besoin de tester avec des comptes sans accès à l'app.
Phase 1 (fondation) — code écrit, scripts dans Assets/GAME/Script/Network/ :
GameSession.cs: holder statique (slot choisi, Host/Join, code) qui survit au chargement de scène.NetworkSessionManager.cs: singleton (sur le GameObject NetworkManager).HostSession()/JoinSession(code)/StopSession(). Démarre server+client, charge la GameScene en réseau viaSceneManager.LoadGlobalScenes(ReplaceOption.All), génère le code = SteamID64, logs[NetworkSession], branché sur le bus.PlayerSpawner.cs: sur le NetworkManager. S'abonne àOnClientLoadedStartScenes, spawn le prefab joueur (NetworkObject) par connexion à unPlayerSpawnPoint.PlayerSpawnPoint.cs: marqueur de point de spawn (avec gizmo).PlayerNetworkController.cs: NetworkBehaviour sur le joueur. EnOnStartClient, n'active les composants « owner-only » (input, caméra, audio, stats, inventaire…) que siIsOwner. Liste à câbler dans l'inspector.SavePanel.LoadGamemodifié : appelleNetworkSessionManager.Instance.HostSession()(repliSceneManager.LoadScenesi pas câblé).- Bus
PlayerEventsétendu : étatsIsOnline/IsHost/SessionCode+ eventsSessionStarting/Started/Joining/Joined/Stopped/Error,RemotePlayerCountChanged,LocalPlayerSpawned.
Correctif Phase 1 (NRE au spawn) : les UI de scène (HotbarUI, InventoryUI, CraftingUI, DeathScreen, PlayerStatsUI) lisaient PlayerInventory.Instance/PlayerStats.Instance dans Start() → NRE car le joueur est spawné par le réseau APRÈS le chargement de scène. Corrigé : init dépendante du joueur différée via PlayerEvents.LocalPlayerSpawned (+ flag bound, + repli si l'Instance existe déjà au Start).
Dette technique Phase 2 (validée avec le user) : le pattern « chaque UI attend LocalPlayerSpawned puis se bind aux singletons » marche mais répète du boilerplate. Version propre à faire plus tard : router les UI uniquement via le bus statique PlayerEvents (events HealthChanged/HungerChanged/ThirstChanged/InventorySlotChanged/SelectedHotbarSlotChanged existent déjà) au lieu des singletons .Instance + leurs UnityEvent d'instance → supprime tout le problème de timing. Option : classe de base PlayerBoundUI si on garde le pattern actuel.
Refacto Crafting (couplage UI/manager, validé user) : CraftingUI est devenue une vue 100% bête (reçoit des délégués canCraft/countItem/craft du manager, ne connaît plus ni PlayerInventory ni CraftingManager). CraftingManager est le contrôleur : possède recipes + une réf craftingUI, Build() la vue, gère l'attente joueur + refresh sur changement d'inventaire. Manip Unity : assigner Crafting UI sur le CraftingManager.
Pickables en réseau (Phase 1) : Pickable est passé en NetworkBehaviour. Ramassage autoritaire serveur : Interact()→Pickup()→RequestPickupServerRpc(NetworkConnection conn) (RequireOwnership=false) → le serveur résout l'inventaire du joueur via conn.FirstObject.GetComponent<PlayerInventory>() (PAS le singleton), AddItem, puis Despawn() (garde taken anti-double). PlayerInventory.DropItem spawne désormais le pickable via InstanceFinder.ServerManager.Spawn quand il est serveur (sinon warning + abort, Phase 2). Manips Unity : ajouter NetworkObject aux prefabs/instances Pickable + drop prefabs (item.WorldPrefab), et Refresh Default Prefabs pour les drops.
- Limite Phase 2 : pour un client distant, l'
AddItemse fait côté serveur sur sa copie dePlayerInventory→ ne remonte pas tant que l'inventaire n'est pas réseau ; le type d'item d'un drop runtime ne se réplique pas aux autres clients sans item-id SyncVar.
Phase 2 — Inventaire réseau (approche CLIENT-AUTHORITATIVE, validée user) : pas de SyncList serveur-auth (trop lourd + faudrait réseauter le crafting). À la place :
ItemDatabase(Resources/ItemDatabase.asset) = registreid ↔ ItemData, rempli par Tools ▸ Ashwild ▸ Rebuild Item Database (ItemDatabaseBuilder). Nécessaire car on ne peut pas envoyer un ScriptableObject sur le réseau.PlayerInventoryest passéMonoBehaviour→NetworkBehaviour. Slots restent locaux (tout le code crafting/use/hotbar inchangé). Singleton posé uniquement pour l'owner enOnStartNetwork(plus de clobber Awake destructeur).GrantItemFromServer(item,qty)(serveur) →TargetGrantItem(owner) →AddItemlocal.DropItem→SpawnDropServerRpc(serveur spawn le pickable). AjoutCanFit().Pickable:RequestPickupServerRpc→ serveur résout l'inventaire viaconn.FirstObject,GrantItemFromServer,Despawn. SyncVarsyncItemIdpour que les drops s'affichent correctement chez les autres clients. Pré-checkCanFitcôté client avant de demander.- Compromis assumé : inventaire pas anti-triche (ok coop entre amis).
Pickups UNIFIÉS (validé user : un seul script pour tout) : on ne peut PAS faire de chaque objet un NetworkObject (limite FishNet 254 imbriqués + trop lourd pour le scatter de masse). Pickable ET ScatterPickup SUPPRIMÉS, remplacés par UN seul WorldPickup + le registre :
WorldPickup(MonoBehaviour, PAS réseau, IInteractable) : pour TOUT (scatter, posés main, drops).id >= 0= pickup de scène (baké) ;id < 0= drop runtime.Interact→registry.RequestPickupServerRpc(id).HideAsClaimed()/InitializeAsDrop().WorldPickupRegistry(NetworkBehaviour, 1 par scène, dansAssets/GAME/Script/Network/) :SyncHashSet<int> pickedSceneIds+SyncDictionary<int,DropRecord> activeDrops.RequestPickupServerRpc(id)(serveur résout l'item depuis sa copie de scène ou le record drop,GrantItemFromServer).RequestDropServerRpc(itemId,qty,pos,rot)(serveur ajoute à activeDrops). OnChange/OnStartClient → cache scène / instancie ou détruit les visuels de drop localement.WorldPickupIdAssigner(éditeur) : Tools ▸ Ashwild ▸ Assign World Pickup IDs.PlayerInventory.DropItem→WorldPickupRegistry.Instance.RequestDropServerRpc(...)(plus de SpawnDropServerRpc/Pickable).- Conséquence : UN seul prefab par item suffit (le même
WorldPickupsert au scatter ET deItemData.WorldPrefabpour les drops). - Renommé ensuite
WorldPickup→Pickable,WorldPickupRegistry→PickableRegistry.
Base partagée + Harvestables réseau (validé user : code clean, polymorphisme, pas de god-object) :
WorldObject(abstract MonoBehaviour) : base de tous les objets de monde locaux (id baké,HideAsInactive(bool fresh),ShowActive(),GetRegistry()abstrait,ShouldAutoRegister,SetId). DossierNetwork/.WorldObjectRegistry(abstract NetworkBehaviour, RequireComponent NetworkObject) : plomberie commune =SyncHashSet<int> inactiveIds, dictregistered, catch-up OnStartClient, OnChange→hide/show,MarkInactive/MarkActive,ResolveInventory.Awake/OnDestroyvirtuels (le singleton typé est posé par la sous-classe).Pickable : WorldObject+PickableRegistry : WorldObjectRegistry(ajouteactiveDropsSyncDictionary + RPC pickup/drop).Harvestable : WorldObject: devient data+feedback (MaxHealth/CanRespawn/RespawnDelay/RollDrops/PlayHitEffect/PlayBlockedFeedback) ; vie/depletion/respawn RETIRÉS (→ registre).HideAsInactive(fresh)joueonDepletedseulement sifresh.HarvestableRegistry : WorldObjectRegistry: vie serveur (Dictionary<int,float>),RequestHitServerRpc(id,damage,toolId,hitDir)(dégâts client-auth, loot+état serveur-auth),PlayHitObserversRpc(feedback chez tous SAUF le frappeur viaLocalConnection.ClientId), respawn coroutine.ToolBehaviour.TryHarvest: blocked→local ; sinonPlayHitEffectlocal immédiat +HarvestableRegistry.Instance.RequestHitServerRpc(...).- Outil unique Tools ▸ Ashwild ▸ Assign World Object IDs (
WorldObjectIdAssigner, remplace PickableIdAssigner) : ids sur TOUS lesWorldObject(Pickable+Harvestable). - Risque à valider au 1er compile : SyncTypes répartis base/sous-classe NetworkBehaviour (héritage non générique — normalement OK ; repli = remettre les SyncTypes dans les sous-classes).
- Ennemis = hors registre (NetworkObjects mobiles, futur).
- Manips Unity : poser 1
PickableRegistry+ 1HarvestableRegistrydans TestScene ;Pickable/Harvestablesur les prefabs (id -1) ; (re)placer → Assign World Object IDs → save scene.
✅ PlayerStats networké (FAIT, juin 2026) : PlayerStats est passé MonoBehaviour→NetworkBehaviour, même pattern que PlayerInventory (client-authoritative). Awake destructeur SUPPRIMÉ ; Instance posé owner-only en OnStartNetwork, nettoyé en OnStopNetwork. Simulation (drain/dégâts/regen) inchangée mais gardée par if (IsSpawned && !IsOwner) return; (remotes ne simulent pas ; hors-ligne IsSpawned=false → tourne quand même pour les test scenes). Ajout SyncVars netHealth/netHunger/netThirst/netDead : l'owner pousse un snapshot via PushStatsServerRpc (throttle replicationInterval=0.2s + push immédiat sur dégâts/mort/revive) → les autres clients mirrorent via OnChange (met à jour currentHealth local + invoke les UnityEvent d'instance, JAMAIS le bus statique PlayerEvents). OnStartServer seed les SyncVars à max pour les late joiners. → barres de vie d'équipiers possibles plus tard en lisant playerStats.HealthNormalized sur le NetworkObject distant. Manip Unity : vérifier que PlayerStats est bien dans la liste ownerOnlyBehaviours du PlayerNetworkController, rouvrir Unity pour le codegen FishNet.
Fix PlayerEvents (FAIT) : ajout ResetPlayerState() appelé dans RaiseSessionStopped → reset des flags joueur-local statiques (IsDead/IsInventoryOpen/IsPaused/IsGrounded…) entre deux sessions (sinon mourir puis quitter laissait l'état collé).
✅ Audit gating IsOwner (FAIT) : le prefab Player.prefab confirme que PlayerNetworkController.ownerOnlyBehaviours contient TOUS les systèmes joueur (PlayerInputRouter, PlayerInput, PlayerLocomotion, PlayerCamera, PlayerToolHolder, PlayerStats, PlayerInventory, HotbarController, PlayerInteractor, PlayerAudio, PlayerLifecycle) et ownerOnlyObjects = Main Camera + AudioListener + Tools + Tools Camera. Donc les puppets distants sont passifs (désactivés). Gating solide. Held items (ToolBehaviour/ConsumableBehaviour) sous le holder Tools (désactivé sur remotes) → OK.
- Seul trou trouvé + corrigé :
DeathScreen.BindToPlayerfaisaitFindFirstObjectByType<PlayerLifecycle>()→ trouve aussi les Behaviours désactivés tant que leur GameObject est actif (les puppets distants le restent) → pouvait respawn le mauvais joueur. Remplacé parPlayerStats.Instance.GetComponent<PlayerLifecycle>()(joueur local garanti). - Latent mineur restant :
PlayerInventory.StartfaitFindAnyObjectByType<InventoryUI>()(UI de scène unique, pas un objet joueur → OK mais fragile). Limite rejoin :DeathScreen/UI gardentbound=trueaprès 1er spawn → pas de rebind si le joueur local despawn/respawn dans la même session.
Coquille corrigée : en réécrivant PlayerStats, j'avais redéclaré public enum DamageType en bas du fichier → CS0101 (il vit déjà en bas de PlayerEvents.cs). Retiré.
Manips Unity restantes : prefab→NetworkObject+NetworkTransform (déjà sur le prefab : NetworkObject + NetworkTransform clientAuthoritative présents ✓), NetworkManager dans MenuScene, retirer le joueur en dur de GameScene, PlayerSpawnPoint, enregistrer le prefab dans DefaultPrefabObjects. Rouvrir Unity pour le codegen FishNet (PlayerStats devenu NetworkBehaviour).