Player/Ball.cs
using System;
/// <summary>
/// 球(真人/bot 共用):**owner 本地模拟移动**(真人=键盘输入,bot=host 的 BotBrain),
/// 权威数值(Mass/IsBot/ColorIndex/PlayerName/OwnerSteamId)由 host 经 [Sync(FromHost)] 下发,
/// 位置随网络 transform 自动同步(owner → 其他端)。
/// 视觉:球体/表情/头像全由 NeonRenderer 批量绘制(表情按 Mood 选 face_N 批次;
/// 真人 Steam 头像各端按 OwnerSteamId 自取后经 NeonRenderer.SetAvatar 登记)。
/// </summary>
public sealed class Ball : Component
{
[Sync( SyncFlags.FromHost )] public float Mass { get; set; } = GameConfig.StartMass;
[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; }
[Sync( SyncFlags.FromHost )] public int ColorIndex { get; set; }
[Sync( SyncFlags.FromHost )] public string PlayerName { get; set; } = "Player";
/// <summary> 拥有者 SteamId(bot 为 0);host 写入,各端据此加载对应头像 </summary>
[Sync( SyncFlags.FromHost )] public long OwnerSteamId { get; set; }
/// <summary> 队伍号(团队赛 0..N-1,普通赛 -1);host 开局轮转分配,重生不变 </summary>
[Sync( SyncFlags.FromHost )] public int TeamIndex { get; set; } = -1;
/// <summary> 蛰伏备用球(v0.7.8.25):房间预留/本局多余的 bot。Alive=false 与"死亡"无法区分,
/// TickBots 曾把 16 颗蛰伏球当尸体原地复活成无队 FFA bot 灌进 T1——必须有独立标记 </summary>
[Sync( SyncFlags.FromHost )] public bool Dormant { get; set; }
/// <summary> 主人账号等级(v0.7.8.19):各端算好写进**自己的球**(普通 [Sync]=owner 可写),
/// 全场名牌可见;bot 恒 0 不显示 </summary>
[Sync] public int AccountLevel { get; set; }
// ---- 道具 buff(v0.7.3.0):host 权威写入,各端展示/效果共用。
// Kind 残留旧值没关系,HasBuff 只看 _sinceBuff < BuffDuration;
// serial 自增保证"同类 buff 再捡一次"也触发 [Change](客户端重置本地计时) ----
[Sync( SyncFlags.FromHost )] public byte BuffKind { get; set; } = 255;
[Sync( SyncFlags.FromHost )] public float BuffDuration { get; set; }
[Sync( SyncFlags.FromHost ), Change( nameof( OnBuffSynced ) )] public byte BuffSerial { get; set; }
/// <summary> bot 表情档(v0.7.8.36 像素风):0=开心 1=兴奋(追猎) 2=惊恐(逃跑) 3=眩晕 4=瞌睡 5=得意(王牌追猎)。
/// host 的 BotBrain 写入(FromHost),NeonRenderer 每帧按 Mood 现画表情脸;真人球不参与 </summary>
[Sync( SyncFlags.FromHost )] public byte Mood { get; set; }
TimeSince _sinceBuff = 999f;
// ---- 道具背包(v0.7.5.0 主动使用):拾取存入 Q/E 两格,按键释放效果。
// 255 = 空槽;死亡/重生清空(机会不跨命携带) ----
[Sync( SyncFlags.FromHost )] public byte PowerA { get; set; } = 255;
[Sync( SyncFlags.FromHost )] public byte PowerB { get; set; } = 255;
/// <summary> 各端收到 buff 变化(含 host 本地写入)即重置本地计时——客户端据此倒算剩余时间 </summary>
void OnBuffSynced() => _sinceBuff = 0;
/// <summary> host:给这颗球上 buff(质量罐等即时效果不走这里) </summary>
public void ApplyBuff( byte kind, float duration )
{
BuffKind = kind;
BuffDuration = duration;
BuffSerial++;
_sinceBuff = 0;
}
/// <summary> buff 是否生效中(host 权威判定与客户端展示同一公式,客户端自然过期无需通知) </summary>
public bool HasBuff( PowerUpManager.Kind kind ) =>
BuffKind == (byte)kind && _sinceBuff < BuffDuration;
/// <summary> 当前 buff 剩余秒数(HUD 倒计时用;无 buff 为 0) </summary>
public float BuffLeft => BuffKind == 255 ? 0f : MathF.Max( 0f, BuffDuration - _sinceBuff );
/// <summary> 背包第 slot 格(0=Q 槽 1=E 槽)的道具种类;255=空 </summary>
public byte PowerSlot( int slot ) => slot == 0 ? PowerA : PowerB;
/// <summary> 拾取入包(v0.7.8.30 起**只进 E 槽**——Q 改为随时间充能的技能位,不再接收拾取);
/// E 满返回 -1(道具留在场上) </summary>
public int StorePower( byte kind )
{
if ( PowerB == 255 ) { PowerB = kind; return 1; }
return -1;
}
/// <summary> host:使用背包道具——清槽并触发效果(PowerUpManager.ApplyEffect) </summary>
public void UsePower( int slot )
{
var kind = PowerSlot( slot );
if ( kind == 255 ) return;
if ( slot == 0 ) PowerA = 255;
else PowerB = 255;
PowerUpManager.ApplyEffect( this, kind );
}
/// <summary> 按键释放背包槽(pressed 为 true 时处理):本地先播激活横幅+音效+轻震,
/// 再走权威/RPC 路径 </summary>
void UsePowerSlot( int slot, bool pressed )
{
if ( !pressed ) return;
var kind = PowerSlot( slot );
if ( kind == 255 ) return; // 空槽:按键无效(不播横幅不发请求)
CircleroyaleGame.Current?.Hud?.AddBanner( GameHud.ActivationText( kind ), GameHud.KindColor( kind ) );
GameSfx.Pickup();
CircleroyaleGame.Current?.Camera?.Shake( 5f ); // 放技能轻震(击杀震更猛,随 combo 加码)
GameAchievements.PowerUsed( kind ); // 成就:首次用道具 / 首次放尖刺(v0.7.8.14)
if ( NetworkManager.IsAuthority )
UsePower( slot );
else
RequestUsePower( slot );
}
/// <summary>
/// 是否存活。**球对象永不销毁**:死亡 = Alive=false(同步字段,渲染/AI/判定全部跳过),
/// 重生 = host 置回 true。绕开"进行中 NetworkSpawn 不复制到已连接客户端"的引擎行为(实测大坑)。
/// </summary>
[Sync( SyncFlags.FromHost )]
public bool Alive { get; set; } = true;
public float Radius => GameConfig.StartRadius * MathF.Sqrt( Mass / GameConfig.StartMass );
/// <summary> 当前速度(v0.7.8.32:bot 拦截预判按它打提前量;只读,BallInput 内部维护) </summary>
public Vector3 Velocity => _velocity;
/// <summary> 本条命已存活秒数(成就"存活 5 分钟"用;重生时 _sinceSpawn 归零) </summary>
public float LifeSeconds => _sinceSpawn;
/// <summary> 本球的霓虹色(由 ColorIndex 从调色板取) </summary>
public Color NeonColor => GameConfig.Palette[Math.Clamp( ColorIndex, 0, GameConfig.Palette.Length - 1 )];
/// <summary> 移速:质量曲线 × 加速 buff(⚡ 时 ×1.4;Speed 属性各端同算,owner 模拟与 host 校验一致) </summary>
public float Speed
{
get
{
var s = MathF.Max( GameConfig.MinSpeed,
GameConfig.BaseSpeed * MathF.Pow( GameConfig.StartMass / Mass, GameConfig.SpeedCurve ) );
if ( HasBuff( PowerUpManager.Kind.Speed ) ) s *= GameConfig.PowerUpSpeedBoost;
// 敌营大本营减速(M7.2 安全屋);InHostileHq 对无队/非领土恒 false
if ( TerritoryManager.InHostileHq( TeamIndex, WorldPosition ) ) s *= GameConfig.TerritoryHqSlowFactor;
return s;
}
}
/// <summary> 是否出生保护中(虚环护盾表现,双向免战) </summary>
public bool IsProtected => _sinceSpawn < GameConfig.SpawnProtectSeconds;
/// <summary> 领土职业(M7.4,host 随机分配随复活重掷;255=无。0 指挥官 1 工兵 2 护卫 3 坦克) </summary>
[Sync( SyncFlags.FromHost )]
public byte TerritoryClass { get; set; } = 255;
TimeSince _sinceClassCast = 999f; // 本地冷却估算(host 权威冷却在 ClassSkillManager,未好时静默拒绝)
/// <summary> 本地估算的职业冷却剩余秒(HUD Q 钮倒 sweep 用) </summary>
public float ClassCooldownRemaining =>
TerritoryClass > 3 ? 0f : MathF.Max( 0f, ClassSkillManager.CooldownOf( TerritoryClass ) - _sinceClassCast );
/// <summary> Q 施放职业技能(M7.4 领土):本地即时反馈+冷却估算起表;host 直调 / 客户端发 RPC </summary>
void TryCastClassSkill()
{
if ( !Alive || TerritoryClass > 3 ) return;
if ( ClassCooldownRemaining > 0f ) return;
CircleroyaleGame.Current?.Hud?.AddBanner( ClassSkillManager.NameOf( TerritoryClass ) + "!", new Color( 0.98f, 0.83f, 0.35f ) );
GameSfx.Pickup();
CircleroyaleGame.Current?.Camera?.Shake( 5f );
_sinceClassCast = 0; // 本地估算起表
if ( NetworkManager.IsAuthority )
ClassSkillManager.Cast( this );
else
NetworkManager.RequestClassSkill();
}
/// <summary> 球体背景填充色:真人=头像主色取样,bot=霓虹配色 </summary>
public Color BackgroundColor => _avatarColor ?? NeonColor;
Vector3 _velocity;
TimeSince _sinceSpawn;
TimeSince _sinceWallHit = 999f; // 撞墙脉冲节流(贴墙滑行不连爆)
Color? _avatarColor;
Vector2 _botDir;
Vector2 _lastAimDir = Vector2.Zero; // 光标瞄准方向(分裂/吐孢子用,v0.7.0.0)
/// <summary> 当前瞄准方向(本机球:渲染外缘箭头用;bot 恒 Zero 不画) </summary>
public Vector2 AimDir => _lastAimDir;
TimeSince _sinceEject; // 客户端按住连吐的节流(host 还有同款校验)
TimeSince _sinceAvatarRetry = 1f; // 首帧立即尝试
int _avatarRetryCount;
bool _avatarReady;
bool _wasAlive = true;
/// <summary> 死亡时刻(host 记录,用于 bot 原地复活的延时) </summary>
public TimeSince SinceDeath { get; private set; }
/// <summary>
/// host 侧标记:这颗球是为远程连接生成的(仅权威端有意义,不联网)。
/// ScanBalls/SetLocalBall 用它把远程球(含竞态下未网络化的幽灵球)排除出本机球候选,
/// 否则同账号双开时幽灵球会顶掉 host 自己的球、相机来回跳(实测)。
/// </summary>
public bool IsRemoteOwned { get; set; }
/// <summary> host 侧标记死亡(对象保留,原地复用) </summary>
public void MarkDead()
{
Alive = false;
SinceDeath = 0;
_velocity = Vector3.Zero;
}
/// <summary> 重置出生保护计时(host 在玩家真正入局时调用——pre-snapshot 球的
/// 保护期在握手期间就空跑完了,实测加入 1.6s 即被吃;IsProtected 按各端本地计时判定,
/// host 重置即恢复权威判定,客户端自己的计时随快照应用另算) </summary>
public void RefreshSpawnProtection() => _sinceSpawn = 0;
/// <summary> 瞬移(分身晋升主球时用):位置归 owner 模拟,owner 端必须自己搬过去;
/// 不触发保护期/重播音效——这是身份延续,不是重生 </summary>
public void TeleportTo( Vector3 pos )
{
WorldPosition = new Vector3( pos.x, pos.y, 0f );
_velocity = Vector3.Zero;
}
/// <summary> host 侧初始化(仅权威端调用;客户端字段全部走网络同步)。
/// teamIndex ≥ 0 时同时写入队伍号;团队赛颜色锁队伍色,普通赛随机霓虹色 </summary>
public void Init( bool isBot, int teamIndex = -1 )
{
IsBot = isBot;
if ( teamIndex >= 0 ) TeamIndex = teamIndex;
ColorIndex = MatchState.IsTeam
? ( ( TeamIndex % GameConfig.Palette.Length ) + GameConfig.Palette.Length ) % GameConfig.Palette.Length
: (int)Game.Random.Float( 0f, GameConfig.Palette.Length - 0.001f );
_sinceSpawn = 0;
_velocity = Vector3.Zero;
// 道具 buff 与背包随重生清掉(机会不跨命携带)
BuffKind = 255;
BuffDuration = 0f;
PowerA = 255;
PowerB = 255;
}
/// <summary> bot 由 BotBrain 每帧写入的移动方向(真人走 BallInput,不用这个) </summary>
public void SetBotDirection( Vector2 dir ) => _botDir = dir;
protected override void OnStart()
{
base.OnStart();
// 所有机器统一从网络对象的 OnStart 注册(host 生成 / 客户端随复制出现)
CircleroyaleGame.Current?.RegisterBall( this );
if ( !IsProxy && !IsBot )
CircleroyaleGame.Current?.SetLocalBall( this );
TryLoadAvatar();
}
/// <summary>
/// 加载头像(真人按 OwnerSteamId 拉 Steam 头像:下载有延迟,失败每秒重试,15 次后转占位图;
/// 拿到后经 NeonRenderer.SetAvatar 登记独立贴图批次)。bot 表情脸由 NeonRenderer 按 Mood
/// 从 face_0..5 现画,不在这里加载。
/// </summary>
void TryLoadAvatar()
{
if ( IsBot )
{
_avatarReady = true;
return;
}
if ( OwnerSteamId == 0 ) return; // 同步未到,等下一轮重试
var avatar = Texture.LoadAvatar( OwnerSteamId, 128 );
if ( avatar is null ) return;
SetAvatarTexture( avatar );
_avatarReady = true;
}
/// <summary> 头像贴图登记进 NeonRenderer(渲染器未就绪时由其静态表待命,下一帧生效) </summary>
void SetAvatarTexture( Texture tex )
{
if ( tex is null )
{
Log.Warning( $"avatar load failed: '{PlayerName}' steam={OwnerSteamId}" );
return;
}
NeonRenderer.SetAvatar( OwnerSteamId, tex );
_avatarColor = SampleAverageColor( tex );
}
/// <summary> 头像主色:全图不透明像素的均值(用作球底填充色) </summary>
static Color? SampleAverageColor( Texture tex )
{
try
{
var pixels = tex.GetPixels( 0 );
if ( pixels is null || pixels.Length == 0 ) return null;
float r = 0f, g = 0f, b = 0f;
int n = 0;
for ( int i = 0; i < pixels.Length; i++ )
{
var p = pixels[i];
if ( p.a < 32 ) continue;
r += p.r;
g += p.g;
b += p.b;
n++;
}
if ( n == 0 ) return null;
return new Color( r / n / 255f, g / n / 255f, b / n / 255f );
}
catch
{
return null;
}
}
protected override void OnUpdate()
{
base.OnUpdate();
// 头像重试:每秒一次,15 次未拿到转占位图(Steam 头像下载慢是常态)
if ( !_avatarReady && _sinceAvatarRetry > 1f )
{
_sinceAvatarRetry = 0;
if ( _avatarRetryCount++ >= 15 )
{
SetAvatarTexture( Texture.Load( GameConfig.AvatarPlaceholderPath, false ) );
_avatarReady = true;
}
else
{
TryLoadAvatar();
}
}
// ---- 分裂/吐孢子输入(M4.11:每帧路径,仅本机真人、活球)----
// 权威端直接执行;客户端发实例 RPC([Rpc.Host],host 侧校验后执行,分身是 host 权威数据)。
// 音效在按键瞬间即播(本地即时反馈;host 校验不过顶多空响一次)
if ( IsProxy || IsRemoteOwned || IsBot || !Alive ) return;
if ( MatchState.MatchOver ) return; // 比赛结束:全场冻结,输入不响应
// 停在等待页(P 退局/掉线恢复)且不在对局时停止操控:球留在原地,防隔屏盲开。
// ⚠️ 必须带 !IsMatchStarted:只要还在对局(_gameStarted)就绝不禁输入——
// _lobbyWaiting 若因异常残留为 true,会把对局中的移动/分裂/吐孢子/瞄准全锁死
// (实测:不能分身/吐丝、瞄准箭头冻在旧方向)
{
var g = CircleroyaleGame.Current;
if ( g is not null && g.IsClientWaiting && !g.IsMatchStarted )
{
return;
}
}
// 瞄准(v0.7.0.0 用户需求):_lastAimDir 供渲染瞄准线用;动作方向由 host 按身体逐个算
var aimDir = BallInput.GetAimDir( Scene.Camera, WorldPosition );
if ( aimDir.Length > 0.001f ) _lastAimDir = aimDir;
// 鼠标键并行(v0.7.4.1 用户需求):右键分裂、左键吐丝("Attack1"/"Attack2" 为引擎内建按钮,
// 官方项目同款用法);空格/R 保留,两套键位共存。
// v0.7.8.7 手感:传**光标世界坐标**而非方向——host 对每个身体单独算指向(分身也朝光标)
var aimPoint = BallInput.GetAimPoint( Scene.Camera );
if ( GameConfig.EnableSplit && ( Input.Pressed( "Jump" ) || Input.Pressed( "Attack2" ) ) )
{
if ( Mass >= GameConfig.SplitMinMass ) GameSfx.Split( WorldPosition );
if ( NetworkManager.IsAuthority )
CircleroyaleGame.Current?.DoSplit( this, aimPoint );
else
RequestSplit( aimPoint );
}
if ( GameConfig.EnableEject
&& ( Input.Down( "Reload" ) || Input.Down( "Attack1" ) )
&& _sinceEject > GameConfig.EjectCooldownSeconds )
{
_sinceEject = 0;
if ( Mass >= 2f ) GameSfx.Eject( WorldPosition ); // 至少能吐最小一颗(下限 1 + 消耗 1)
if ( NetworkManager.IsAuthority )
CircleroyaleGame.Current?.DoEject( this, aimPoint );
else
RequestEject( aimPoint );
}
// 道具主动使用(v0.7.5.0):Q/E 释放背包里的道具——权威端直接触发,客户端发 RPC。
// banner 按键即播(本地即时反馈,不等 host 往返);槽位空则什么都不发生
// Q 键(M7.4):领土模式=职业技能(冷却制,PowerA 时间充能 pass 在领土下冻结);
// 其他模式=充能道具槽(既有行为)。E 键道具照旧
// 文本输入(聊天框)持键盘焦点时一律不读按键:引擎把按键无差别丢进所有 InputContext,
// 不挡的话聊天里打 Q/E 会直接放职业技/道具
if ( !BallInput.Typing )
{
if ( MatchState.IsTerritory )
{
if ( Input.Keyboard.Pressed( "Q" ) ) TryCastClassSkill();
}
else
{
UsePowerSlot( 0, Input.Keyboard.Pressed( "Q" ) );
}
UsePowerSlot( 1, Input.Keyboard.Pressed( "E" ) );
}
}
protected override void OnFixedUpdate()
{
if ( IsProxy ) return; // 只有拥有者(真人)或 host(bot)模拟
if ( IsRemoteOwned ) return; // host 侧远程球由其主人模拟;未网络化的幽灵球绝不本地模拟
// 死亡状态:停住不动(对象保留);Alive 翻回 true 时本地重生(随机换位+保护期)
if ( !Alive )
{
_velocity = Vector3.Zero;
_wasAlive = false;
return;
}
if ( !_wasAlive )
{
_wasAlive = true;
_sinceSpawn = 0; // 复活保护期(本地表现;权威保护期在 host 的 Init 里)
_velocity = Vector3.Zero;
// owner 自己随机换位(位置归 owner 模拟);领土模式默认回大本营,
// 死亡面板点 FRONT(M7.2)则换到最近己方占领格——偏好随点击存本机,此处消费
WorldPosition = RandomSpawnPos( TeamIndex, CircleroyaleGame.Current?.ConsumeFrontRespawn() ?? false, WorldPosition );
if ( GameSfx.IsMine( OwnerSteamId ) ) // 只播本机球:开局 32 个 bot 同帧生成曾叠 33 层音(v0.7.8.10 修)
GameSfx.Respawn( WorldPosition );
GameSfx.ResetEatCount(); // 下一条命吃食物音从 0 重新计
GameSfx.ResetCombo(); // 连击清零(新的一条命重新起算)
GameLog.Info( $"[ball] local respawn '{PlayerName}'" );
}
// 比赛结束:全场冻结(结算面板接管,谁也不许再动)
if ( MatchState.MatchOver )
{
_velocity = Vector3.Zero;
return;
}
// 真人 = 本地输入(owner 模拟);bot = BotBrain 写入的方向。
// 分裂/吐孢子的输入判定已移到 OnUpdate(每帧路径)——Input 按键边沿按渲染帧刷新,
// 固定步进的 FixedUpdate 可能错过 Pressed 边沿(按了没反应的隐患)
var dir = IsBot ? _botDir : BallInput.GetMoveDir( Scene.Camera );
// 速度与转向都随质量衰减(批18①):体积越大极速越低、惯性越强转向越笨拙,
// 大球必须提前预判走位。响应时间常数 = BaseTurnSpeed × (StartMass/Mass)^TurnCurve
var turn = MathF.Max( GameConfig.MinTurnSpeed,
GameConfig.BaseTurnSpeed * MathF.Pow( GameConfig.StartMass / Mass, GameConfig.TurnCurve ) );
var t = 1f - MathF.Exp( -turn * Time.Delta );
_velocity = Vector3.Lerp( _velocity, dir * Speed, t );
var pos = WorldPosition + _velocity * Time.Delta;
// 场地边界钳制(半径异常涨破场地时 half 兜底为 0,绝不让 Clamp 的 min>max 抛异常)
var half = MathF.Max( 0f, GameConfig.ArenaHalfSize - Radius );
var preX = pos.x;
var preY = pos.y;
pos.x = Math.Clamp( pos.x, -half, half );
pos.y = Math.Clamp( pos.y, -half, half );
pos.z = 0f;
WorldPosition = pos;
// 撞墙脉冲(v0.7.8.78):钳制真把位置按住 + 撞击速度够 + 节流,接触点来一圈扩散环。
// 各端本地触发(真人 owner 机 / bot host 机),特效不做网络广播
if ( _sinceWallHit > GameConfig.WallHitFxCooldown )
{
Vector3 contact;
var hit = false;
if ( preX != pos.x && MathF.Abs( _velocity.x ) > GameConfig.WallHitFxMinSpeed )
{
hit = true;
contact = new Vector3( MathF.Sign( pos.x ) * ( GameConfig.ArenaHalfSize - 2f ), pos.y, 0f );
}
else if ( preY != pos.y && MathF.Abs( _velocity.y ) > GameConfig.WallHitFxMinSpeed )
{
hit = true;
contact = new Vector3( pos.x, MathF.Sign( pos.y ) * ( GameConfig.ArenaHalfSize - 2f ), 0f );
}
else
{
contact = Vector3.Zero;
}
if ( hit )
{
_sinceWallHit = 0;
NeonRenderer.WallPulse( contact, NeonColor );
}
}
// 球体推挤(v0.7.8.24 球球大作战手感):吃不动对方=实心——推出与敌方的重叠
ApplyPush( half );
}
/// <summary>
/// 球体推挤(v0.7.8.24 球球大作战手感):**各自把自己推出重叠**——对方 bot/host 球走同代码路径、
/// 真人球由其主人推,双方按质量权重让位,一帧内收敛到分离,不需要位置权威仲裁。
/// 队友与自己的身体不推(BoB 惯例可重叠);能吃/被吃时不推(吃归 host 权威,吃了就不存在重叠);
/// 尖刺分身是武器不推(碰到走武器判定);孢子不推。位置是 owner 权威,推完自动同步
/// </summary>
void ApplyPush( float half )
{
var game = CircleroyaleGame.Current;
if ( game is null ) return;
foreach ( var b in game.Balls )
{
if ( b is null || !b.IsValid() || !b.Alive || b == this ) continue;
if ( b.OwnerSteamId == OwnerSteamId ) continue;
if ( game.SameTeamSteam( OwnerSteamId, b.OwnerSteamId ) ) continue;
var hi = MathF.Max( Mass, b.Mass );
var lo = MathF.Min( Mass, b.Mass );
if ( hi >= lo * GameConfig.EatRatio ) continue; // 能吃就吃(host 权威),不推
var dx = WorldPosition.x - b.WorldPosition.x;
var dy = WorldPosition.y - b.WorldPosition.y;
var rr = Radius + b.Radius;
var d2 = dx * dx + dy * dy;
if ( d2 >= rr * rr ) continue;
var d = MathF.Sqrt( MathF.Max( d2, 0.0001f ) );
var nx = d > 0.001f ? dx / d : 1f;
var ny = d > 0.001f ? dy / d : 0f;
// 完全重合兜底:推满 rr 的一半;正常情况推"重叠 × 对方质量占比"(越重让得越少)
var push = d > 0.001f ? MathF.Min( rr - d, GameConfig.PushMaxStep ) * ( b.Mass / ( Mass + b.Mass ) ) : rr * 0.5f;
WorldPosition = new Vector3(
Math.Clamp( WorldPosition.x + nx * push, -half, half ),
Math.Clamp( WorldPosition.y + ny * push, -half, half ), 0f );
}
// 敌方分身=实心障碍(自己不动,我让路全额推;能吃时不推——host 会吃掉它)
foreach ( var c in game.Cells )
{
if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;
if ( c.OwnerSteamId == OwnerSteamId ) continue;
if ( game.SameTeamSteam( OwnerSteamId, c.OwnerSteamId ) ) continue;
if ( Mass >= c.Mass * GameConfig.EatRatio ) continue;
var dx = WorldPosition.x - c.Pos.x;
var dy = WorldPosition.y - c.Pos.y;
var rr = Radius + c.Radius;
var d2 = dx * dx + dy * dy;
if ( d2 >= rr * rr ) continue;
var d = MathF.Sqrt( MathF.Max( d2, 0.0001f ) );
var nx = d > 0.001f ? dx / d : 1f;
var ny = d > 0.001f ? dy / d : 0f;
var push = d > 0.001f ? MathF.Min( rr - d, GameConfig.PushMaxStep ) : rr * 0.5f;
WorldPosition = new Vector3(
Math.Clamp( WorldPosition.x + nx * push, -half, half ),
Math.Clamp( WorldPosition.y + ny * push, -half, half ), 0f );
}
}
/// <summary> 场地内出生点(owner 复活时本地选位):走游戏系统的安全采样,无系统时纯随机兜底。
/// 领土模式(M7):有队号就回己方大本营采样;frontLine=true(M7.2 二选一)换最近己方占领格 </summary>
static Vector3 RandomSpawnPos( int teamIndex = -1, bool frontLine = false, Vector3 nearPos = default )
{
var game = CircleroyaleGame.Current;
if ( game is not null ) return game.RandomSpawnPos( teamIndex, frontLine, nearPos );
if ( MatchState.IsTerritory && teamIndex >= 0 )
{
if ( frontLine && TerritoryManager.TryFrontSpawn( teamIndex, nearPos, out var front ) )
return front;
TerritoryManager.HqBounds( teamIndex, out var minX, out var maxX, out var minY, out var maxY );
return new Vector3( Game.Random.Float( minX, maxX ), Game.Random.Float( minY, maxY ), 0f );
}
var half = GameConfig.ArenaHalfSize - 128f;
return new Vector3( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ), 0f );
}
// ---- 分裂/吐孢子 RPC(M3):客户端 → host,host 校验归属后执行。
// v0.7.8.7:参数语义改为**光标世界坐标**(aimPoint),host 逐身体算指向 ----
[Rpc.Host]
public void RequestSplit( Vector2 aimPoint )
{
var caller = Rpc.Caller;
if ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return; // 只能操作自己的球
CircleroyaleGame.Current?.DoSplit( this, aimPoint );
}
[Rpc.Host]
public void RequestEject( Vector2 aimPoint )
{
var caller = Rpc.Caller;
if ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return;
CircleroyaleGame.Current?.DoEject( this, aimPoint );
}
[Rpc.Host]
public void RequestUsePower( int slot )
{
var caller = Rpc.Caller;
if ( caller is not null && caller.SteamId.Value != OwnerSteamId ) return; // 只能操作自己的球
UsePower( slot );
}
protected override void OnDestroy()
{
base.OnDestroy();
CircleroyaleGame.Current?.RemoveBall( this );
}
}