Rendering/NeonRenderer.cs
using System;

/// <summary>
/// 像素批渲染(v0.7.8.36 整体替换霓虹线框风):每帧重写各批次顶点,全场实体约 7 个 DrawCall。
/// 贴图批次走 line.shader 的 UV 采样 + 顶点色 tint(D_BLEND=0 普通 alpha):
/// 球/分身/孢子 = ball_body.png 白模 tint;食物 = food.png 六色条带;野刺/尖刺分身 = spike.png;
/// 刺爆弹幕 = spine.png(按速度旋转);道具 = powerups.png 图标叠程序化菱形底座。
/// 表情/头像(v0.7.8.40 从 SpriteRenderer 迁入——sprite 通道画在 OverlayWithDepth 批次之前,
/// 会被实心身体 quad 盖住):bot = face_0..5 按 Mood 选批,真人 = SetAvatar 登记的每 SteamId 贴图批次。
/// 线/三角批次(普通 alpha):椭圆投影、CPU 粒子方块、瞄准/边缘箭头。
/// 特效入口(Pop/Spark/Puff/Swallow)签名不变,调用方零改动。
/// </summary>
public sealed class NeonRenderer : Component
{
	SceneDynamicObject _shapeLines;   // 线段(普通 alpha):道具底座描边/边缘箭头尾杆
	SceneDynamicObject _shapeTris;    // 三角形(普通 alpha):投影/粒子/瞄准箭头/菱形填充
	SceneDynamicObject _bodies;       // ball_body.png:球/分身/孢子身体(顶点色 tint)
	SceneDynamicObject _food;         // food.png:糖豆(UV 取色格)
	SceneDynamicObject _spikes;       // spike.png:野刺 + 尖刺分身
	SceneDynamicObject _spines;       // spine.png:刺爆弹幕(旋转)
	SceneDynamicObject _icons;        // powerups.png:道具图标(UV 取格)
	SceneDynamicObject _rings;        // shield_ring.png:护盾 buff 虚线环(白模 tint,绕球慢转,v0.7.8.87)
	SceneDynamicObject[] _faces;      // face_0..5:bot 表情(Mood 选批,v0.7.8.40)

	/// <summary> ball_body.png 里球体内容占画布比例(process.py fit margin 0.06)——贴图直径→世界直径换算用 </summary>
	const float BodyContentFraction = 0.9375f;

	/// <summary> face_N.png 里脸部内容占画布比例——头像直径→世界直径换算用 </summary>
	const float FaceContentFraction = 0.9f;

	/// <summary>
	/// ⚠️ powerups.png 图集格子顺序是美术定的:speed/magnet/**shield/mass**/spike,
	/// 与 Kind 枚举(…Mass=2, Shield=3…)不同序——直接拿 Kind 当格子号会把盾和质量罐
	/// 画反(v0.7.8.92 用户实测"看到的和捡到的不一样")。下标=Kind,值=图集格子号。
	/// </summary>
	static readonly int[] PowerKindToCell = { 0, 1, 3, 2, 4 };

	sealed class AvatarBatch
	{
		public SceneDynamicObject Dyno;
		public Texture Tex;
		public Material Mat;   // 存档用(诊断读)
	}

	/// <summary> 真人 Steam 头像批次(每 SteamId 一个独立贴图批次) </summary>
	readonly Dictionary<long, AvatarBatch> _avatars = new();

	/// <summary> 头像登记表(Ball.SetAvatar 写,OnUpdate 消费;渲染器未就绪/热重载期间在此待命) </summary>
	static readonly Dictionary<long, Texture> _pendingAvatars = new();

	/// <summary> 全局引用(特效生成入口 NeonRenderer.Pop/Spark/Puff 走静态调用) </summary>
	public static NeonRenderer Instance { get; private set; }

	/// <summary> 帧率表(v0.7.8.104,cr_perf 渲染 A/B 测量用):1 秒窗口平均 </summary>
	public static float DebugFps { get; private set; }
	static int _fpsFrames;
	static TimeSince _fpsWindow = 999f;

	/// <summary> 调试 A/B(cr_perf 渲染减负定位):整体隐藏/恢复本渲染器全部批次 </summary>
	public void SetDebugVisible( bool visible )
	{
		foreach ( var d in new[] { _shapeLines, _shapeTris, _bodies, _food, _spikes, _spines, _icons, _rings } )
			if ( d is not null ) d.RenderingEnabled = visible;
		if ( _faces is not null )
			foreach ( var f in _faces )
				if ( f is not null ) f.RenderingEnabled = visible;
		foreach ( var a in _avatars.Values )
			if ( a.Dyno is not null ) a.Dyno.RenderingEnabled = visible;
	}

	protected override void OnStart()
	{
		base.OnStart();

		Instance = this;
		_shapeLines = CreateShapeBatch();
		_shapeTris = CreateShapeBatch();
		_bodies = CreateTexBatch( "ui/pixel/ball_body.png" );
		_food = CreateTexBatch( "ui/pixel/food.png" );
		_spikes = CreateTexBatch( "ui/pixel/spike.png" );
		_spines = CreateTexBatch( "ui/pixel/spine.png" );
		_icons = CreateTexBatch( "ui/pixel/powerups.png" );
		_rings = CreateTexBatch( "ui/pixel/shield_ring.png" );
		_faces = new SceneDynamicObject[6];
		for ( int i = 0; i < 6; i++ )
			_faces[i] = CreateTexBatch( $"ui/pixel/face_{i}.png", topmost: true );   // 0开心 1兴奋 2惊恐 3眩晕 4瞌睡 5得意
	}

	protected override void OnDestroy()
	{
		base.OnDestroy();

		// ⚠️ 世界重建只销毁子 GameObject,不会销毁组件创建的原生动态批次——遗留批次会带着
		// 最后一帧几何继续渲染,且旧材质被回收后引擎回退错误材质:满屏红几何 + 新管线创建崩溃
		// (实测红线/红块 + rendersystemvulkan 固定偏移 0x471a0)。组件销毁时必须显式 Delete。
		DeleteBatch( _shapeLines );
		DeleteBatch( _shapeTris );
		DeleteBatch( _bodies );
		DeleteBatch( _food );
		DeleteBatch( _spikes );
		DeleteBatch( _spines );
		DeleteBatch( _icons );
		DeleteBatch( _rings );
		if ( _faces is not null )
			foreach ( var f in _faces ) DeleteBatch( f );
		foreach ( var av in _avatars.Values ) DeleteBatch( av.Dyno );
		foreach ( var sp in _avatarSpare ) DeleteBatch( sp.Dyno );

		if ( Instance == this ) Instance = null;
	}

	static void DeleteBatch( SceneDynamicObject dyno )
	{
		if ( dyno is null ) return;
		try { dyno.Delete(); } catch { }
	}

	/// <summary> 材质强引用池:材质若只被原生对象引用,托管包装一旦被 GC,原生材质随之回收,
	/// 场景对象就画成引擎错误材质(红几何 = 实测的"会话内跑几分钟变红"主因之一)。创建的材质全部留档。 </summary>
	readonly List<Material> _mats = new();

	Material TrackMat( Material m )
	{
		if ( m is not null ) _mats.Add( m );
		return m;
	}

	SceneDynamicObject CreateShapeBatch( bool topmost = false )
	{
		var dyno = new SceneDynamicObject( Scene.SceneWorld );
		dyno.Material = TrackMat( LineMaterial.Create() );
		dyno.Flags.CastShadows = false;
		// 头像/表情批次放 OverlayWithoutDepth(比 OverlayWithDepth 晚一段绘制,无深度测试):
		// OverlayWithDepth 内部对各 dyno 的排序不受顶点 z 控制(实测表情被 z=1.0 的身体盖住)
		dyno.RenderLayer = topmost ? SceneRenderLayer.OverlayWithoutDepth : SceneRenderLayer.OverlayWithDepth;
		dyno.Attributes.SetCombo( "D_BLEND", 0 );   // 普通 alpha 混合(像素风无加色发光)
		return dyno;
	}

	sealed class TexBatch
	{
		public SceneDynamicObject Dyno;
		public string Path;
		public Texture Tex;
		public Material Mat;   // 存档用(Dyno.Material 只写不可读),DumpTexState 诊断读
	}

	/// <summary> 贴图批次登记(路径留底——热重载重建世界时 Texture.Load 可能瞬时失败,
	/// OnUpdate 里 1s 节流补载自愈,否则整场渲染成错误红纹理,实测) </summary>
	readonly List<TexBatch> _texBatches = new();

	TimeSince _sinceTexHeal = 1f;

	SceneDynamicObject CreateTexBatch( string texturePath, bool topmost = false )
	{
		var dyno = CreateShapeBatch( topmost );

		var batch = new TexBatch { Dyno = dyno, Path = texturePath };
		_texBatches.Add( batch );
		LoadTex( batch );

		return dyno;
	}

	void LoadTex( TexBatch b )
	{
		b.Tex = Texture.Load( b.Path, false );
		if ( b.Tex is null )
			Log.Warning( $"[pixel] texture not ready: {b.Path} (1s 后自动重试)" );

		b.Mat = TrackMat( MakeTexMaterial( b.Tex, pixel: true ) );   // SceneDynamicObject.Material 只写,先备好材质再赋值
		b.Dyno.Material = b.Mat;
	}

	/// <summary> 诊断(临时,红屏排查用):各贴图批次现存贴图 vs 现场重载 vs 材质绑定实况。
	/// 排查完可随 cr_tex 工具一起删。 </summary>
	public static string DumpTexState()
	{
		var inst = Instance;
		if ( inst is null || !inst.IsValid() ) return "no NeonRenderer instance";

		var sb = new System.Text.StringBuilder();
		sb.AppendLine( $"batches={inst._texBatches.Count} avatars={inst._avatars.Count} spare={inst._avatarSpare.Count}" );

		static string Tx( Texture t ) => t is null
			? "NULL"
			: $"err={t.IsError} loaded={t.IsLoaded} {t.Width}x{t.Height} mips={t.Mips} fmt={t.ImageFormat}";

		foreach ( var b in inst._texBatches )
		{
			var fresh = Texture.Load( b.Path, false );
			var mat = b.Mat;
			var matInfo = mat is null
				? "mat=NULL"
				: $"mat={mat.Name} matValid={mat.IsValid()} shValid={( mat.Shader is null ? "?" : mat.Shader.IsValid().ToString() )}";
			sb.AppendLine( $"{b.Path}: now[{Tx( b.Tex )}] fresh[{Tx( fresh )}] {matInfo}" );
		}

		int n = 0;
		foreach ( var kv in inst._avatars )
		{
			sb.AppendLine( $"avatar {kv.Key}: now[{Tx( kv.Value.Tex )}]" );
			if ( ++n >= 8 ) { sb.AppendLine( $"… +{inst._avatars.Count - n} more" ); break; }
		}

		// 现场探针:此刻新建一个 line.shader 材质,报告材质/着色器/贴图绑定是否可用
		// (红屏 = 所有批次回退错误材质时,这里能直接指出是材质创建失败还是绑定失败)
		var probe = Material.Create( "cr_probe", "shaders/line.shader" );
		if ( probe is null )
			sb.AppendLine( "probe: Material.Create -> NULL" );
		else
		{
			var sh = probe.Shader;
			sb.AppendLine( $"probe: valid={probe.IsValid()} shaderName='{probe.ShaderName}' shader={( sh is null ? "NULL" : sh.IsValid().ToString() )} first={( probe.FirstTexture is null ? "NULL" : "tex" )}" );
			var probeTex = Texture.Load( "ui/pixel/food.png", false );
			bool setOk = false;
			try { setOk = probe.Set( "g_tColor", probeTex ); } catch ( System.Exception e ) { sb.AppendLine( $"probe set threw: {e.GetType().Name}" ); }
			sb.AppendLine( $"probe set g_tColor -> {setOk}" );
		}
		return sb.ToString();
	}

	// —— 临时诊断(红屏排查):每 30s 自检一次"材质链"健康(材质/着色器有效 + 贴图可绑定 +
	//    纹理非错误纹理),只在状态翻转时打日志——故障发生的时刻会自己写进日志文件。排查完删除。——
	static Material _probeMat;
	static int _probeState = -1;   // -1 未初始化;0 OK;1 FAIL
	TimeSince _sinceProbe = 30f;

	void ProbeMaterialHealth()
	{
		if ( _sinceProbe < 30f ) return;
		_sinceProbe = 0;

		if ( _probeMat is null )
			_probeMat = Material.Create( "cr_probe", "shaders/line.shader" );

		var tex = Texture.Load( "ui/pixel/food.png", false );
		bool matOk = _probeMat is not null && _probeMat.IsValid();
		bool shOk = matOk && _probeMat.Shader is not null && _probeMat.Shader.IsValid();
		bool setOk = false;
		if ( matOk ) { try { setOk = _probeMat.Set( "g_tColor", tex ); } catch { } }
		bool texOk = tex is not null && !tex.IsError;
		bool ok = matOk && shOk && setOk && texOk;

		int state = ok ? 0 : 1;
		if ( state != _probeState )
		{
			_probeState = state;
			GameLog.Info( $"[pixel] material probe {( ok ? "OK" : "FAIL" )} (mat={matOk} shader={shOk} set={setOk} tex={texOk})" );
		}
	}

	/// <summary>
	/// 贴图批次材质(static:TerritoryRenderer 的旗/城堡贴图批次同款复用):独立实例。
	/// pixel=true 换 **Point/Nearest 点采样**(v0.7.8.93):像素贴图放大十几倍必须最近邻,
	/// 双线性会把像素颗粒糊掉——旧实现意图就是 point 采样,但 SamplerIndex 写死 0(默认
	/// bilinear 槽位)从未生效。走 line.shader 的 Bindless::GetSampler(SamplerIndex),槽位由
	/// SamplerState.Index 现取(引擎内部有缓存)。头像等照片保持默认 bilinear(点采样会锯齿)。
	/// SamplerIndex 设不进去不致命(回退默认采样,只是糊)。
	/// </summary>
	public static Material MakeTexMaterial( Texture tex, bool pixel = false )
	{
		var mat = LineMaterial.Create();
		try
		{
			if ( pixel )
				mat.Set( "SamplerIndex", PixelSamplerIndex() );
			else
				mat.Set( "SamplerIndex", 0 );
		}
		catch { }
		if ( tex is not null ) mat.Set( "g_tColor", tex );
		return mat;
	}

	static int? _pointSamplerIndex;

	/// <summary> Point 采样的 bindless 槽位(懒取:静态初始化会在渲染设备就绪前跑,专用服务器上会炸) </summary>
	static int PixelSamplerIndex()
	{
		_pointSamplerIndex ??= new Sandbox.Rendering.SamplerState { Filter = Sandbox.Rendering.FilterMode.Point }.Index;
		return _pointSamplerIndex.Value;
	}

	/// <summary> 真人头像登记(Ball 各端自取 Steam 头像后调;每 SteamId 一个独立贴图批次) </summary>
	public static void SetAvatar( long steamId, Texture tex )
	{
		if ( steamId == 0 || tex is null ) return;
		_pendingAvatars[steamId] = tex;
	}

	/// <summary> 消化头像登记:缺批次补建、贴图变了换材质(渲染器未就绪时的登记留到下一帧生效) </summary>
	void SyncAvatars()
	{
		foreach ( var kv in _pendingAvatars )
		{
			if ( _avatars.TryGetValue( kv.Key, out var av ) && av.Tex == kv.Value ) continue;

			if ( av is null )
			{
				av = TakeAvatarBatch();
				_avatars[kv.Key] = av;
			}
			av.Mat = TrackMat( MakeTexMaterial( kv.Value ) );
			av.Dyno.Material = av.Mat;
			av.Tex = kv.Value;
		}
	}

	readonly HashSet<long> _avatarSeen = new();
	readonly List<long> _avatarDrop = new();
	readonly List<AvatarBatch> _avatarSpare = new();   // 回收来复用的空批次(避免现删原生对象)
	TimeSince _sinceAvatarPrune = 5f;

	/// <summary> 取一个可用批次:优先复用回收池,池空才新建(v0.7.8.87) </summary>
	AvatarBatch TakeAvatarBatch()
	{
		if ( _avatarSpare.Count > 0 )
		{
			var spare = _avatarSpare[^1];
			_avatarSpare.RemoveAt( _avatarSpare.Count - 1 );
			return spare;
		}
		return new AvatarBatch { Dyno = CreateShapeBatch( topmost: true ) };
	}

	/// <summary>
	/// 回收不在场玩家的头像批次(v0.7.8.87):`_avatars` 是"每 SteamId 一个动态批次 + 材质"的重资源,
	/// 不回收会随"这台机器见过的玩家总数"一直涨。贴图登记留在静态 `_pendingAvatars`(很轻),
	/// 同一玩家再入场时 SyncAvatars 会复用批次重建。
	/// ⚠️ 只清几何、进回收池,**不调用 SceneObject.Delete()**:批次是"每帧 Clear + 重新 Init"驱动的,
	/// 在游戏循环里销毁原生场景对象风险高(清几何即可让脱管批次不再显示,避免幽灵头像)
	/// </summary>
	void PruneAvatars( CircleroyaleGame game )
	{
		if ( _avatars.Count == 0 || _sinceAvatarPrune < 5f ) return;
		_sinceAvatarPrune = 0;

		_avatarSeen.Clear();
		foreach ( var b in game.Balls )
		{
			if ( b.IsValid() && !b.IsBot ) _avatarSeen.Add( b.OwnerSteamId );   // 死亡待重生的球仍算在场
		}

		_avatarDrop.Clear();
		foreach ( var kv in _avatars )
		{
			if ( !_avatarSeen.Contains( kv.Key ) ) _avatarDrop.Add( kv.Key );
		}

		foreach ( var k in _avatarDrop )
		{
			var av = _avatars[k];
			_avatars.Remove( k );
			if ( av?.Dyno is null ) continue;

			av.Dyno.Clear();   // 清掉最后一帧的几何:脱管后没人再清,留着就是场景里的幽灵头像
			av.Tex = null;
			if ( _avatarSpare.Count < 24 ) _avatarSpare.Add( av );
		}
	}

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

		// 帧率表(v0.7.8.104,cr_perf A/B 测量用)
		_fpsFrames++;
		if ( _fpsWindow > 1f )
		{
			DebugFps = _fpsFrames / _fpsWindow;
			_fpsFrames = 0;
			_fpsWindow = 0;
		}

		var game = CircleroyaleGame.Current;
		if ( game is null || _shapeTris is null || _bodies is null || _faces is null ) return;

		_shapeLines.Clear();
		_shapeLines.Init( Graphics.PrimitiveType.Lines );
		_shapeTris.Clear();
		_shapeTris.Init( Graphics.PrimitiveType.Triangles );
		_bodies.Clear();
		_bodies.Init( Graphics.PrimitiveType.Triangles );
		_food.Clear();
		_food.Init( Graphics.PrimitiveType.Triangles );
		_spikes.Clear();
		_spikes.Init( Graphics.PrimitiveType.Triangles );
		_spines.Clear();
		_spines.Init( Graphics.PrimitiveType.Triangles );
		_icons.Clear();
		_icons.Init( Graphics.PrimitiveType.Triangles );
		_rings.Clear();
		_rings.Init( Graphics.PrimitiveType.Triangles );

		SyncAvatars();
		PruneAvatars( game );

		// 贴图自愈(1s 节流):热重载/资产刷新期间加载失败的批次补载
		if ( _sinceTexHeal > 1f )
		{
			_sinceTexHeal = 0;
			foreach ( var b in _texBatches )
			{
				if ( b.Tex is not null ) continue;
				LoadTex( b );
			}
		}

		ProbeMaterialHealth();

		foreach ( var f in _faces )
		{
			f.Clear();
			f.Init( Graphics.PrimitiveType.Triangles );
		}
		foreach ( var av in _avatars.Values )
		{
			av.Dyno.Clear();
			av.Dyno.Init( Graphics.PrimitiveType.Triangles );
		}

		UpdateParticles();
		UpdateWallPulses( Time.Delta );

		// ---- 球:椭圆投影 + tint 白模 + 出生保护闪烁 ----
		foreach ( var ball in game.Balls )
		{
			if ( !ball.IsValid() || !ball.Alive ) continue;

			var c = ball.WorldPosition;
			var r = ball.Radius;

			// 投影画在脚下:屏幕下方 = 世界 -X(相机 90° 滚转),横长竖扁
			AddShadowEllipse( c + new Vector3( -r * 0.12f, 0f, 0.25f ), r * 0.32f, r * 0.72f );

			// 出生保护:整球方波闪烁(暗相 35%),闪烁在身体 tint 上,头像保持可见
			var blink = 1f;
			if ( ball.IsProtected )
				blink = MathF.Sin( Time.Now * GameConfig.SpawnBlinkHz * MathF.PI * 2f ) > 0f ? 1f : 0.35f;
			var tint = new Color( ball.NeonColor.r, ball.NeonColor.g, ball.NeonColor.b, blink );

			AddQuad( _bodies, c, r / BodyContentFraction, r / BodyContentFraction, 0f,
				new Vector4( 0f, 0f, 1f, 1f ), tint, 1.0f );

			// 护盾 buff(M6.5):白模虚线环染道具蓝,绕球慢转(v0.7.8.87 接 shield_ring.png)
			if ( ball.HasBuff( PowerUpManager.Kind.Shield ) )
				AddQuad( _rings, c, r * 1.32f, r * 1.32f, Time.Now * 0.7f,
					new Vector4( 0f, 0f, 1f, 1f ), PowerUpManager.ColorOf( (byte)PowerUpManager.Kind.Shield ), 1.05f );

			// 头像(v0.7.8.40):bot=表情脸(Mood 选批),真人=Steam 头像;直径=半径,画在身体之上
			DrawAvatar( c, r, ball.IsBot, ball.Mood, ball.OwnerSteamId, 1.1f );

			// 瞄准箭头(v0.7.0.0):本机球外缘白色小三角指向光标,仅对局中显示
			if ( game.IsMatchStarted && ball == game.LocalBall && ball.AimDir.Length > 0.01f )
				WriteAimArrow( ball );
		}

		// ---- 分身/孢子/尖刺分身(纯数据实体):tint 白模 / 刺贴图,出生弹跳保留 ----
		foreach ( var c in game.Cells )
		{
			var color = GameConfig.Palette[Math.Clamp( c.ColorIndex, 0, GameConfig.Palette.Length - 1 )];
			var pos = new Vector3( c.DrawPos.x, c.DrawPos.y, 0f );
			var r = c.Radius;

			// 出生弹跳(v0.7.8.28 juice):新 id 首帧记时刻,0.3s 内缩放脉冲。客户端镜像用"首见 id"判定,双端统一
			if ( !_seenCells.Contains( c.Id ) )
			{
				_seenCells.Add( c.Id );
				_cellBorn[c.Id] = 0;
				if ( _seenCells.Count > 512 ) PruneCellFx( game );
			}
			if ( _cellBorn.TryGetValue( c.Id, out var born ) && born < 0.3f )
			{
				var phase = born / 0.3f;
				r *= phase < 0.3f
					? MathX.Lerp( 0.55f, 1.14f, phase / 0.3f )
					: MathX.Lerp( 1.14f, 1f, ( phase - 0.3f ) / 0.7f );
			}

			if ( c.PieceKind == CellPiece.Kind.SpikeMinion )
			{
				// 尖刺分身(v0.7.4.0):刺族贴图(紫色=是刺),尺寸不小于野刺观感
				var sr = MathF.Max( r, 40f );
				AddQuad( _spikes, pos, sr, sr, 0f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, 0.65f );
			}
			else
			{
				// 分身/孢子:同一张白模 tint(孢子略缩,观感=小糖豆团)
				var scale = c.PieceKind == CellPiece.Kind.EjectedMass ? 0.85f : 1f;
				var half = r * scale / BodyContentFraction;
				var z = c.PieceKind == CellPiece.Kind.EjectedMass ? 0.8f : 0.9f;
				AddQuad( _bodies, pos, half, half, 0f, new Vector4( 0f, 0f, 1f, 1f ), color, z );

				// 分身头像(v0.7.8.40 自 PieceAvatarLayer 并入):只给 SplitPiece,直径=分身半径
				if ( c.PieceKind == CellPiece.Kind.SplitPiece )
				{
					var owner = game.FindBallBySteamId( c.OwnerSteamId );
					DrawAvatar( pos, r, owner.IsValid() && owner.IsBot,
						owner.IsValid() ? owner.Mood : (byte)0, c.OwnerSteamId, 0.95f );
				}
			}
		}

		// ---- 野刺:喂食长大后直接缩放贴图 ----
		var spikes = game.Spikes;
		if ( spikes is not null )
		{
			for ( int i = 0; i < spikes.Length; i++ )
			{
				var grow = 1f + GameConfig.SpikeGrowVisual * Math.Clamp( game.SpikeFedAt( i ) / GameConfig.SpikeGrowTarget, 0f, 1f );
				var half = GameConfig.SpikeRadius * grow;
				AddQuad( _spikes, new Vector3( spikes[i].x, spikes[i].y, 0f ), half, half, 0f,
					new Vector4( 0f, 0f, 1f, 1f ), Color.White, 0.6f );
			}
		}

		// ---- 刺爆弹幕(v0.7.8.24):刺弹贴图按速度方向旋转,随寿命淡出 ----
		foreach ( var sp in game.Spines )
		{
			var remain = 1f - Math.Clamp( sp.SinceSpawn / GameConfig.SpineLife, 0f, 1f );
			var dir = sp.Vel;
			if ( dir.Length < 0.001f ) continue;
			var rot = MathF.Atan2( dir.y, dir.x );
			var tint = new Color( 1f, 1f, 1f, 0.35f + 0.65f * remain );
			AddQuad( _spines, new Vector3( sp.Pos.x, sp.Pos.y, 0f ), 17f, 17f, rot,
				new Vector4( 0f, 0f, 1f, 1f ), tint, 1.2f );
		}

		// ---- 道具(v0.7.3.0):旋转菱形底座(普通 alpha)+ 种类图标贴图 ----
		var power = game.PowerUps;
		if ( power is not null && power.Ready )
		{
			var spin = Time.Now * 1.6f;
			var breathe = 1f + MathF.Sin( Time.Now * 3f ) * 0.12f;

			foreach ( var s in power.Slots )
			{
				if ( !s.Alive ) continue;

				var pos = new Vector3( s.Pos.x, s.Pos.y, 0f );
				var kindColor = PowerUpManager.ColorOf( s.Kind );
				var r = GameConfig.PowerUpRadius * breathe;

				WriteDiamond( pos, r, spin, kindColor );

				var k = PowerKindToCell[Math.Clamp( s.Kind % 5, 0, 4 )];
				var uv = new Vector4( k / 5f, 0f, ( k + 1 ) / 5f, 1f );
				AddQuad( _icons, pos + Vector3.Up * 1.3f, r * 1.35f, r * 1.35f, MathF.PI * 0.5f, uv, Color.White, 1.3f );
			}
		}

		// ---- 食物:糖豆贴图按色号取 UV 格(视口剔除保留) ----
		var foods = game.Food?.Foods;
		if ( foods is not null )
		{
			// 视口剔除(v0.6.3.0):4140 颗食物只有 ~20% 在画面里。
			// ⚠️ 轴向(v0.7.8.87 修):相机固定 90° 滚转——屏幕横向=世界 Y、屏幕纵向=世界 X
			// (见 GameHud.UpdateTagPositions 同一套投影式),所以"按宽高比放开的横向余量"必须给 Y、
			// 窄余量给 X。此前两轴写反:屏幕左右两侧仍可视范围内的食物被剔掉(观感就是缺食物)
			var fr = GameConfig.FoodRadius;
			var cullCx = Scene.Camera.WorldPosition.x;
			var cullCy = Scene.Camera.WorldPosition.y;
			var cullAspect = MathF.Max( 2.4f, Screen.Height > 0f ? Screen.Width / Screen.Height : 2.4f );
			var cullHx = Scene.Camera.OrthographicHeight * 0.5f + 64f;                 // 世界 X = 屏幕纵向(窄)
			var cullHy = Scene.Camera.OrthographicHeight * 0.5f * cullAspect + 64f;    // 世界 Y = 屏幕横向(宽,超宽屏不缺左右食物)

			for ( int i = 0; i < foods.Length; i++ )
			{
				if ( !foods[i].Alive ) continue;

				var fx = foods[i].Pos.x;
				var fy = foods[i].Pos.y;
				if ( MathF.Abs( fx - cullCx ) > cullHx || MathF.Abs( fy - cullCy ) > cullHy ) continue;

				var k = foods[i].ColorIndex % 6;
				var uv = new Vector4( k / 6f, 0f, ( k + 1 ) / 6f, 1f );
				AddQuad( _food, new Vector3( fx, fy, 0f ), fr / 0.95f, fr / 0.95f, 0f, uv, Color.White, 0.55f );
			}
		}

		DrawParticles();
		DrawWallPulses();
		DrawEdgeArrows();
	}

	// ---- 屏幕边缘队友指示箭头(v0.7.8.12):GameHud 名牌重投时写入请求,本组件 OnUpdate 末尾消费 ----

	sealed class EdgeArrowReq
	{
		public Vector3 Pos;      // 贴边点(世界)
		public Vector2 Dir;      // 指向队友真实方位(世界,单位向量)
		public Color Color;
		public float Size;       // 世界单位(按每像素世界单位折算,保持像素大小恒定)
	}

	static readonly List<EdgeArrowReq> _edgeArrows = new();

	/// <summary> 请求一支边缘箭头(贴边世界点 + 指向 + 颜色)。OnUpdate 消费后清空,晚一帧无感 </summary>
	public static void EdgeArrow( Vector3 pos, Vector2 dir, Color color, float size )
	{
		_edgeArrows.Add( new EdgeArrowReq { Pos = pos, Dir = dir, Color = color, Size = size } );
	}

	/// <summary> 实心三角箭头 + 短尾杆,朝向 Dir </summary>
	void DrawEdgeArrows()
	{
		for ( int i = 0; i < _edgeArrows.Count; i++ )
		{
			var r = _edgeArrows[i];
			var d = new Vector3( r.Dir.x, r.Dir.y, 0f );
			var perp = new Vector3( -r.Dir.y, r.Dir.x, 0f );
			var len = r.Size;
			var halfW = len * 0.5f;

			var tip = r.Pos + d * len;
			var b1 = r.Pos + perp * halfW;
			var b2 = r.Pos - perp * halfW;

			AddTri( _shapeTris, b1, b2, tip, r.Color * 0.9f );

			// 尾杆:从箭头向屏内延伸一小段,提示"方向"而非"位置点"
			var t0 = r.Pos - d * len * 0.6f;
			_shapeLines.AddVertex( new Vertex( t0, r.Color * 0.5f ) );
			_shapeLines.AddVertex( new Vertex( r.Pos, r.Color * 0.5f ) );
		}
		_edgeArrows.Clear();
	}

	// ---- CPU 粒子(M3 表现力):普通 alpha 批次方块粒子 ----

	/// <summary> 瞄准箭头(v0.7.0.0):本机球外缘的白色实心小三角,指向光标(分裂/吐孢子方向) </summary>
	void WriteAimArrow( Ball ball )
	{
		var dir = ball.AimDir;
		var c = ball.WorldPosition;
		var r = ball.Radius;

		var len = Math.Clamp( r * 0.22f, 18f, 64f );   // 箭头长度随球径微缩放
		var halfW = len * 0.42f;
		var gap = r * 1.04f;

		var dir3 = new Vector3( dir.x, dir.y, 0f );
		var perp = new Vector3( -dir.y, dir.x, 0f );

		var baseC = c + dir3 * gap;
		var tip = c + dir3 * ( gap + len );
		var p1 = baseC + perp * halfW;
		var p2 = baseC - perp * halfW;

		AddTri( _shapeTris, p1, p2, tip, new Color( 1f, 1f, 1f, 0.55f ) );
	}

	/// <summary> 单个实心三角 </summary>
	void AddTri( SceneDynamicObject batch, Vector3 a, Vector3 b, Vector3 c, Color color )
	{
		batch.AddVertex( new Vertex( a, color ) );
		batch.AddVertex( new Vertex( b, color ) );
		batch.AddVertex( new Vertex( c, color ) );
	}

	/// <summary> 贴图四边形(两个三角):center 中心、half 半宽/半高、rot 弧度、uv=(x0,y0,x1,y1) </summary>
	static void AddQuad( SceneDynamicObject b, Vector3 center, float halfX, float halfY, float rot, Vector4 uv, Color color, float z )
	{
		var cos = MathF.Cos( rot );
		var sin = MathF.Sin( rot );
		var rx = cos * halfX; var ry = sin * halfX;      // right 轴
		var ux = -sin * halfY; var uy = cos * halfY;     // up 轴

		var v0 = new Vertex( new Vector3( center.x - rx - ux, center.y - ry - uy, z ), new Vector4( uv.x, uv.y, 0f, 0f ), color );
		var v1 = new Vertex( new Vector3( center.x + rx - ux, center.y + ry - uy, z ), new Vector4( uv.z, uv.y, 0f, 0f ), color );
		var v2 = new Vertex( new Vector3( center.x + rx + ux, center.y + ry + uy, z ), new Vector4( uv.z, uv.w, 0f, 0f ), color );
		var v3 = new Vertex( new Vector3( center.x - rx + ux, center.y - ry + uy, z ), new Vector4( uv.x, uv.w, 0f, 0f ), color );

		b.AddVertex( v0 ); b.AddVertex( v1 ); b.AddVertex( v2 );
		b.AddVertex( v0 ); b.AddVertex( v2 ); b.AddVertex( v3 );
	}

	/// <summary> 头像/表情:直径=球半径(占直径 50%,用户指定);bot 从 face_N 批次取(Mood 选格),
	/// 真人从 Steam 头像批次取。批次在 OverlayWithoutDepth 层,天然画在身体(OverlayWithDepth)之上。
	/// rot=π/2:相机 90° 滚转下贴图 u 轴朝屏幕竖直,不转的话脸是横躺的(食物/球体等圆形看不出来) </summary>
	void DrawAvatar( Vector3 c, float radius, bool bot, byte mood, long steamId, float z )
	{
		var half = radius * 0.5f / FaceContentFraction;

		if ( bot )
		{
			var k = Math.Clamp( (int)mood, 0, 5 );
			AddQuad( _faces[k], c, half, half, MathF.PI * 0.5f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, z );
		}
		else if ( _avatars.TryGetValue( steamId, out var av ) )
		{
			AddQuad( av.Dyno, c, half, half, MathF.PI * 0.5f, new Vector4( 0f, 0f, 1f, 1f ), Color.White, z );
		}
	}

	/// <summary> 椭圆投影(世界 XY 平面:ax=世界 X 半轴=屏幕竖向,ay=世界 Y 半轴=屏幕横向) </summary>
	void AddShadowEllipse( Vector3 center, float ax, float ay, int segments = 14 )
	{
		segments = Math.Max( 6, segments );
		var prev = center + new Vector3( ax, 0f, 0f );
		for ( int i = 1; i <= segments; i++ )
		{
			var a = i * MathF.PI * 2f / segments;
			var p = center + new Vector3( MathF.Cos( a ) * ax, MathF.Sin( a ) * ay, 0f );
			AddTri( _shapeTris, center, prev, p, new Color( 0.59f, 0.67f, 0.82f, 0.30f ) );
			prev = p;
		}
	}

	sealed class Particle
	{
		public Vector3 Pos;
		public Vector3 Vel;
		public float Life;
		public float MaxLife;
		public float Size;
		public Color Color;
		public Vector3 Target;   // Seek=true:吸引目标(吞球滑进吃者嘴里)
		public bool Seek;
	}

	readonly List<Particle> _particles = new();
	readonly HashSet<int> _seenCells = new();                    // 出生弹跳:见过的分身/孢子 id(客户端镜像不带 SinceSpawn,用"首见"双端统一判定)
	readonly Dictionary<int, TimeSince> _cellBorn = new();

	/// <summary> 爆裂(死亡/撞刺):中大粒子径向飞散 </summary>
	public static void Pop( Vector3 pos, int colorIndex, float radius )
	{
		Spawn( pos, colorIndex, 26, 90f + radius * 0.9f, 0.45f, 0.9f, 3f, 9f );
	}

	/// <summary> 火花(吃食物):几颗小碎屑一闪 </summary>
	public static void Spark( Vector3 pos, int colorIndex )
	{
		Spawn( pos, colorIndex, 5, 100f, 0.2f, 0.4f, 2f, 4f );
	}

	/// <summary> 火花(自定义色,道具拾取用):种类色不在调色板里,直接给 Color </summary>
	public static void Spark( Vector3 pos, Color color )
	{
		SpawnColored( pos, color, 10, 140f, 0.25f, 0.5f, 2f, 5f );
	}

	/// <summary> 喷发(分裂/吐孢子生成瞬间) </summary>
	public static void Puff( Vector3 pos, int colorIndex )
	{
		Spawn( pos, colorIndex, 8, 150f, 0.25f, 0.5f, 2f, 5f );
	}

	/// <summary> 吞球滑入(v0.7.8.28 juice):被吃者缩成一团、加速滑进吃者嘴里,尺寸随寿命收缩归零 </summary>
	public static void Swallow( Vector3 pos, int colorIndex, Vector3 target, float radius )
	{
		var inst = Instance;
		if ( inst is null || !inst.IsValid() ) return;

		if ( inst._particles.Count >= GameConfig.FxMaxParticles )
			inst._particles.RemoveAt( 0 );

		var life = 0.3f;
		inst._particles.Add( new Particle
		{
			Pos = pos,
			Target = target,
			Seek = true,
			Life = life,
			MaxLife = life,
			Size = MathF.Max( 10f, radius * 0.9f ),
			Color = GameConfig.Palette[Math.Clamp( colorIndex, 0, GameConfig.Palette.Length - 1 )],
		} );
		Spawn( pos, colorIndex, 4, 120f, 0.15f, 0.3f, 2f, 4f );   // 少量碎屑点缀
	}

	static void Spawn( Vector3 pos, int colorIndex, int count, float maxSpeed, float minLife, float maxLife, float minSize, float maxSize )
	{
		SpawnColored( pos, GameConfig.Palette[Math.Clamp( colorIndex, 0, GameConfig.Palette.Length - 1 )],
			count, maxSpeed, minLife, maxLife, minSize, maxSize );
	}

	static void SpawnColored( Vector3 pos, Color color, int count, float maxSpeed, float minLife, float maxLife, float minSize, float maxSize )
	{
		var inst = Instance;
		if ( inst is null || !inst.IsValid() ) return;

		for ( int i = 0; i < count; i++ )
		{
			if ( inst._particles.Count >= GameConfig.FxMaxParticles )
				inst._particles.RemoveAt( 0 );   // 挤掉最老的,保帧率优先

			var a = Game.Random.Float( 0f, MathF.PI * 2f );
			var sp = maxSpeed * Game.Random.Float( 0.35f, 1f );
			var life = Game.Random.Float( minLife, maxLife );
			inst._particles.Add( new Particle
			{
				Pos = pos + new Vector3( Game.Random.Float( -6f, 6f ), Game.Random.Float( -6f, 6f ), 0f ),
				Vel = new Vector3( MathF.Cos( a ) * sp, MathF.Sin( a ) * sp, 0f ),
				Life = life,
				MaxLife = life,
				Size = Game.Random.Float( minSize, maxSize ),
				Color = color,
			} );
		}
	}

	void UpdateParticles()
	{
		var dt = Time.Delta;
		for ( int i = _particles.Count - 1; i >= 0; i-- )
		{
			var p = _particles[i];
			p.Life -= dt;
			if ( p.Life <= 0f )
			{
				_particles.RemoveAt( i );
				continue;
			}
			if ( p.Seek )
			{
				p.Pos = Vector3.Lerp( p.Pos, p.Target, 1f - MathF.Exp( -11f * dt ) );   // 指数吸向吃者嘴
			}
			else
			{
				p.Pos += p.Vel * dt;
				p.Vel *= MathF.Exp( -3.5f * dt );   // 阻尼,飞散后减速悬浮消散
			}
		}
	}

	/// <summary> 方块粒子(像素风):轴对齐小方块,尺寸/透明度随寿命衰减 </summary>
	void DrawParticles()
	{
		foreach ( var p in _particles )
		{
			var a = p.Life / p.MaxLife;
			var size = p.Size * ( 0.5f + a * 0.5f );
			var col = new Color( p.Color.r, p.Color.g, p.Color.b, ( 0.25f + a * 0.75f ) * 0.9f );
			AddQuad( _shapeTris, p.Pos, size * 0.5f, size * 0.5f, 0f, new Vector4( 0f, 0f, 1f, 1f ), col, 1.4f );
		}
	}

	// ---- 撞墙脉冲(v0.7.8.78):球被围墙钳住时,接触点一圈小方块向外扩散淡出——"墙挡住了"的反馈 ----

	const float WallPulseLife = 0.4f;

	sealed class WallPulseFx
	{
		public Vector3 Pos;
		public float Age;
		public Color Color;
	}

	readonly List<WallPulseFx> _wallPulses = new();

	/// <summary> 围墙受击脉冲:接触点 + 撞球颜色(程序化方块环;日后可换贴图环) </summary>
	public static void WallPulse( Vector3 pos, Color color )
	{
		var inst = Instance;
		if ( inst is null || !inst.IsValid() ) return;

		if ( inst._wallPulses.Count >= 24 ) inst._wallPulses.RemoveAt( 0 );
		inst._wallPulses.Add( new WallPulseFx { Pos = pos, Age = 0f, Color = color } );
	}

	void UpdateWallPulses( float dt )
	{
		for ( int i = _wallPulses.Count - 1; i >= 0; i-- )
		{
			_wallPulses[i].Age += dt;
			if ( _wallPulses[i].Age >= WallPulseLife ) _wallPulses.RemoveAt( i );
		}
	}

	void DrawWallPulses()
	{
		foreach ( var w in _wallPulses )
		{
			var t = w.Age / WallPulseLife;
			var r = 8f + t * 54f;                              // 半径外扩
			var alpha = ( 1f - t ) * 0.85f;                    // 渐隐
			var size = 8f - t * 3f;                            // 方块随扩散略缩
			var col = new Color( w.Color.r, w.Color.g, w.Color.b, alpha );

			for ( int s = 0; s < 14; s++ )
			{
				var a = s * MathF.PI * 2f / 14f;
				var p = w.Pos + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0f );
				AddQuad( _shapeTris, p, size * 0.5f, size * 0.5f, 0f, new Vector4( 0f, 0f, 1f, 1f ), col, 1.4f );
			}
		}
	}

	/// <summary> 出生弹跳注册表超限时整体重建(当前活着的 id 重新立册,重置瞬间轻微再弹一次,罕见可接受) </summary>
	void PruneCellFx( CircleroyaleGame game )
	{
		_seenCells.Clear();
		_cellBorn.Clear();
		foreach ( var c in game.Cells )
			_seenCells.Add( c.Id );
	}

	/// <summary> 道具菱形底座:旋转的正方形(半透明填充 + 描边),像素风下作图标的衬底 </summary>
	void WriteDiamond( Vector3 c, float r, float spin, Color color )
	{
		var pts = new Vector3[4];
		for ( int i = 0; i < 4; i++ )
		{
			var a = spin + i * MathF.PI * 0.5f;
			pts[i] = c + new Vector3( MathF.Cos( a ) * r, MathF.Sin( a ) * r, 0.7f );
		}

		for ( int i = 0; i < 4; i++ )
		{
			AddTri( _shapeTris, c + Vector3.Up * 0.7f, pts[i], pts[( i + 1 ) % 4], color * 0.35f );
			_shapeLines.AddVertex( new Vertex( pts[i], color ) );
			_shapeLines.AddVertex( new Vertex( pts[( i + 1 ) % 4], color ) );
		}
	}
}