Rendering/NeonCamera.cs
using System;

/// <summary>
/// 俯视跟随相机:正交投影、指数平滑跟随本地球;视口高度随质量增大(镜头拉远)。
/// 相机朝向固定垂直向下,鼠标→世界方向的映射见 BallInput。
/// </summary>
public sealed class NeonCamera : Component
{
	const float FollowSpeed = 6f;
	const float ZoomSpeed = 2.5f;

	/// <summary> 由 CircleroyaleGame 注入(避免每帧 GetComponent) </summary>
	public CameraComponent Cam { get; set; }

	Ball _target;
	Ball _spectate;      // 死亡观战目标(本地球活着时恒为 null)
	Vector3 _focus;
	float _shakeAmp;     // 屏幕震动幅度(击杀/放技能触发,指数衰减,v0.7.7.0)
	Bloom _bloom;        // 泛光组件(cr_bloom convar 即时开关,v0.7.8.15)

	/// <summary> 触发屏幕轻微震动(击杀连播/放技能的高光反馈;幅度按事件定,自动指数衰减) </summary>
	public void Shake( float amplitude ) => _shakeAmp = MathF.Max( _shakeAmp, amplitude );

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

		// 立即摆好默认俯视姿态(焦点=场地中心)。没有本机球之前 OnUpdate 会因
		// !_target.IsValid() 直接 return——相机保持引擎默认姿态(原点水平看),
		// 整个场地平面侧视塌成一条线:主菜单背景实测就是这样(客户端竞态期同样中招)
		ApplyPose();
		if ( Cam.IsValid() )
		{
			Cam.OrthographicHeight = GameConfig.CameraBaseView;
			_bloom = Cam.GetComponent<Bloom>();
		}
	}

	public void SetTarget( Ball ball )
	{
		if ( _target == ball ) return;   // 同一颗球:不重复 SnapTo(扫描会反复断言)
		_target = ball;
		SnapTo();
	}

	public void SnapTo()
	{
		if ( !_target.IsValid() ) return;
		_focus = _target.WorldPosition;
		_focus.z = 0f;
		ApplyPose();
		if ( Cam.IsValid() ) Cam.OrthographicHeight = ViewForMass( _target.Mass );
	}

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

		// 泛光开关(v0.7.8.15):控制台 cr_bloom 0/1 即时生效(幂等赋值,无抖动);
		// 热重载换程序集后缓存失效 → 惰性重取
		if ( Cam.IsValid() )
		{
			if ( !_bloom.IsValid() ) _bloom = Cam.GetComponent<Bloom>();
			if ( _bloom.IsValid() ) _bloom.Enabled = GameConfig.ConvarBloom;
		}

		// 自愈:热重载会把场景组件换成新程序集对象,缓存的 _target 可能变成失效引用——
		// OnUpdate 首行早退会让相机永久冻结(实测:客户端复活着、分裂着,镜头僵死不跟)。
		// 从游戏系统重新认领本机球;连本机球都没有也保持俯视姿态,绝不僵死
		if ( !_target.IsValid() )
		{
			var local = CircleroyaleGame.Current?.LocalBall;
			if ( local.IsValid() )
			{
				SetTarget( local );
			}
			else
			{
				// 菜单背景演示赛(v0.6.3.0):没有本机球时观战全场最大身体的 bot。
				// 粘滞同死亡观战:当前对象还活着且挑战者没领先 30% → 不换镜头
				var g = CircleroyaleGame.Current;
				var best = g?.TopMassBall();

				if ( _spectate.IsValid() && _spectate.Alive
					&& best.IsValid() && best != _spectate && g is not null )
				{
					if ( g.TotalMassFor( best ) <= g.TotalMassFor( _spectate ) * GameConfig.SpectateOvertakeRatio )
						best = _spectate;
				}

				if ( best.IsValid() )
				{
					_spectate = best;
					var tp = 1f - MathF.Exp( -FollowSpeed * Time.Delta );
					var fp = g.FocusPositionOf( best, out var fm );
					_focus = Vector3.Lerp( _focus, fp, tp );
					_focus.z = 0f;
					if ( Cam.IsValid() )
					{
						Cam.OrthographicHeight = MathX.Lerp( Cam.OrthographicHeight, ViewForMass( fm ),
							1f - MathF.Exp( -ZoomSpeed * Time.Delta ) );
					}
					ApplyPose();
					UpdateTagPositions( g );   // 姿态与变焦都写完,本帧投影一次即可
				}
				else
				{
					ApplyPose();
				}
				return;
			}
		}

		// 死亡观战(M4):本地球阵亡后镜头跟住当前榜首,重生瞬间弹回自己
		var cur = SpectateTarget();

		// 视角跟随最大的分身(v0.6.2.0 用户定稿):焦点与变焦基准都取
		// "主球 + 自己分身"中质量最大的身体
		var game = CircleroyaleGame.Current;
		Vector3 focusPos = cur.WorldPosition;
		float followMass = cur.Mass;
		if ( game is not null )
			focusPos = game.FocusPositionOf( cur, out followMass );

		var t = 1f - MathF.Exp( -FollowSpeed * Time.Delta );
		_focus = Vector3.Lerp( _focus, focusPos, t );
		_focus.z = 0f;

		// 变焦先写(投影用的 OrthoHeight 必须是本帧最终值),再摆位、再投影——每帧一次,
		// 不重复调(ApplyPose 每多调一次 shake 衰减就快一倍,名牌重复投影也是白做)
		if ( Cam.IsValid() )
		{
			var tz = 1f - MathF.Exp( -ZoomSpeed * Time.Delta );
			Cam.OrthographicHeight = MathX.Lerp( Cam.OrthographicHeight, ViewWithCells( cur, followMass ), tz );
		}

		ApplyPose();

		if ( Cam.IsValid() )
			UpdateTagPositions( game );
	}

	/// <summary>
	/// 死亡观战目标:团队赛优先跟**最大的存活队友**(v0.7.8.28 用户定稿——"我死了队伍还在打",
	/// 跟着大哥看战局;队友全灭才回落全场榜首)。带粘滞(v0.6.3.1)——几个目标交替领先时
	/// 不再每次反超都切镜头,挑战者须领先 SpectateOvertakeRatio 才接管。
	/// </summary>
	Ball SpectateTarget()
	{
		if ( _target.Alive )
		{
			if ( _spectate.IsValid() )
			{
				_spectate = null;
				SpectateName = null;
				SnapTo();   // 重生:镜头瞬移回自己(不从榜首位置长距离滑过来)
			}
			return _target;
		}

		var game = CircleroyaleGame.Current;
		Ball best = null;

		// 团队赛队友优先(最大存活队友);没有才看全场榜首
		if ( MatchState.IsTeam && _target.TeamIndex >= 0 && game is not null )
		{
			float bestMass = -1f;
			foreach ( var b in game.Balls )
			{
				if ( !b.IsValid() || !b.Alive || b == _target ) continue;
				if ( b.TeamIndex != _target.TeamIndex ) continue;
				var m = game.TotalMassFor( b );
				if ( m > bestMass ) { bestMass = m; best = b; }
			}
		}
		if ( !best.IsValid() )
			best = game?.TopMassBall();

		// 粘滞:当前观战对象还活着,且挑战者没领先到阈值 → 不换镜头
		if ( _spectate.IsValid() && _spectate.Alive
			&& best.IsValid() && best != _spectate && game is not null )
		{
			if ( game.TotalMassFor( best ) <= game.TotalMassFor( _spectate ) * GameConfig.SpectateOvertakeRatio )
				return _spectate;
		}

		_spectate = best.IsValid() ? best : _target;
		SpectateName = _spectate.IsValid() && _spectate != _target ? _spectate.PlayerName : null;
		return _spectate;
	}

	/// <summary> 当前死亡观战目标名(本机活着=null),HUD 死亡面板提示用 </summary>
	public string SpectateName { get; private set; }

	/// <summary>
	/// 视口高度:按"最大身体"质量取基准;有自家分身时拉远到能盖住最远的那个
	/// (分裂后细胞冲出屏幕外就看不见了,取景要把自家分身包进来)。
	/// 客户端匹配用 OwnerSteamId:分身与主球带同一个同步值,双开合成 ID 也能对上。
	/// </summary>
	float ViewWithCells( Ball cur, float baseMass )
	{
		var view = ViewForMass( baseMass );

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

		var sid = cur.OwnerSteamId;
		float maxDist = 0f;
		foreach ( var c in game.Cells )
		{
			if ( c.PieceKind != CellPiece.Kind.SplitPiece || c.OwnerSteamId != sid ) continue;
			maxDist = MathF.Max( maxDist, cur.WorldPosition.Distance( c.DrawPos ) );
		}

		if ( maxDist > 0f )
			view = Math.Clamp( ( cur.Radius + maxDist ) * 2.6f, view, GameConfig.CameraViewMax );

		return view;
	}

	void ApplyPose()
	{
		// 屏幕震动(v0.7.7.0):每次摆位叠加一个随机方向偏移,幅度指数衰减到零——
		// 摆位本身逐帧重写(不含残量),震完自动归位;名牌投影用同一相机位置,抖动同步一致
		var shake = Vector3.Zero;
		if ( _shakeAmp > 0.1f )
		{
			var a = Game.Random.Float( 0f, MathF.PI * 2f );
			shake = new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f )
				* _shakeAmp * Game.Random.Float( 0.4f, 1f );
			_shakeAmp *= MathF.Exp( -6f * Time.Delta );
		}

		GameObject.WorldPosition = _focus + Vector3.Up * GameConfig.CameraHeight + shake;
		GameObject.WorldRotation = Rotation.LookAt( Vector3.Down, Vector3.Forward );
	}

	/// <summary> 相机姿态刚写完,立刻让 HUD 用同一份相机数据重投名牌(零帧间差) </summary>
	void UpdateTagPositions( CircleroyaleGame game )
	{
		if ( game is null ) return;
		var ortho = Cam.IsValid() ? Cam.OrthographicHeight : 0f;
		game.Hud?.UpdateTagPositions( game, GameObject.WorldPosition, ortho );
	}

	static float ViewForMass( float mass )
	{
		return Math.Clamp( GameConfig.CameraBaseView * MathF.Sqrt( mass / GameConfig.StartMass ),
			GameConfig.CameraViewMin, GameConfig.CameraViewMax );
	}
}