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;
protected override void OnStart()
{
base.OnStart();
// 立即摆好默认俯视姿态(焦点=场地中心)。没有本机球之前 OnUpdate 会因
// !_target.IsValid() 直接 return——相机保持引擎默认姿态(原点水平看),
// 整个场地平面侧视塌成一条线:主菜单背景实测就是这样(客户端竞态期同样中招)
ApplyPose();
if ( Cam.IsValid() )
Cam.OrthographicHeight = GameConfig.CameraBaseView;
}
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();
// 自愈:热重载会把场景组件换成新程序集对象,缓存的 _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;
ApplyPose();
UpdateTagPositions( g );
if ( Cam.IsValid() )
{
Cam.OrthographicHeight = MathX.Lerp( Cam.OrthographicHeight, ViewForMass( fm ),
1f - MathF.Exp( -ZoomSpeed * Time.Delta ) );
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;
ApplyPose();
UpdateTagPositions( game );
if ( !Cam.IsValid() ) return;
var tz = 1f - MathF.Exp( -ZoomSpeed * Time.Delta );
Cam.OrthographicHeight = MathX.Lerp( Cam.OrthographicHeight, ViewWithCells( cur, followMass ), tz );
UpdateTagPositions( game ); // 变焦也改了相机参数,重投一次保证本帧一致
}
/// <summary>
/// 死亡观战目标:跟全场总质量榜首。带粘滞(v0.6.3.1)——几个 bot 交替领先时
/// 不再每次反超都切镜头,挑战者须领先 SpectateOvertakeRatio 才接管。
/// </summary>
Ball SpectateTarget()
{
if ( _target.Alive )
{
if ( _spectate.IsValid() )
{
_spectate = null;
SnapTo(); // 重生:镜头瞬移回自己(不从榜首位置长距离滑过来)
}
return _target;
}
var game = CircleroyaleGame.Current;
var 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;
return _spectate;
}
/// <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()
{
GameObject.WorldPosition = _focus + Vector3.Up * GameConfig.CameraHeight;
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 );
}
}