Defines playable characters, their model paths, and utilities to query and apply a players chosen character and avatar. Handles loading hand/body models, deciding which character sets are allowed per map, applying avatar clothing or character bodies to player renderers, and console commands for debugging and toggles.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>One playable character: who they are, and what the player sees of them.</summary>
public sealed record NZCharacter( string Id, string Name, string Arms, string Body );
/// <summary>
/// The four playable characters, and which one this player is.
///
/// ⛔ THE ARMS ARE THE WHOLE VISIBLE CHARACTER RIGHT NOW, and that is not a shortcut. nZombies is
/// first person; the body is a Citizen and stays one. The Primis playermodels are **ValveBiped, 75
/// bones** while s&box's player is the **Citizen** skeleton — different rigs, so a Primis body would
/// not animate at all without a full retarget. The c_arms share `ValveBiped.Bip01_*` naming with the
/// `c_arms_cstrike` hands this project already bone-merges onto every weapon, so they drop straight
/// in. One half of the port is nearly free and the other is days of work; this is the free half.
///
/// ⚠️ NO VOICE LINES YET. 526 of them are extracted and sitting in `Staging/primis`. `Id` is
/// deliberately the same string as their sound folder so wiring them later is a path join.
/// </summary>
public static class PlayerCharacters
{
public const string ArmsRoot = "models/player/primis";
/// <summary>
/// The four.
///
/// ⛔ RENAMED AGAIN — `All` → `Roster` → `Cast` → `Primis` — BECAUSE HOTLOAD KEEPS WINNING. It copies static
/// values forward BY NAME, so editing what is INSIDE this array changes nothing in a running
/// session: the previously-loaded array survives under the same name. Switching the bodies from
/// `_rigged` back to the static meshes appeared to do nothing at all, while `PreviewYaw` — a
/// `const`, compiled inline — flipped immediately. The result was a character that had visibly
/// rotated and changed in no other way.
///
/// ⚠️ THE RULE IS THE SAME EVERY TIME: a new field name has no old value to inherit. Renaming the
/// TYPE was not enough before, and neither is editing the contents now.
///
/// ⛔ ORIGINALLY RENAMED FROM `All` BECAUSE HOTLOAD KEPT THE OLD ONE ALIVE. s&box copies static values
/// forward BY NAME into the recompiled assembly, so when `NZCharacter` gained a `Body` field the
/// previously-loaded 3-field array carried straight over it — records that resolved by id,
/// reported the right name, and had `Body` null. The lobby therefore found the character, asked
/// for its body, got nothing, and fell back to the Citizen.
///
/// ⚠️ A NEW FIELD NAME HAS NO OLD VALUE TO INHERIT. `WalkerFootsteps` carries the same warning
/// for the same reason; restarting play does not help, because that does not rebuild the
/// assembly.
/// </summary>
public static readonly NZCharacter[] Primis =
{
new( "dempsey", "Tank Dempsey", $"{ArmsRoot}/dempsey_arms.vmdl", $"{ArmsRoot}/dempsey_rigged.vmdl" ),
new( "nikolai", "Nikolai Belinski", $"{ArmsRoot}/nikolai_arms.vmdl", $"{ArmsRoot}/nikolai_rigged.vmdl" ),
new( "richtofen", "Edward Richtofen", $"{ArmsRoot}/richtofen_arms.vmdl", $"{ArmsRoot}/richtofen_rigged.vmdl" ),
new( "takeo", "Takeo Masaki", $"{ArmsRoot}/takeo_arms.vmdl", $"{ArmsRoot}/takeo_rigged.vmdl" ),
};
public const string UltimisRoot = "models/player/ultimis";
public const string VictisRoot = "models/player/victis";
/// <summary>
/// The Ultimis four — the same characters as <see cref="Primis"/> in their WaW-era outfits.
///
/// ⚠️ SEPARATE IDS, NOT A SKIN. `CharacterId` is a plain string on the player and the sound
/// folders are named after it, so "dempsey" is already taken by the Primis model; sharing an id
/// would make the two indistinguishable everywhere the id is the key.
/// </summary>
public static readonly NZCharacter[] Ultimis =
{
new( "ult_dempsey", "Tank Dempsey (Ultimis)", $"{UltimisRoot}/ult_dempsey_arms.vmdl", $"{UltimisRoot}/ult_dempsey_rigged.vmdl" ),
new( "ult_nikolai", "Nikolai Belinski (Ultimis)", $"{UltimisRoot}/ult_nikolai_arms.vmdl", $"{UltimisRoot}/ult_nikolai_rigged.vmdl" ),
new( "ult_richtofen", "Edward Richtofen (Ultimis)", $"{UltimisRoot}/ult_richtofen_arms.vmdl", $"{UltimisRoot}/ult_richtofen_rigged.vmdl" ),
new( "ult_takeo", "Takeo Masaki (Ultimis)", $"{UltimisRoot}/ult_takeo_arms.vmdl", $"{UltimisRoot}/ult_takeo_rigged.vmdl" ),
};
/// <summary>The Victis four, from Black Ops II.</summary>
public static readonly NZCharacter[] Victis =
{
new( "marlton", "Marlton Johnson", $"{VictisRoot}/marlton_arms.vmdl", $"{VictisRoot}/marlton_rigged.vmdl" ),
new( "misty", "Misty", $"{VictisRoot}/misty_arms.vmdl", $"{VictisRoot}/misty_rigged.vmdl" ),
new( "russman", "Russman", $"{VictisRoot}/russman_arms.vmdl", $"{VictisRoot}/russman_rigged.vmdl" ),
new( "stuhlinger", "Samuel Stuhlinger", $"{VictisRoot}/stuhlinger_arms.vmdl", $"{VictisRoot}/stuhlinger_rigged.vmdl" ),
};
/// <summary>
/// Every playable character, in roster order. THE list — `Find` and the diagnostics read this.
///
/// ⛔ A NEW FIELD NAME, AND THAT IS THE WHOLE REASON IT IS NOT JUST MORE ROWS IN `Primis`.
/// Hotload copies static values forward BY NAME, so editing what is INSIDE an existing array
/// changes nothing in a running session — this file has been renamed `All` → `Roster` → `Cast`
/// → `Primis` for exactly that reason, three times. Adding twelve characters by appending to
/// `Primis` would have shown four.
/// </summary>
public static readonly NZCharacter[] Everyone =
Primis.Concat( Ultimis ).Concat( Victis ).ToArray();
/// <summary>
/// The roster grouped for the lobby picker, in roster order.
///
/// ⚠️ TWELVE FLAT ROWS IS A WALL. The picker was written for four names and reads as one
/// decision; three named groups of four keeps that, where one list of twelve would not.
///
/// ⛔ A NEW FIELD NAME, for the same reason as <see cref="Everyone"/> — hotload copies statics
/// forward BY NAME, so a group list has to be a name that never existed rather than extra rows
/// in an array a running session already holds.
/// </summary>
public static readonly (string Name, NZCharacter[] Members)[] Sets =
{
( "Primis", Primis ),
( "Ultimis", Ultimis ),
( "Victis", Victis ),
};
/// <summary>
/// The Easter egg line of the map being played — "primis", "immunis", or "" for a Survival map — from the manifest
/// (`MapLibrary.Original.Line`, the field the map browser's tabs read).
/// ⚠️ READ ON EVERY CALL, NEVER KEPT: the map changes under a running session, and a remembered line is the last map's.
/// </summary>
public static string MapLine( string mapName = null )
{
var key = NZMap.KeyFor( mapName ?? NZMap.CurrentRaw ?? "" );
return MapLibrary.Originals.FirstOrDefault( o => NZMap.KeyFor( o.MapName ) == key )?.Line ?? "";
}
/// <summary>
/// The groups a player may choose from on the map being played (or `mapName`), in roster order.
///
/// ⛔ ON A PRIMIS MAP, THE PRIMIS FOUR ONLY (2026-10-05). The user: *"On the primis maps only the 4 [...] characters are
/// available"*; asked which four, they chose the Primis group (the BO3 outfits). Every other map offers every group.
/// ⚠️ A METHOD, NOT A STATIC LIST: hotload carries a static's old value forward by name (see `Primis`), and the answer
/// changes with the map anyway. The group is found by its NAME, which no hotload changes.
/// </summary>
public static (string Name, NZCharacter[] Members)[] SetsHere( string mapName = null )
=> MapLine( mapName ) == "primis" ? Sets.Where( s => s.Name == "Primis" ).ToArray() : Sets;
/// <summary>May this character be chosen on the map being played? No character at all (your own avatar) always may.</summary>
public static bool AllowedHere( NZCharacter c, string mapName = null )
=> c is null || SetsHere( mapName ).Any( s => s.Members.Any( m => m.Id.Equals( c.Id, StringComparison.OrdinalIgnoreCase ) ) );
/// <summary>Is "Your own avatar" offered in the picker here? Not on a Primis map, where the four are the whole list.</summary>
public static bool AvatarChoosableHere => MapLine() != "primis";
/// <summary>
/// What a pick becomes on the map being played: itself where it is allowed; else the same person from a group that is
/// (an Ultimis pick on a Primis map: `ult_takeo` → `takeo`); else no character (a Victis pick there).
/// </summary>
public static NZCharacter AllowedVersionOf( NZCharacter c, string mapName = null )
{
if ( AllowedHere( c, mapName ) ) return c;
var person = c.Id.StartsWith( "ult_", StringComparison.OrdinalIgnoreCase ) ? c.Id["ult_".Length..] : c.Id;
return SetsHere( mapName ).SelectMany( s => s.Members )
.FirstOrDefault( m => m.Id.Equals( person, StringComparison.OrdinalIgnoreCase ) );
}
/// <summary>`nz_cast [map]` — which characters a map offers, and what each pick becomes there (no argument: this map).</summary>
[ConCmd( "nz_cast" )]
public static void CastCmd( string map = "" )
{
var name = string.IsNullOrWhiteSpace( map ) ? null : map;
var line = MapLine( name );
Log.Info( $"[nz-char] cast of '{name ?? NZMap.CurrentRaw}': line '{(line == "" ? "survival" : line)}' · offers "
+ string.Join( ", ", SetsHere( name ).Select( s => s.Name ) )
+ (MapLine( name ) == "primis" ? " · no own-avatar row" : "") );
foreach ( var c in Everyone )
{
var to = AllowedVersionOf( c, name );
Log.Info( $"[nz-char] {c.Id,-14} {(to == c ? "allowed" : $"→ {to?.Id ?? "own avatar"}")}" );
}
}
/// <summary>
/// Yaw to add so the model faces the camera in a preview.
///
/// ⛔ ZERO NOW, AND THAT IS A REAL VALUE RATHER THAN A DISABLED ONE. The facing is baked into the
/// exported model by `Tools/retarget_to_human.py` (`EXPORT_YAW`), so a retargeted body faces the
/// same way the Citizen does and needs no correction anywhere — preview, lobby or gameplay.
///
/// ⚠️ IT WAS +90 WHILE `Body` POINTED AT THE ValveBiped STATIC MESHES, which are authored facing
/// a different axis. Fixing facing per-viewer meant every new place that drew a body had to know
/// about it; fixing it in the asset means none of them do.
///
/// ⚠️ KEPT RATHER THAN DELETED because a future character may arrive from a pipeline that does
/// not bake it. If one does, this becomes a field on the record instead of a constant.
/// </summary>
public const float PreviewYaw = 0f;
/// <summary>
/// What the player wore before any character was applied, captured once.
///
/// ⛔ RESTORED RATHER THAN REPLACED WITH A CONSTANT. A hardcoded default imposes a body the
/// scene may never have used — and this project has BOTH in play already: the lobby preview
/// stages `citizen_human_male` while a constant here said `citizen`. Clearing a character should
/// put back what was there, not assert what ought to be.
///
/// ⚠️ CAPTURED ON THE FIRST APPLY, WHICH IS THE ONLY MOMENT IT IS STILL TRUE. Read it later
/// and it is whatever character was last worn.
/// </summary>
static Model _originalBody;
/// <summary>
/// The local player, whether or not they are currently in the map.
///
/// ⛔ `Scene.GetAllComponents<NZPlayer>()` DOES NOT SEE A DISABLED PLAYER, and in the lobby the
/// player object IS disabled — `PlayerPresence` switches it off rather than destroying it. That
/// is why picking a character from the lobby answered "no player" while the player was plainly
/// sitting there in the menu.
///
/// ⚠️ `PlayerPresence.Find()` already solves this and caches the object. Anything asking "who
/// is the local player" from a menu must go through it.
/// </summary>
public static NZPlayer Local()
=> PlayerPresence.Find()?.Components
.Get<NZPlayer>( FindMode.EverythingInSelfAndDescendants );
public static NZCharacter Find( string id )
=> string.IsNullOrWhiteSpace( id )
? null
: Everyone.FirstOrDefault( c => c.Id.Equals( id, StringComparison.OrdinalIgnoreCase ) );
/// <summary>
/// The arms model this player should be holding, or null to leave the weapon's own choice alone.
///
/// ⛔ NULL MEANS "DO NOT TOUCH", NOT "USE THE DEFAULT". Every weapon already ships a
/// `ViewModelHands`, and some may legitimately want their own — gloves, a suit. Returning a
/// fallback here would silently overwrite those with generic hands for a player who never picked
/// a character.
///
/// ⚠️ IT RETURNS A LOADED `Model`, NOT A PATH, so a missing or broken asset is caught here once
/// rather than at three separate call sites.
/// </summary>
public static Model HandsFor( NZPlayer player )
{
var c = Find( player.IsValid() ? player.CharacterId : null );
if ( c is null ) return null;
var m = Model.Load( c.Arms );
// ⚠️ `IsError` TOO, NOT JUST NULL. A compiled-but-broken vmdl loads as the ERROR MODEL, which
// renders happily as a checkerboard — the same trap `ZombieAI.EnsureBody` documents.
return m is null || m.IsError ? null : m;
}
/// <summary>The Human body s&box avatars are made on. A female avatar's body comes from its own skin item.</summary>
public const string HumanBody = "models/citizen_human/citizen_human_male.vmdl";
/// <summary>
/// The s&box avatar of whoever owns this body: their saved outfit and appearance. Null when it cannot be read here yet (a
/// teammate's connection this machine has not heard of).
///
/// ⚠️ MY OWN BODY READS THE LOCAL USER, solo or not — solo has no other avatar to read.
/// </summary>
public static ClothingContainer AvatarOf( NZPlayer player )
{
if ( !player.IsValid() ) return null;
if ( !Networking.IsActive || PlayerPresence.Mine( player.GameObject ) ) return ClothingContainer.CreateFromLocalUser();
return AvatarOf( OwnerConnection( player.GameObject ) );
}
/// <summary>One connection's s&box avatar: the local user's own, or what the engine knows of a teammate's.</summary>
public static ClothingContainer AvatarOf( Connection connection )
{
if ( connection is null ) return null;
if ( connection == Connection.Local ) return ClothingContainer.CreateFromLocalUser();
return ClothingContainer.CreateFromConnection( connection );
}
/// <summary>
/// An outfit as something two logs can compare: how many items, and a print of its whole content that is the same on every
/// machine (FNV-1a over `Serialize`, never `GetHashCode`, which .NET seeds per process).
///
/// ⛔ A PRINT THAT MATCHES YOUR OWN, ON SOMEBODY ELSE'S BODY, IS THE BUG OF 2026-10-05: *"from my point of view everyone is
/// using my human model, and for other players everyone is using their model"*. The published lobby dressed every slot with
/// `CreateFromLocalUser`. It is fixed here and in the lobby, and the line this goes into says so on every machine.
/// </summary>
public static string DescribeOutfit( ClothingContainer outfit, bool isMine )
{
if ( outfit is null ) return "no outfit";
var print = OutfitPrint( outfit );
var items = outfit.Clothing?.Count ?? 0;
// ⚠️ ONLY WITH SOMETHING ON: two fresh avatars really are alike, and an empty outfit says nothing about whose it is
var clash = !isMine && items > 0 && print == OutfitPrint( ClothingContainer.CreateFromLocalUser() );
return $"{items} item(s) · print {print}" + (clash ? " ⚠ THE SAME AS YOURS — another player is in your outfit" : "");
}
/// <summary>The outfit's content as eight hex digits, the same on every machine. See <see cref="DescribeOutfit"/>.</summary>
static string OutfitPrint( ClothingContainer outfit )
{
uint h = 2166136261;
foreach ( var ch in outfit?.Serialize() ?? "" )
{
h ^= ch;
h *= 16777619;
}
return h.ToString( "x8" );
}
/// <summary>
/// Whose this body is, as a connection: the RECORDED owner first (`NZPlayers.OwnerOf`), the engine's only as a fallback —
/// the order every "whose body" question in this project takes, after the engine's own answer was wrong twice.
/// </summary>
static Connection OwnerConnection( GameObject body )
{
if ( Guid.TryParse( NZPlayers.OwnerOf( body ), out var id ) && Connection.Find( id ) is { } recorded ) return recorded;
return body.IsValid() ? body.Network.Owner : null;
}
/// <summary>
/// The body every player's own avatar is shown on: ALWAYS THE HUMAN.
///
/// ⛔ NOT THE ONE THE AVATAR WAS BUILT ON (2026-10-05). It asked the avatar's `PrefersHuman`, so an avatar built on the Citizen
/// stayed a Citizen; the user: *"no no, they are forced to be the human form"*. The Citizen only if the Human will not load.
/// The avatar's own skin item can still make the Human female.
/// </summary>
public static Model AvatarBody()
{
var human = Model.Load( HumanBody );
if ( human is not null && !human.IsError ) return human;
return _originalBody ?? Model.Load( "models/citizen/citizen.vmdl" );
}
/// <summary>
/// Put this player in their chosen character's body, or in their own s&box avatar (2026-10-05; it was the Citizen).
///
/// ⛔ THE BODY IS THE PORTED MESH AND DOES NOT ANIMATE. These are ValveBiped, 75 bones; s&box's
/// player animation is authored for the human rig, 94 bones, sharing not one name with them. So
/// the body renders correctly — right proportions, right materials — in its bind pose, and stays
/// there.
///
/// ⚠️ THE BODIES ARE THE RETARGETED ONES. `Tools/retarget_to_human.py` rebinds the ValveBiped
/// meshes onto the human skeleton so the animgraph drives them, and the four `_rigged.vmdl`
/// files are what `Primis` points at.
///
/// ⛔ THE REBIND HAS TO REPROPORTION THE MESH, AND THAT IS NOT OPTIONAL. Every human animation
/// carries position channels for all 72 animated bones, so the skeleton snaps to the human's
/// proportions whatever rest pose the model ships with — keeping the character's own limb lengths
/// is not available. Residual mean edge-length error is 7.6% concentrated at the joints, down
/// from 17.5%; the median edge is exact.
///
/// ⚠️ WHICH COSTS LITTLE HERE. This is a first-person game — you never look at your own body. It
/// shows in the lobby preview, where a still pose is what a character-select screen wants anyway,
/// and in the downed state.
///
/// ⚠️ `PlayerController.Renderer` IS THE FIELD TO WRITE. The body is a child object; finding it
/// by name or by "first SkinnedModelRenderer" would also match a weapon's world model.
/// </summary>
public static void ApplyBody( NZPlayer player )
{
if ( !player.IsValid() ) return;
var ctrl = player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
if ( !ctrl.IsValid() ) return;
// ⛔ `ctrl.Renderer` IS A COMPONENT REFERENCE, AND A SILENT `return` ON IT IS A TRAP.
// It points at the `Body` child's renderer, which is fine on a body this machine built —
// and is not something this project has ever verified survives a `NetworkSpawn`. If it
// arrives null on the far machine, this method returned quietly and the body kept whatever
// model it happened to have, with nothing logged and nothing to find.
//
// ⚠️ AND `nz_see` WOULD NOT HAVE CAUGHT IT, because that falls back to searching
// descendants — so it reports a perfectly healthy renderer that nobody ever wrote a model
// into. "Drawable: yes" and "wearing the right character" can both be true of a body you
// cannot see.
var rend = ctrl.Renderer;
if ( !rend.IsValid() )
{
// ⚠️ THE FALLBACK IS SAFE NOW IN A WAY IT WAS NOT BEFORE. The original note here
// warned that "the first SkinnedModelRenderer" could match a weapon's world model —
// true then, and weapons no longer cross the network at all (their prefabs are
// `NetworkMode.Never`), so on somebody else's body the only skinned renderer is the
// body. The viewmodel tag is excluded anyway.
rend = player.Components
.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants )
.FirstOrDefault( r => r.IsValid() && !r.GameObject.Tags.Has( "viewmodel" ) );
Log.Warning( $"[nz-char] '{player.GameObject.Name}' has no PlayerController.Renderer — "
+ $"{(rend.IsValid() ? $"using '{rend.GameObject.Name}' instead" : "AND NO SKINNED RENDERER AT ALL")}."
+ " A body whose renderer reference did not survive the network would be invisible"
+ " while reporting perfectly healthy." );
}
if ( !rend.IsValid() ) return;
_originalBody ??= rend.Model;
var c = Find( player.CharacterId );
var dresser = player.Components.Get<Dresser>( FindMode.EverythingInSelfAndDescendants );
if ( c is null )
{
// ⛔ THE PLAYER'S OWN s&box AVATAR, NOT THE PREFAB'S CITIZEN (2026-10-05). The user: *"when a player does not choose a
// character, they become a citizen, but I'd like them to use their own human model from sbox"*. This put back the
// prefab's `citizen.vmdl` and left the Dresser to it, and an avatar made on the Human body has little that fits a
// Citizen, so a player who never picked anyone stood there as a near-bare Citizen. The body is now always the Human
// (`AvatarBody`), dressed in the owner's avatar right here.
var avatar = AvatarOf( player );
var body = AvatarBody();
if ( body is not null && rend.Model != body ) rend.Model = body;
if ( dresser.IsValid() )
{
// ⚠️ MANUAL, AND HANDED THE AVATAR RESOLVED ABOVE, rather than left to read the network owner itself
// (`OwnerConnection`): solo has no network owner, and here the owner comes the way every "whose body" answer
// does. Set before it is switched on, so it never dresses from the other source first.
if ( avatar is not null ) dresser.Source = Dresser.ClothingSource.Manual;
dresser.Enabled = true;
// ⚠️ RE-DRESSED EVERY TIME, because the clothing is fitted to the body just set. Appearance first (height, skin,
// eyes, tints), then the outfit, downloading what this machine lacks: a teammate's workshop clothing is not on
// your disk until it is worn in front of you.
if ( avatar is not null )
{
dresser.UpdateAppearance( avatar );
_ = dresser.ApplyAsync( avatar );
}
}
// ⚠️ SAID ONCE PER DRESSING (2026-10-05): whose avatar went on this body, and its print (`DescribeOutfit`), so a
// game with friends shows on every machine who is wearing what. Not while it can't be read: that retries every tick.
if ( avatar is not null )
{
var isMine = !Networking.IsActive || PlayerPresence.Mine( player.GameObject );
var whose = isMine ? "your own" : $"{OwnerConnection( player.GameObject )?.DisplayName ?? "an unknown player"}'s";
Log.Info( $"[nz-avatar] '{player.GameObject.Name}' wears {whose} avatar · {DescribeOutfit( avatar, isMine )}" );
}
// ⚠️ NOT MARKED DRESSED UNTIL THE AVATAR COULD BE READ, so `NZPlayers.RefreshBodies` tries again on its next tick: a
// teammate whose connection this machine has not heard of yet would otherwise stay undressed for good.
player.BodyLook = avatar is null ? null : "";
return;
}
var m = Model.Load( c.Body );
if ( m is null || m.IsError )
{
Log.Warning( $"[nz-char] body '{c.Body}' is"
+ $" {(m is null ? "missing" : "the error model")}" );
return;
}
// ⚠️ THE AVATAR'S OUTFIT COMES OFF BEFORE THE CHARACTER GOES ON, AND THE DRESSER GOES QUIET. The wardrobe is fitted to
// the Citizen and Human skeletons, not to a character's body. Taken off explicitly (`Clear`) rather than trusting that
// switching the Dresser off removes what it already made. `Clear` keeps the avatar's appearance values (height among
// them); the engine's full reset is private.
if ( dresser.IsValid() )
{
dresser.Clear();
dresser.Enabled = false;
}
rend.Model = m;
player.BodyLook = c.Id;
}
/// <summary>
/// `nz_thirdperson [0/1]` — look at your own body.
///
/// ⚠️ IT REPORTS WHAT YOU WILL BE LOOKING AT, because a retargeted body and a static one look
/// identical standing still. If it says `citizen` you are about to inspect the wrong model.
/// </summary>
[ConCmd( "nz_thirdperson" )]
public static void ThirdPersonCmd( int on = -1 )
{
NZPlayer.ThirdPerson = on < 0 ? !NZPlayer.ThirdPerson : on != 0;
var player = Local();
// ⛔ WRITE THE CONTROLLER HERE, NOT JUST THE FLAG. `ApplyConfig` is the only thing that pushes
// it across and it runs on config load — not per tick — so setting the static alone changed
// nothing until the next config load. The flag exists so `ApplyConfig` does not undo this;
// it is not what applies it.
var c = player.IsValid()
? player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants )
: null;
if ( c.IsValid() ) c.ThirdPerson = NZPlayer.ThirdPerson;
else Log.Warning( "[nz] no PlayerController — the camera will not move" );
var who = Find( player.IsValid() ? player.CharacterId : null );
var body = player.IsValid()
? player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants )
?.Renderer?.Model?.ResourcePath ?? "none"
: "no player";
Log.Info( $"[nz] third person {(NZPlayer.ThirdPerson ? "ON" : "off")}"
+ $" · {who?.Name ?? "no character"} · body '{body}'" );
if ( NZPlayer.ThirdPerson )
Log.Info( "[nz] the viewmodel is hidden while this is on — `nz_thirdperson 0` to return" );
}
/// <summary>
/// Bumped whenever anyone's character changes, so views showing a body know to rebuild.
///
/// ⛔ THE LOBBY PREVIEW CHOOSES ITS MODEL WHEN THE BODY IS CREATED and nothing re-reads it, so a
/// character change has to invalidate the stage rather than expect it to notice. `PickCharacter`
/// did that by hand, which left the CONSOLE path — `nz_character` — silently stale: the character
/// changed, hands and voice followed, and the preview kept showing a Citizen.
///
/// ⚠️ A COUNTER RATHER THAN AN EVENT, because the reader is a UI tick that is already comparing a
/// staged player count. One more comparison costs nothing and cannot leak a subscription.
/// </summary>
public static int Revision;
/// <summary>`nz_character [id]` — read or set who you are playing.</summary>
[ConCmd( "nz_character" )]
public static void CharacterCmd( string id = "" )
{
var player = Local();
if ( !player.IsValid() ) { Log.Warning( "[nz-char] no player" ); return; }
if ( string.IsNullOrWhiteSpace( id ) )
{
var cur = Find( player.CharacterId );
Log.Info( $"[nz-char] you are: {cur?.Name ?? "nobody (weapon default hands)"}" );
foreach ( var c in Everyone )
{
var m = Model.Load( c.Arms );
Log.Info( $"[nz-char] {c.Id,-10} {c.Name,-20}"
+ $" arms {(m is null ? "MISSING" : m.IsError ? "ERROR MODEL" : "ok")}"
+ (c.Id == player.CharacterId ? " <-- you" : "") );
}
Log.Info( "[nz-char] nz_character <id>, or `none` to go back to weapon default" );
return;
}
if ( id.Equals( "none", StringComparison.OrdinalIgnoreCase ) )
{
player.CharacterId = null;
Revision++;
Log.Info( "[nz-char] cleared - weapons use their own hands again" );
player.RefreshCharacterHands();
ApplyBody( player );
return;
}
var pick = Find( id );
if ( pick is null )
{
Log.Warning( $"[nz-char] '{id}' is not a character. Try: "
+ string.Join( ", ", Everyone.Select( c => c.Id ) ) );
return;
}
player.CharacterId = pick.Id;
Revision++;
Log.Info( $"[nz-char] you are now {pick.Name}" );
player.RefreshCharacterHands();
ApplyBody( player );
}
}