Console command helpers for wall-buy editor and runtime. Provides ConCmds to place, list, remove, tweak visuals, set rarity, buy and report wallbuy objects and their visuals via WallBuyManager and NZPlayer.
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// Console access to wallbuys.
///
/// ⚠️ Every buyable in this project gets commands, for the same reason the
/// others do: a thing that can only be placed by aiming and only tested by
/// walking up to it is a thing that goes untested. These also make the feature
/// drivable remotely.
/// </summary>
public static class WallBuyCommands
{
static WallBuyManager Mgr => WallBuyManager.Ensure();
static NZPlayer Player => NZPlayer.Local;
/// <summary>
/// Place one where you are looking. `nz_wallbuy_place galil 1250`.
///
/// ⚠️ Accepts a bare name — "galil" becomes prefabs/weapons/nz_galil.prefab,
/// the same shorthand nz_give takes. Typing full prefab paths at a console
/// while standing in a map is not a thing anyone does twice.
/// </summary>
[ConCmd( "nz_wallbuy_place" )]
public static void Place( string weapon = "m1911", int price = 500, int rarity = 0 )
{
var player = Player;
if ( player is null || Mgr is null ) { Log.Info( "[wallbuy] no player/manager" ); return; }
var cam = Game.ActiveScene.Camera;
var tr = Game.ActiveScene.Trace
.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * 200f )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
// ⚠️ Sit it just off the surface and face it OUTWARD along the normal —
// placed flush it z-fights the wall, and facing along the trace direction
// would bury the model inside it.
//
// ⚠️ FALLS BACK TO THE PLAYER'S FRONT when the ray misses. Refusing to
// place unless a wall is under the crosshair makes the command unusable
// from a console where the view cannot be aimed — and "aim at a surface"
// gave no clue whether the trace missed or the manager was absent.
Vector3 pos;
Rotation rot;
if ( tr.Hit )
{
// ⚠️ 0.25, not 1.5 — the chalk plane should sit ON the wall you clicked, just
// clear enough not to z-fight with it. The old 1.5 was headroom for a
// solid weapon model that no longer exists.
pos = tr.HitPosition + tr.Normal * 0.25f;
rot = Rotation.LookAt( tr.Normal );
Log.Info( $"[wallbuy] surface hit at {tr.HitPosition} ({tr.GameObject?.Name})" );
}
else
{
// ⚠️ FROM THE CAMERA, NOT THE PLAYER ORIGIN. The origin is at the feet,
// so "+40 up" put the box at waist height — below the crosshair and
// therefore un-aimable, which looked like the collider was missing.
pos = cam.WorldPosition + cam.WorldRotation.Forward.WithZ( 0 ).Normal * 60f;
rot = Rotation.LookAt( -cam.WorldRotation.Forward.WithZ( 0 ).Normal );
Log.Info( "[wallbuy] no surface under the crosshair — placed in front of the player" );
}
var path = weapon.Contains( '/' ) ? weapon
: $"prefabs/weapons/{(weapon.StartsWith( "nz_" ) ? weapon : "nz_" + weapon)}.prefab";
var buy = Mgr.Place( pos, rot, path, price, rarity );
Log.Info( $"[wallbuy] placed {buy.WeaponName} @ {price} (ammo {buy.AmmoPrice})" );
}
/// <summary>List every wallbuy in the map. `nz_wallbuy_list`.</summary>
/// <summary>
/// `nz_wallbuy_rarity <index> <tier>` — retag a placed wall buy. No args lists them.
///
/// ⛔ WRITES THE CONFIG ENTRY AND THE LIVE OBJECT, THEN REBUILDS THE CHALK. Setting only
/// the component leaves the colour right until the next save/load and then reverts; setting
/// only the config leaves the wall on screen in its old colour. §13: changing the data is not
/// changing the world.
/// </summary>
[ConCmd( "nz_wallbuy_rarity" )]
public static void SetRarity( int index = -1, int tier = -1 )
{
var list = ActiveConfig.Current?.WallBuys;
if ( list is null || list.Count == 0 ) { Log.Info( "[wallbuy] none placed" ); return; }
if ( index < 0 || tier < 0 )
{
Log.Info( $"[wallbuy] {list.Count} placed — nz_wallbuy_rarity <index> <0-{Rarity.LegendaryTier}>" );
for ( var i = 0; i < list.Count; i++ )
Log.Info( $"[wallbuy] [{i}] {Rarity.NameFor( list[i].Rarity )}"
+ $" {System.IO.Path.GetFileNameWithoutExtension( list[i].WeaponPrefab )}"
+ $" @ {list[i].Price}" );
return;
}
if ( index >= list.Count )
{
Log.Warning( $"[wallbuy] no wall buy #{index} (have {list.Count})" );
return;
}
// ⚠️ TO LEGENDARY: Godly is basalt's Easter egg's, and no wall sells it
var t = System.Math.Clamp( tier, 0, Rarity.LegendaryTier );
list[index].Rarity = t;
// ⚠️ The live object too, and its chalk — see the summary above.
// ⚠️ `Mgr`, NOT `WallBuyManager.All` — All is an instance member and Mgr is the
// create-on-demand accessor every other command in this file already goes through.
var live = Mgr?.All?.ElementAtOrDefault( index );
if ( live.IsValid() )
{
live.Rarity = t;
WallBuyManager.SpawnChalk( live );
}
Log.Info( $"[wallbuy] #{index} -> {Rarity.NameFor( t )}"
+ $" (damage x{Rarity.Mult( t ):0.##}) (unsaved — nz_save to keep it)" );
}
[ConCmd( "nz_wallbuy_list" )]
public static void List()
{
if ( Mgr is null ) { Log.Info( "[wallbuy] no manager" ); return; }
var all = Mgr.All;
Log.Info( $"[wallbuy] {all.Count} placed" );
foreach ( var b in all )
Log.Info( $" {b.WeaponName,-10} {b.Price,5} / ammo {b.AmmoPrice,5} {b.WorldPosition}" );
}
/// <summary>
/// Remove every wall buy selling this weapon — matched anywhere in its prefab's path, as `nz_hex_rings_from_wallbuy`
/// matches — from the config, and rebuild. `nz_wallbuy_remove deagle`. In memory, like every edit: nz_save keeps it.
///
/// ⚠️ A WEAPON IS NEEDED. An empty match would take every wall buy, and `nz_wallbuy_clear all` is the command for that.
/// ⚠️ THE CONFIG, NOT THE OBJECTS: a wall buy's object is rebuilt from its config entry, so destroying only the object
/// leaves it to come back at the next rebuild.
/// </summary>
[ConCmd( "nz_wallbuy_remove" )]
public static void RemoveWeapon( string weapon = "" )
{
if ( NZGame.IsClient ) { Log.Warning( "[wallbuy] the config is the host's" ); return; }
var match = weapon?.Trim() ?? "";
if ( match == "" ) { Log.Warning( "[wallbuy] nz_wallbuy_remove <weapon> — which weapon's wall buys to take away" ); return; }
var cfg = ActiveConfig.Current;
if ( cfg?.WallBuys is null ) { Log.Warning( "[wallbuy] no config here" ); return; }
var gone = cfg.WallBuys
.Where( b => b.WeaponPrefab is not null && b.WeaponPrefab.Contains( match, System.StringComparison.OrdinalIgnoreCase ) )
.ToList();
foreach ( var b in gone ) cfg.WallBuys.Remove( b );
if ( gone.Count > 0 ) Mgr?.Rebuild();
Log.Info( $"[wallbuy] removed {gone.Count} selling '{match}' — {cfg.WallBuys.Count} left (unsaved — nz_save to keep it)" );
}
/// <summary>Remove the one you are aiming at, or all of them. `nz_wallbuy_clear [all]`.</summary>
[ConCmd( "nz_wallbuy_clear" )]
public static void Clear( string what = "" )
{
if ( Mgr is null ) return;
if ( what == "all" )
{
var n = Mgr.All.Count;
foreach ( var b in Mgr.All ) b.GameObject.Destroy();
Log.Info( $"[wallbuy] removed {n}" );
return;
}
var aimed = Mgr.Aimed( Player );
if ( aimed is null ) { Log.Info( "[wallbuy] not aiming at one" ); return; }
Log.Info( $"[wallbuy] removed {aimed.WeaponName}" );
aimed.GameObject.Destroy();
}
/// <summary>
/// Resize / spin the chalk drawings. `nz_wallbuy_chalk 64 0`.
///
/// ⚠️ Respawns every chalk quad rather than only the aimed one — the setting is
/// global, so leaving the others at the old value would make the next
/// adjustment impossible to judge.
/// </summary>
[ConCmd( "nz_wallbuy_chalk" )]
public static void Chalk( float scale = 64f, float spin = 270f )
{
if ( Mgr is null ) return;
WallBuyManager.ChalkScale = scale;
WallBuyManager.ChalkSpin = spin;
WallBuyManager.ClearCentreCache(); // recompute, do not reuse a stale centre
// ⚠️ SpawnVisual, not SpawnChalk — it also puts the marker box back for any
// weapon whose chalk is missing. Calling SpawnChalk alone would leave those
// wallbuys invisible, since nothing else renders them any more.
var all = Mgr.All;
foreach ( var b in all ) WallBuyManager.SpawnVisual( b );
Log.Info( $"[wallbuy] chalk scale {scale} spin {spin}° on {all.Count} wallbuy(s)" );
// ⚠️ REPORT THE CHILD, NOT A MATERIAL. This used to probe for
// materials/chalk/<name>.vmat, which was how chalk worked when it was traced
// from pack icons. Chalk is generated from the weapon's geometry now, so that
// probe always said MISSING — a status line that is always wrong is worse
// than none, because it sends you looking for an asset that should not exist.
foreach ( var b in all )
{
var chalk = b.GameObject.Children.FirstOrDefault( c => c.Name == "chalk" );
var model = chalk.IsValid() ? chalk.Components.Get<ModelRenderer>()?.Model : null;
Log.Info( $"[wallbuy] {b.WeaponName,-10} " +
$"chalk={(chalk.IsValid() ? "spawned" : "NONE (marker box)")} " +
$"from={model?.Name ?? "-"}" );
}
}
/// <summary>
/// Force the weapon model visible on every wallbuy. `nz_wallbuy_reveal 1`.
///
/// ⚠️ Exists because the reveal is normally driven by where the player is
/// LOOKING, and an aim-gated visual cannot be inspected from a console or
/// screenshotted from the editor camera. Also the fastest way to judge whether
/// the model actually fits its outline.
/// </summary>
[ConCmd( "nz_wallbuy_reveal" )]
public static void Reveal( int on = 1 )
{
if ( Mgr is null ) return;
WallBuyManager.ForceReveal = on != 0;
var n = 0;
foreach ( var b in Mgr.All )
{
var go = b.GameObject.Children.FirstOrDefault( c => c.Name == "weapon" );
if ( !go.IsValid() ) { Log.Info( $"[wallbuy] {b.WeaponName}: no weapon model" ); continue; }
go.Enabled = on != 0;
n++;
Log.Info( $"[wallbuy] {b.WeaponName} model scale {go.LocalScale.x:0.000} " +
$"local {go.LocalPosition}" );
}
Log.Info( $"[wallbuy] reveal={on != 0} on {n} model(s)" );
}
/// <summary>
/// Nudge the revealed model's depth off the wall. `nz_wallbuy_depth 2`.
/// </summary>
[ConCmd( "nz_wallbuy_depth" )]
public static void Depth( float depth = 2f )
{
if ( Mgr is null ) return;
WallBuyManager.ModelDepth = depth;
foreach ( var b in Mgr.All ) WallBuyManager.SpawnVisual( b );
if ( WallBuyManager.ForceReveal ) Reveal( 1 );
Log.Info( $"[wallbuy] model depth {depth}" );
}
/// <summary>
/// Buy from the first wallbuy in the map. `nz_wallbuy_buy`.
///
/// ⚠️ Does not require aiming — the buy path is what spawns the weapon on the
/// player, and testing it should not also depend on the camera trace, which
/// cannot be driven from a console.
/// </summary>
[ConCmd( "nz_wallbuy_buy" )]
public static void Buy()
{
var player = Player;
if ( player is null || Mgr is null ) return;
var buy = Mgr.Aimed( player ) ?? Mgr.All.FirstOrDefault();
if ( buy is null ) { Log.Info( "[wallbuy] none placed" ); return; }
var spent = buy.TryBuy( player );
Log.Info( $"[wallbuy] buy {buy.WeaponName} -> spent {spent}, points now {player.Points}" );
// ⚠️ Report what the wallbuy is left holding. A duplicate weapon model is
// only visible as a COUNT, and eyeballing the wall cannot tell you whether
// the second one belongs to the wallbuy or to the player.
foreach ( var c in buy.GameObject.Children )
Log.Info( $"[wallbuy] child '{c.Name}' enabled={c.Enabled}" );
var held = player.GameObject.Components
.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ).ToList();
Log.Info( $"[wallbuy] player holds {held.Count} weapon(s): " +
string.Join( ", ", held.Select( w => w.ClassName ) ) );
}
/// <summary>
/// Rotate the weapon model within its outline. `nz_wallbuy_model 0 0 180`.
///
/// ⚠️ Logs the resulting WORLD angles next to the wallbuy's own, because
/// "perpendicular" is judged from a screenshot and confirmed from the numbers:
/// the model's yaw should sit 90° off the wallbuy's for a gun lying along the
/// wall, and its roll says which way up it is.
/// </summary>
[ConCmd( "nz_wallbuy_model" )]
public static void ModelTweak( float yaw = 0f, float pitch = 0f, float roll = 180f )
{
if ( Mgr is null ) return;
WallBuyManager.ModelTweak = Rotation.From( pitch, yaw, roll );
WallBuyManager.RefitModels();
foreach ( var b in Mgr.All )
{
var go = b.GameObject.Children.FirstOrDefault( c => c.Name == "weapon" );
if ( !go.IsValid() ) continue;
Log.Info( $"[wallbuy] {b.WeaponName} wallbuy={b.WorldRotation.Angles()} " +
$"model={go.WorldRotation.Angles()}" );
}
Log.Info( $"[wallbuy] model tweak yaw={yaw} pitch={pitch} roll={roll}" );
}
/// <summary>
/// Rotate a pack's weapon model BEFORE it is flattened into the chalk. Unlike
/// nz_wallbuy_model — which spins the finished wafer and cannot change its shape —
/// this turns the MESH, so a DIFFERENT face becomes the drawing. This is the fix for
/// a pack that comes out end-on ("forwards"). Per pack: `nz_wallbuy_premodel 0 90 0 simers`.
///
/// ⚠️ Use CARDINAL turns (0/90/180/270). The flatten axis follows this rotation and
/// can squash only a whole model axis, so an off-axis value flattens approximately.
/// Dial until the gun reads in profile, then bake it into WallBuyManager.PreTweaks.
/// </summary>
[ConCmd( "nz_wallbuy_premodel" )]
public static void PreModelTweak( float yaw = 0f, float pitch = 0f, float roll = 0f, string pack = "" )
{
// ⛔ WAS A SILENT `return`, WHICH IS THE WORST OF THE THREE WAYS THIS COMMAND CAN DO
// NOTHING. No manager, no matching weapons and a wrong angle all looked identical: you type
// it, nothing happens, and there is no way to tell which. The other two now report; this
// one says the play session is not running.
if ( Mgr is null )
{
Log.Info( "[wallbuy] no wall-buy manager — start play mode first; the chalk only exists"
+ " in a running scene" );
return;
}
if ( string.IsNullOrWhiteSpace( pack ) )
{
Log.Info( "[wallbuy] nz_wallbuy_premodel needs a pack tag — e.g. `nz_wallbuy_premodel 0 90 0 simers`" );
return;
}
WallBuyManager.PreTweaks[pack] = Rotation.From( pitch, yaw, roll );
// ⚠️ DROP THE DERIVED ROSTERS TOO. A manifest-identified pack (`historical`) builds its
// weapon list lazily and caches it in a static — so dialing a NEW tag would otherwise be
// resolved against a roster computed before that tag existed.
WallBuyManager.InvalidateRosters();
WallBuyManager.RefitModels();
Log.Info( $"[wallbuy] PRE-rotation for '{pack}' = yaw={yaw} pitch={pitch} roll={roll}"
+ " — mesh turned before flatten" );
Log.Info( $"[wallbuy] matches {WallBuyManager.MatchCount( pack )} weapon(s)"
+ " — 0 means the tag matches nothing and the dial will appear to do nothing" );
}
/// <summary>Slide the model within its outline. `nz_wallbuy_nudge 0 -2`.</summary>
[ConCmd( "nz_wallbuy_nudge" )]
public static void Nudge( float along = 0f, float up = 0f )
{
if ( Mgr is null ) return;
WallBuyManager.ModelNudge = new Vector3( 0f, along, up );
WallBuyManager.RefitModels();
Log.Info( $"[wallbuy] model nudge along={along} up={up}" );
}
/// <summary>What is under the crosshair, and what it would cost. `nz_wallbuy_report`.</summary>
[ConCmd( "nz_wallbuy_report" )]
public static void Report()
{
var player = Player;
// ⚠️ Report the TRACE, not just the conclusion. "nothing aimed" is three
// different failures wearing one message: the ray missed, it hit
// something else, or it hit the right object and the component lookup
// failed. Saying which is the whole value of the command.
var cam = Game.ActiveScene.Camera;
var tr = Game.ActiveScene.Trace
.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * 200f )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
Log.Info( $"[wallbuy] ray hit={tr.Hit} obj={tr.GameObject?.Name ?? "-"} " +
$"dist={(tr.Hit ? tr.Distance : 0):0}" );
foreach ( var b in Mgr.All )
Log.Info( $"[wallbuy] placed '{b.GameObject.Name}' at {b.WorldPosition} " +
$"collider={b.GameObject.Components.Get<BoxCollider>()?.Scale.ToString() ?? "NONE"} " +
$"dist={Vector3.DistanceBetween( b.WorldPosition, player.WorldPosition ):0}" );
var buy = Mgr?.Aimed( player );
if ( buy is null ) { Log.Info( "[wallbuy] Aimed() returned nothing" ); return; }
Log.Info( $"[wallbuy] {buy.WeaponName} price {buy.Price} ammo {buy.AmmoPrice}" );
Log.Info( $"[wallbuy] prompt: {buy.UseText( player )}" );
Log.Info( $"[wallbuy] points: {player?.Points} creative(free): {NZGame.IsCreative}" );
}
}