Static helper that tracks whether the local player body should exist and finds/enables/disables the player GameObject accordingly. It locates the NZPlayer component in the active scene (including disabled objects), decides ownership (Mine/Theirs), applies character selection and pending spawn data when enabling, and caches a reference with a fallback to scene search.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Whether a body exists in the world at all.
///
/// ⚠️ THE LOBBY HAD A LIVE PLAYER STANDING IN THE MAP BEHIND IT. The scene ships
/// the Player Controller enabled, so the moment you pressed play a body spawned,
/// took mouse-look, and the lobby drew on top of it — which is why none of its
/// buttons could be clicked. The menu was never broken; something underneath it
/// owned the cursor.
///
/// So presence is a rule, not a scene setting:
///
/// Lobby no body — nothing to control, cursor is free for the menu
/// Spectator no body — watching, not playing
/// Creative body — you build from inside the map
/// Survival body — you play
/// </summary>
public static class PlayerPresence
{
/// <summary>Should there be a player in the world right now?</summary>
public static bool ShouldExist
=> NZGame.Mode is GameMode.Creative or GameMode.Survival;
/// <summary>
/// Cached so the player can be found again AFTER being disabled.
///
/// ⚠️ GetAllComponents skips disabled objects. Without this the first switch
/// to Lobby would hide the player permanently — there would be nothing left
/// to search for and no way back into the map.
/// </summary>
static GameObject _player;
/// <summary>Enable or disable the body to match the mode.</summary>
public static void Apply()
{
var go = Find();
if ( go is null ) return;
var want = ShouldExist;
if ( go.Enabled == want ) return;
go.Enabled = want;
Log.Info( $"[nz] player {(want ? "spawned into" : "removed from")} the map ({NZGame.Mode})" );
// ⛔ RE-APPLIED ON EVERY ENTRY, because the object is DISABLED and re-enabled rather than
// destroyed — and a component coming back from disabled can restore its serialised model.
// Picking a character in the lobby and then finding a Citizen in the map is the failure this
// prevents, and it would look like the selection never worked.
//
// ⚠️ HERE RATHER THAN IN `NZPlayer.OnStart`, which does not run again on an already-spawned
// player — the same trap the `Slide` component's comment records.
if ( want )
PlayerCharacters.ApplyBody( go.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndDescendants ) );
if ( !want ) return;
// ⛔ THE FIRST INSTANT A BODY CAN BE MOVED AND MADE TO STAY MOVED. The host deals the
// spawns inside `StartGame`, which is the same instant this machine is still leaving the
// lobby — so its placement message lands with nothing to apply it to and is held. This
// is where it gets taken. See `PlayerSpawner.Defer`.
PlayerSpawner.TakePending( go );
// ⚠️ AND EVERYONE ELSE GETS DRESSED. Character choices replicate as a table, not as a
// property on the body, so a body that has just come up is wearing whatever the scene
// shipped until something reads the table — see `NZPlayers.RefreshBodies`.
NZPlayers.RefreshBodies();
}
/// <summary>The player object, enabled or not.</summary>
public static GameObject Find()
{
// ⛔ A CACHED PROXY IS WORSE THAN NO CACHE. With two players the scene holds several
// bodies and only one of them is ours; a stale entry pointing at somebody else's would
// send every `PlayerCharacters.Local()` caller — twenty of them — at the wrong player,
// silently and plausibly.
if ( _player.IsValid() && Mine( _player ) ) return _player;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
// Enabled first — cheap and correct while a body exists.
var live = scene.GetAllComponents<NZPlayer>()
.FirstOrDefault( p => p.IsValid() && Mine( p.GameObject ) );
if ( live.IsValid() ) return _player = live.GameObject;
// ⚠️ `false` = include DISABLED objects. This is the branch that runs
// once the player has been removed, and the only way to get them back.
_player = scene.GetAllObjects( false )
.FirstOrDefault( o => o.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is not null
&& Mine( o ) );
return _player;
}
/// <summary>
/// Is this body THIS machine's to drive?
///
/// ⚠️ THIS IS THE CHOKEPOINT THAT MAKES 20 CALL SITES CORRECT AT ONCE.
/// `PlayerCharacters.Local()` resolves through here, and before it existed "local" meant
/// "whichever came first" — the right name over the wrong behaviour, which is worse than a
/// raw lookup because it reads as already solved.
///
/// ⛔ IT ASKED `IsProxy` AND THAT WAS NOT SPECIFIC ENOUGH. A proxy is "a network object
/// that is not being simulated on the local pc", which is exactly right for a body handed to
/// somebody by `NetworkSpawn( connection )` — and says nothing useful about an object NOBODY
/// owns. The host's body is the scene's own `Player Controller` at `NetworkMode.Snapshot`;
/// nothing ever claimed it. If an unowned object reports `IsProxy == false` on a client then
/// on that machine BOTH bodies answered "mine", and one wrong predicate produced three
/// separate symptoms at once: the host's body was never shown (you could not see each
/// other), `Find` could return the HOST'S body as the local one (a character model drawn
/// over your own hands), and every zombie ran a full local AI against a replicated transform.
///
/// ⚠️ SO IT ASKS THE QUESTION DIRECTLY: is the owner me? Ownership is an id, and an id
/// compares the same on every machine — there is nothing to interpret.
///
/// ⚠️ THE UNOWNED CASE IS NAMED RATHER THAN GUESSED AT. An object with no owner belongs
/// to the host by convention, which is also what `NZPlayers.EnsureHostBody` now makes
/// explicit — this clause is what keeps the host correct in the frames before it does.
///
/// ⚠️ AND IT STILL READS CORRECTLY SOLO. `Connection.Local` is null with nothing
/// connected, so the last clause hands single player its one scene body, unchanged.
/// </summary>
public static bool Mine( GameObject go )
{
if ( !go.IsValid() ) return false;
// ⚠️ SOLO: nothing is connected, so the one body in the scene is ours.
var me = Connection.Local?.Id.ToString();
if ( string.IsNullOrEmpty( me ) ) return true;
// ⛔ THE WRITTEN-DOWN ANSWER FIRST, AND IT IS THE ONLY ONE THAT HAS EVER BEEN RIGHT ON
// BOTH MACHINES. See `NZPlayer.OwningConnection` for the two derived answers that were
// tried before it and how each of them failed.
var np = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
var said = np.IsValid() ? np.OwningConnection : "";
if ( !string.IsNullOrEmpty( said ) ) return said == me;
// ⚠️ NOTHING RECORDED — FALL BACK TO THE ENGINE, WHICH IS WHAT THIS DID ORIGINALLY.
// It is reached only by a body created before anybody claimed it, and on the machine that
// is simulating it that answer is correct. Being no worse than the previous version in
// the one unhandled case is the point of having a fallback at all.
return !go.Network.IsProxy;
}
/// <summary>Somebody else's body — the exact complement of <see cref="Mine"/>.</summary>
public static bool Theirs( GameObject go ) => go.IsValid() && !Mine( go );
/// <summary>Forget the cache — for a scene reload, where the old object is
/// gone but the static survives.</summary>
public static void Forget() => _player = null;
}