Game/FoodManager.cs
using System;

/// <summary>
/// 食物管理(**每台机器一份本地实例,不走对象复制**——NetList 挂对象无法复制到客户端,实测):
/// host 权威生成/吃/重生;状态变化经 NetworkManager 的静态 RPC 广播,各端应用到本地列表。
/// Init/TryEat/Tick 仅权威端调用;Apply* 仅供客户端应用远端事件。
/// 空间哈希(v0.6.3.0):3 倍场地 4140 颗食物后,吃判定 O(球数×食物)≈20 万次/帧成为瓶颈——
/// 512 单位桶查询把每球检查量从 4140 降到几十。
/// </summary>
public sealed class FoodManager
{
	FoodData[] _foods;
	TimeSince[] _eatenAt;                           // 仅 host(重生计时)
	readonly List<int> _pendingRespawn = new();     // 仅 host

	// ---- 空间哈希(仅 host 吃判定用;客户端无吃判定不建桶)----
	const float BucketSize = 512f;
	Dictionary<int, List<int>> _buckets;
	int _cols;
	readonly List<int> _nearResults = new();

	/// <summary> 存活食物数 </summary>
	public int AliveCount { get; private set; }

	/// <summary> 全量食物(NeonRenderer 每帧读;各端内容一致) </summary>
	public FoodData[] Foods => _foods;

	int BucketKey( Vector2 p )
	{
		var half = GameConfig.ArenaHalfSize;
		int bx = Math.Clamp( (int) ( ( p.x + half ) / BucketSize ), 0, _cols - 1 );
		int by = Math.Clamp( (int) ( ( p.y + half ) / BucketSize ), 0, _cols - 1 );
		return by * _cols + bx;
	}

	void BucketAdd( int index ) => BucketAddAt( index, _foods[index].Pos );

	void BucketAddAt( int index, Vector2 p )
	{
		int key = BucketKey( p );
		if ( !_buckets.TryGetValue( key, out var list ) )
		{
			list = new List<int>( 16 );
			_buckets[key] = list;
		}
		list.Add( index );
	}

	void BucketMove( int index, Vector2 from )
	{
		int oldKey = BucketKey( from );
		if ( _buckets.TryGetValue( oldKey, out var list ) )
			list.Remove( index );

		BucketAdd( index );
	}

	/// <summary> 圆形范围内的存活食物下标(近似:按桶粗筛,调用方需精确复核距离)。
	/// 返回内部复用列表,下一次调用即失效。仅 host(吃判定权威端)。 </summary>
	public List<int> Nearby( Vector2 pos, float radius )
	{
		_nearResults.Clear();
		if ( _buckets is null || _foods is null ) return _nearResults;

		int minBx = Math.Clamp( (int) ( ( pos.x - radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );
		int maxBx = Math.Clamp( (int) ( ( pos.x + radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );
		int minBy = Math.Clamp( (int) ( ( pos.y - radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );
		int maxBy = Math.Clamp( (int) ( ( pos.y + radius + GameConfig.ArenaHalfSize ) / BucketSize ), 0, _cols - 1 );

		for ( int by = minBy; by <= maxBy; by++ )
		{
			for ( int bx = minBx; bx <= maxBx; bx++ )
			{
				if ( !_buckets.TryGetValue( by * _cols + bx, out var list ) ) continue;

				foreach ( var idx in list )
				{
					if ( _foods[idx].Alive ) _nearResults.Add( idx );
				}
			}
		}
		return _nearResults;
	}

	/// <summary> host 投食(权威端;随后经 FoodFull RPC 发给新连接) </summary>
	public void Init()
	{
		_foods = new FoodData[GameConfig.FoodCount];
		_eatenAt = new TimeSince[GameConfig.FoodCount];
		_pendingRespawn.Clear();
		_buckets = new Dictionary<int, List<int>>( 1024 );
		_cols = Math.Max( 1, (int) ( GameConfig.ArenaHalfSize * 2f / BucketSize ) + 1 );

		for ( int i = 0; i < _foods.Length; i++ )
		{
			_foods[i] = MakeFood();
			BucketAdd( i );
		}
		AliveCount = _foods.Length;
	}

	/// <summary> 每帧:把到期的重生位重新投食并广播(仅权威端) </summary>
	public void Tick()
	{
		for ( int i = _pendingRespawn.Count - 1; i >= 0; i-- )
		{
			int idx = _pendingRespawn[i];
			if ( _eatenAt[idx] < GameConfig.FoodRespawnSeconds ) continue;

			var oldPos = _foods[idx].Pos;
			_foods[idx] = MakeFood();
			_pendingRespawn.RemoveAt( i );
			AliveCount++;
			BucketMove( idx, oldPos );
			if ( Networking.IsActive ) NetworkManager.FoodRespawned( idx, _foods[idx] );   // 无会话(菜单演示赛)不广播
		}
	}

	/// <summary> 尝试吃掉指定食物;成功返回 true(调用方据此加质量)。仅权威端,内部广播。
	/// eaterSteamId 随广播下发,客户端据此判断是不是自己的嘴(播吃音) </summary>
	public bool TryEat( int index, long eaterSteamId = 0 )
	{
		if ( (uint)index >= (uint)_foods.Length || !_foods[index].Alive ) return false;

		_foods[index].Alive = false;
		_eatenAt[index] = 0;
		_pendingRespawn.Add( index );
		AliveCount--;
		if ( Networking.IsActive ) NetworkManager.FoodEaten( index, eaterSteamId );   // 无会话不广播
		return true;
	}

	FoodData MakeFood()
	{
		var half = GameConfig.ArenaHalfSize - GameConfig.FoodMargin;
		return new FoodData
		{
			Pos = new Vector2( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ) ),
			ColorIndex = (byte)Game.Random.Float( 0f, GameConfig.Palette.Length - 0.001f ),
			Alive = true,
			Type = 0,
		};
	}

	// ---- 客户端应用(远端事件 → 本地列表)----

	public void ApplyFull( FoodData[] foods )
	{
		_foods = foods;
		AliveCount = 0;
		foreach ( var f in _foods )
		{
			if ( f.Alive ) AliveCount++;
		}
	}

	public void ApplyEaten( int index )
	{
		if ( _foods is null ) return;   // 热重载后新实例未同步全量前,忽略增量
		if ( (uint)index >= (uint)_foods.Length || !_foods[index].Alive ) return;

		var f = _foods[index];
		f.Alive = false;
		_foods[index] = f;
		AliveCount--;
	}

	public void ApplyRespawned( int index, FoodData food )
	{
		if ( _foods is null ) return;
		if ( (uint)index >= (uint)_foods.Length ) return;

		_foods[index] = food;
		AliveCount++;
	}
}