Component that pins a GameObject to a named bone on a SkinnedModelRenderer every frame. It looks up the bone once, reads the bone transform via TryGetBoneTransform each update to set WorldPosition with an upward offset, and falls back to a fixed height above the source if the bone is missing.
using Sandbox;
namespace NZombies;
/// <summary>
/// Pin this object to a bone on someone else's skinned model, every frame.
///
/// ⛔ WHY NOT JUST PARENT TO THE BONE. `SkinnedModelRenderer.GetBoneObject( "j_head" )`
/// returns null unless the renderer was asked to materialise bone GameObjects, and the
/// zombies' renderers are not — turning that on would create a GameObject per bone per
/// zombie, which is a real cost across a horde for something only one object needs.
/// `TryGetBoneTransform` reads the pose directly and costs one lookup, which is the
/// pattern `Weapon.cs` already uses to compare merged skeletons.
///
/// ⚠️ THE SYMPTOM THIS FIXES: parented to the zombie's ROOT at a fixed height, the burning
/// flame sat in the air beside the head instead of on it — a walk cycle leans the head
/// forward and the root does not move, so the offset was only ever correct on a zombie
/// standing perfectly upright.
///
/// ⚠️ POSITION ONLY, NEVER ROTATION. The flame emits in local space, so inheriting head
/// rotation would swing the whole plume every time the zombie looked around. Fire goes up
/// regardless of which way a head is facing.
/// </summary>
[Group( "nZombies" )]
public sealed class BoneFollow : Component
{
/// <summary>The skinned model whose pose to read. Set by whoever spawns this.</summary>
[Property] public SkinnedModelRenderer Source { get; set; }
/// <summary>Bone name. `j_head` on the CoD-derived zombie skeletons.</summary>
[Property] public string Bone { get; set; } = "j_head";
/// <summary>Units along world up from the bone, so a flame sits ON the head not IN it.</summary>
[Property] public float Offset { get; set; } = 4f;
/// <summary>
/// Used only when the bone cannot be resolved: units above <see cref="Source"/>'s own
/// object. Whoever spawns this should derive it from `ZombieAI.BodyHeight` rather than
/// passing a constant, because a quadruped variant is not 72 units tall.
/// </summary>
[Property] public float FallbackHeight { get; set; } = 66f;
/// <summary>
/// ⚠️ Cached, because resolving a bone by string every frame for every burning zombie
/// is a dictionary lookup per head per frame for a value that cannot change while the
/// model does not. Null means "not looked up yet"; <see cref="_missing"/> is what
/// records a lookup that failed, so a failure is not retried forever.
/// </summary>
BoneCollection.Bone _bone;
bool _missing;
bool _warned;
protected override void OnUpdate()
{
if ( !Source.IsValid() ) return;
if ( _bone is null && !_missing )
{
var model = Source.Model;
_bone = model?.Bones?.GetBone( Bone );
if ( _bone is null )
{
_missing = true;
if ( !_warned )
{
_warned = true;
Log.Warning( $"[nz-bone] '{Bone}' not on {Source.Model?.ResourceName ?? "<no model>"}"
+ " — falling back to a fixed height, so this will not follow the animation" );
}
}
}
if ( _bone is not null && Source.TryGetBoneTransform( _bone, out var t ) )
{
WorldPosition = t.Position + Vector3.Up * Offset;
return;
}
WorldPosition = Source.WorldPosition + Vector3.Up * FallbackHeight;
}
}