Rendering/PieceAvatarLayer.cs
using System;
using System.Collections.Generic;

/// <summary>
/// 分身头像层(M4,用户需求:分身带头像便于区分归属)。
/// 分身是纯数据实体,头像用**本地非联网的 GameObject 群**跟随 DrawPos 渲染——
/// 各端已有的分身快照镜像(15Hz)足够驱动,零额外同步,也没有 live create 可靠性问题。
/// 贴图按主人解析:真人 = Steam 头像(按 OwnerSteamId,缓存 + 1s 重试),
/// bot = 名字哈希几何图案(BotAvatar,bot 已有独立假 SteamId 可精确归属)。
/// 槽按分身 Id 稳定绑定(同名牌池模式),分身消失即回收。
/// </summary>
public sealed class PieceAvatarLayer : Component
{
	const int PoolSize = 96;   // 分身上限 16×真人,bot 撞刺碎片另计;超出部分本轮不画头像

	sealed class Slot
	{
		public GameObject Go;
		public SpriteRenderer Sprite;
		public Texture Texture;     // 当前贴图(变了才重设 Sprite)
		public int PieceId = -1;
		public bool Used;
	}

	sealed class AvatarEntry
	{
		public Texture Texture;
		public TimeSince SinceTry;
		public int Tries;
	}

	readonly List<Slot> _pool = new();
	readonly Dictionary<long, AvatarEntry> _avatars = new();

	protected override void OnUpdate()
	{
		base.OnUpdate();
		if ( !Game.IsPlaying ) return;

		var game = CircleroyaleGame.Current;
		if ( game is null ) return;

		foreach ( var s in _pool ) s.Used = false;

		foreach ( var c in game.Cells )
		{
			if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;

			var slot = GetSlot( c.Id );
			if ( slot is null ) break;   // 池满:宁缺毋滥,下帧再试

			slot.Used = true;

			if ( !slot.Go.IsValid() ) continue;

			// 跟随分身渲染位置;头像直径 = 半径(直径的 50%,与主球同规格)
			slot.Go.WorldPosition = new Vector3( c.DrawPos.x, c.DrawPos.y, 0f );
			var d = c.Radius;
			slot.Sprite.Size = new Vector2( d, d );

			var tex = GetAvatarTexture( game, c );
			if ( tex is null )
			{
				slot.Sprite.Enabled = false;
				continue;
			}

			if ( slot.Texture != tex )
			{
				slot.Texture = tex;
				slot.Sprite.Sprite = Sprite.FromTexture( tex );
			}
			slot.Sprite.Enabled = true;
		}

		// 本帧没用到的槽隐藏回收
		foreach ( var s in _pool )
		{
			if ( !s.Used && s.Go.IsValid() )
			{
				s.Go.Enabled = false;
				s.PieceId = -1;
				s.Texture = null;
			}
		}
	}

	/// <summary> 按 Id 取槽:已有直接用,否则从空闲槽取(惰性建 GO) </summary>
	Slot GetSlot( int pieceId )
	{
		foreach ( var s in _pool )
		{
			if ( s.Used && s.PieceId == pieceId ) return s;
		}

		foreach ( var s in _pool )
		{
			if ( s.Used ) continue;

			if ( !s.Go.IsValid() )
			{
				// 顶层本地对象(不挂 Parent):客户端上 Neon GO 是 host 拥有的网络对象,
				// 往它下面挂子物体可能静默失效(M2 reparent 教训),顶层本地产物最稳
				s.Go = new GameObject( true, "PieceAvatar" );

				s.Sprite = s.Go.AddComponent<SpriteRenderer>();
				s.Sprite.Lighting = false;
				s.Sprite.Shadows = false;
				s.Texture = null;
			}

			s.Go.Enabled = true;
			s.PieceId = pieceId;
			return s;
		}
		return null;
	}

	/// <summary> 主人头像:bot 用几何图案(即时);真人拉 Steam 头像(缓存,1s 重试,15 次后占位图) </summary>
	Texture GetAvatarTexture( CircleroyaleGame game, CellPiece c )
	{
		if ( _avatars.TryGetValue( c.OwnerSteamId, out var entry ) )
		{
			if ( entry.Texture is not null ) return entry.Texture;
			if ( entry.SinceTry < 1f ) return null;   // 重试节流
		}
		else
		{
			entry = new AvatarEntry { SinceTry = 1f };   // 首帧立即尝试
			_avatars[c.OwnerSteamId] = entry;
		}

		entry.SinceTry = 0;

		var owner = game.FindBallBySteamId( c.OwnerSteamId );
		Texture tex = null;

		if ( owner.IsValid() && owner.IsBot )
			tex = BotAvatar.GetOrCreate( owner.PlayerName, c.ColorIndex );
		else if ( c.OwnerSteamId != 0 )
			tex = Texture.LoadAvatar( c.OwnerSteamId, 128 );

		if ( tex is null )
		{
			// 15 次拿不到(头像不存在/下载失败/双开合成 ID 拉不到)→ 占位图兜底,别无限重试
			if ( ++entry.Tries >= 15 )
			{
				tex = Texture.Load( GameConfig.AvatarPlaceholderPath, false );
			}
			if ( tex is null ) return null;
		}

		entry.Texture = tex;
		return tex;
	}
}