Files
2026-06-22 16:11:12 +02:00

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)
type
project

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

  1. un code de session partageable, et
  2. 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), sinon SteamAPI.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 via SceneManager.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 à un PlayerSpawnPoint.
  • PlayerSpawnPoint.cs : marqueur de point de spawn (avec gizmo).
  • PlayerNetworkController.cs : NetworkBehaviour sur le joueur. En OnStartClient, n'active les composants « owner-only » (input, caméra, audio, stats, inventaire…) que si IsOwner. Liste à câbler dans l'inspector.
  • SavePanel.LoadGame modifié : appelle NetworkSessionManager.Instance.HostSession() (repli SceneManager.LoadScene si pas câblé).
  • Bus PlayerEvents étendu : états IsOnline/IsHost/SessionCode + events SessionStarting/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'AddItem se fait côté serveur sur sa copie de PlayerInventory → 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) = registre id ↔ ItemData, rempli par Tools ▸ Ashwild ▸ Rebuild Item Database (ItemDatabaseBuilder). Nécessaire car on ne peut pas envoyer un ScriptableObject sur le réseau.
  • PlayerInventory est passé MonoBehaviourNetworkBehaviour. Slots restent locaux (tout le code crafting/use/hotbar inchangé). Singleton posé uniquement pour l'owner en OnStartNetwork (plus de clobber Awake destructeur). GrantItemFromServer(item,qty) (serveur) → TargetGrantItem (owner) → AddItem local. DropItemSpawnDropServerRpc (serveur spawn le pickable). Ajout CanFit().
  • Pickable : RequestPickupServerRpc → serveur résout l'inventaire via conn.FirstObject, GrantItemFromServer, Despawn. SyncVar syncItemId pour que les drops s'affichent correctement chez les autres clients. Pré-check CanFit cô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. Interactregistry.RequestPickupServerRpc(id). HideAsClaimed()/InitializeAsDrop().
  • WorldPickupRegistry (NetworkBehaviour, 1 par scène, dans Assets/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.DropItemWorldPickupRegistry.Instance.RequestDropServerRpc(...) (plus de SpawnDropServerRpc/Pickable).
  • Conséquence : UN seul prefab par item suffit (le même WorldPickup sert au scatter ET de ItemData.WorldPrefab pour les drops).
  • Renommé ensuite WorldPickupPickable, WorldPickupRegistryPickableRegistry.

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). Dossier Network/.
  • WorldObjectRegistry (abstract NetworkBehaviour, RequireComponent NetworkObject) : plomberie commune = SyncHashSet<int> inactiveIds, dict registered, catch-up OnStartClient, OnChange→hide/show, MarkInactive/MarkActive, ResolveInventory. Awake/OnDestroy virtuels (le singleton typé est posé par la sous-classe).
  • Pickable : WorldObject + PickableRegistry : WorldObjectRegistry (ajoute activeDrops SyncDictionary + RPC pickup/drop).
  • Harvestable : WorldObject : devient data+feedback (MaxHealth/CanRespawn/RespawnDelay/RollDrops/PlayHitEffect/PlayBlockedFeedback) ; vie/depletion/respawn RETIRÉS (→ registre). HideAsInactive(fresh) joue onDepleted seulement si fresh.
  • 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 via LocalConnection.ClientId), respawn coroutine.
  • ToolBehaviour.TryHarvest : blocked→local ; sinon PlayHitEffect local immédiat + HarvestableRegistry.Instance.RequestHitServerRpc(...).
  • Outil unique Tools ▸ Ashwild ▸ Assign World Object IDs (WorldObjectIdAssigner, remplace PickableIdAssigner) : ids sur TOUS les WorldObject (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 + 1 HarvestableRegistry dans TestScene ; Pickable/Harvestable sur les prefabs (id -1) ; (re)placer → Assign World Object IDs → save scene.

PlayerStats networké (FAIT, juin 2026) : PlayerStats est passé MonoBehaviourNetworkBehaviour, 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.BindToPlayer faisait FindFirstObjectByType<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é par PlayerStats.Instance.GetComponent<PlayerLifecycle>() (joueur local garanti).
  • Latent mineur restant : PlayerInventory.Start fait FindAnyObjectByType<InventoryUI>() (UI de scène unique, pas un objet joueur → OK mais fragile). Limite rejoin : DeathScreen/UI gardent bound=true aprè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).