Zombies/HoundCheck.cs

Debug and spawn utilities for a "hellhound" zombie variant. Provides console commands to spawn a bare renderer test hound (nz_hound_test), spawn real AI-driven hellhounds from a variant (nz_hound), inspect live hellhounds (nz_hound_info), and clear test renderers (nz_hound_clear). It loads models, checks sequences, and logs results.

File Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// Temporary: prove the ported hellhound loads and can play its clips.
///
/// ⚠️ EXISTS BECAUSE "IT COMPILED" IS NOT "IT ANIMATES". A vmdl with 15 `AnimFile`
/// entries can carry zero SEQUENCES — animations and sequences are different lists,
/// and `Sequence.Name` plays the latter. That gap has cost this project a day before
/// now, so it gets checked before any AI is written against the model.
/// </summary>
public static class HoundCheck
{
	public const string ModelPath = "models/nz/hound/hound.vmdl";

	/// <summary>Spawn one in front of you: `nz_hound_test [sequence]`.</summary>
	[ConCmd( "nz_hound_test" )]
	public static void Spawn( string sequence = "nz_dog_run" )
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var model = Model.Load( ModelPath );

		if ( model is null || model.IsError )
		{
			Log.Warning( $"[nz] hound model '{ModelPath}' failed to load" );
			return;
		}

		var controller = player.Components.Get<PlayerController>();
		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;

		var from = eye + rot.Forward * 140f;
		var tr = Game.ActiveScene.Trace.Ray( from, from + Vector3.Down * 300f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		var go = new GameObject( true, "hound_test" );
		go.WorldPosition = tr.Hit ? tr.HitPosition : from;

		// Face the player, so the test dog is looking at you rather than away.
		go.WorldRotation = Rotation.LookAt( (player.WorldPosition - go.WorldPosition).WithZ( 0f ).Normal );

		var r = go.Components.Create<SkinnedModelRenderer>();

		// ⛔ ANIMGRAPH OFF — a ported model has none, and with the graph left on
		// `Sequence.Name` is ignored and the model never animates, silently.
		if ( model.AnimGraph is null ) r.UseAnimGraph = false;

		r.Model = model;

		Log.Info( $"[nz] hound spawned — animations={model.AnimationCount}, "
			+ $"bounds {model.Bounds.Size}" );

		// ⚠️ Sequences only exist once the renderer has a scene object, so this is
		// read AFTER it is enabled — reading earlier always reports zero.
		try
		{
			var names = r.Sequence.SequenceNames?.ToList();

			if ( names is null || names.Count == 0 )
			{
				Log.Warning( "[nz] hound has NO SEQUENCES — the vmdl carries animations "
					+ "but nothing playable" );
				return;
			}

			Log.Info( $"[nz] hound sequences ({names.Count}): {string.Join( ", ", names )}" );

			if ( !names.Contains( sequence ) )
			{
				sequence = names.FirstOrDefault( n => n.Contains( "run" ) ) ?? names[0];
				Log.Info( $"[nz] falling back to '{sequence}'" );
			}

			r.Sequence.Name = sequence;
			r.Sequence.Looping = true;

			Log.Info( $"[nz] playing '{sequence}' ({r.Sequence.Duration:0.00}s)" );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[nz] hound sequence check failed: {e.Message}" );
		}
	}


	/// <summary>
	/// Spawn a REAL hound — the zombie AI driving the hellhound variant:
	/// `nz_hound [count]`.
	///
	/// ⛔ THIS IS A ZOMBIE, NOT A NEW ENTITY. `ZombieAI` already owns navmesh
	/// chasing, damage, death, ragdolls and the points award, and `ZombieVariant` is
	/// a GameResource built precisely so a new enemy is DATA — models, speed tiers,
	/// animation names. Writing a parallel `HellhoundAI` would mean reimplementing
	/// 3,700 lines of tested behaviour to change a model and three clip names.
	///
	/// ⚠️ `nz_hound_test` is the other command and does something different: it spawns
	/// a bare renderer to prove the MODEL loads. That one cannot chase or be shot —
	/// it has no AI and no collider, which is exactly what "runs in place and takes no
	/// bullets" meant.
	/// </summary>
	[ConCmd( "nz_hound" )]
	public static void SpawnReal( int count = 1 )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz] no active scene" ); return; }

		var variant = ResourceLibrary.Get<ZombieVariant>( VariantPath );

		if ( variant is null )
		{
			Log.Warning( $"[nz] hellhound variant '{VariantPath}' not found" );
			return;
		}

		var player = NZPlayer.Local;
		var origin = player.IsValid() ? player.WorldPosition : Vector3.Zero;

		int made = 0;

		for ( int i = 0; i < count; i++ )
		{
			var pos = origin + Vector3.Random.WithZ( 0 ).Normal * 220f;

			// ⚠️ PASSED IN, NOT ASSIGNED AFTERWARDS. OnStart picks the body model
			// and animation tier off the variant, so a later assignment produces a
			// walker running at dog speed — the bug this command shipped with.
			var z = ZombieCommands.SpawnAt( scene, pos, variant );
			if ( !z.IsValid() ) continue;

			z.GameObject.Name = "Hellhound";

			made++;
		}

		// ⚠️ Deliberately does NOT claim the hound is correct. The renderer is
		// built in OnStart, a frame from now, so nothing here can see what model
		// it actually resolved to. `nz_hound_info` is the check that reads the
		// live object — a spawn command reporting its own success is how the
		// walker-bodied hound passed for working in the first place.
		Log.Info( $"[nz] spawned {made} hellhound(s) — variant '{variant.ResourceName}' "
			+ "· run nz_hound_info to confirm the body and clips" );
	}

	/// <summary>What the live hounds ACTUALLY resolved to: `nz_hound_info`.
	/// Reads the model and sequence off the renderer rather than repeating what
	/// the spawn asked for.</summary>
	[ConCmd( "nz_hound_info" )]
	public static void Info()
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz] no active scene" ); return; }

		var all = scene.GetAllComponents<ZombieAI>().ToList();
		if ( all.Count == 0 ) { Log.Info( "[nz-hound] no zombies alive" ); return; }

		foreach ( var z in all )
		{
			var r = z.Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
			var model = r?.Model?.ResourcePath ?? "(no renderer yet)";
			var seq = r?.Model is null ? "-" : r.Sequence?.Name ?? "-";
			var variant = z.Variant?.ResourceName ?? "(none — plain walker)";

			Log.Info( $"[nz-hound] {z.GameObject.Name,-10} variant={variant,-12} "
				+ $"seq={seq,-20} {model}" );
		}
	}

	/// <summary>Where the hellhound variant lives.</summary>
	public const string VariantPath = "zombies/hellhound.zvar";

	/// <summary>Remove the test hounds: `nz_hound_clear`.</summary>
	[ConCmd( "nz_hound_clear" )]
	public static void Clear()
	{
		int n = 0;

		foreach ( var go in Game.ActiveScene?.GetAllObjects( true )
			.Where( o => o.Name == "hound_test" ).ToList() ?? new() )
		{
			go.Destroy();
			n++;
		}

		Log.Info( $"[nz] removed {n} test hound(s)" );
	}
}