Bot/BotBrain.cs
using System;
using System.Collections.Generic;
using System.Text;
/// <summary>
/// bot AI(host 侧模拟,代理端不跑):有限状态机
/// 逃跑(范围内能吃我的威胁)> 追猎(能吃的近距离小球/分身)> 靠近喂养目标 > 觅食 > 游荡。
/// 每 BotDecisionInterval 秒决策一次,方向写入 Ball.SetBotDirection。
/// 进阶能力(v0.6.6.0 起,v0.7.8.32 本轮加强):
/// - **感知敌方分身**(v0.7.8.32):分身能吞主球也能被吞——威胁/猎物/分裂风险评估全覆盖敌方分身(此前是纯盲区);
/// - **分裂捕猎**(v0.6.6.0):贴身且分出去的一半仍够吃时朝猎物分裂(agar 秒杀手法);
/// v0.7.8.32:猎物太小不浪费分裂冷却;减半后附近有能吃我的敌人就放弃(送命分裂);
/// - **分裂逃命**(v0.7.8.7):威胁到嘴边时朝逃跑方向分裂(冲量前窜+变轻提速),逃命优先于捕猎;
/// - **多威胁合成逃跑**(v0.7.8.32):被包夹时反向按威胁远近加权合成,不往第二个威胁怀里撞;
/// - **拦截预判**(v0.7.8.32):追猎按猎物当前速度打提前量(新手直线追被风筝,王牌几乎完美拦截);
/// - **觅食粘性**(v0.7.8.32):食物/孢子目标活着就追到底(距离折扣竞争),不在两个目标间来回锯齿;
/// - **躲刺爆弹幕**(v0.7.8.32):刺爆弹幕也是真伤害(-15% 质量),近距离侧向闪避;
/// - **吐孢子喂队友**(团队赛):向范围内更大的队友喂孢子(养大哥),喂到自己不再明显小于对方为止;
/// v0.7.8.94:靠近到"孢子够得着"即停(不贴脸)、连续喂养有时长预算(喂一阵休整去觅食)、
/// 超出孢子射程不空吐——修"两只 bot 缩在一起蹲几十秒";
/// - **顺路捡道具**(v0.7.8.7):附近场上有道具就冲过去(bot 拾取即用);
/// - **孢子觅食**(v0.7.8.7):散落孢子按性价比折算进觅食目标,不再无视;
/// - **团队集结**:团队赛游荡目标偏向远处队友(v0.7.8.94:已挨着队友就随机散开,不再原地 hover 抱团);
/// - **选旗摊开**(v0.7.8.94):同队 bot 已选中的旗格降权 + 赶旗进圈即停不挤旗心,防全队叠在同一格;
/// - **可追性检查**(v0.7.8.95):猎物比我快且在逃 → 追不上直接放弃,不再傻追十几秒;
/// - **TTC 预警**(v0.7.8.95):致命敌人高速逼近按"接触时间"提前避让(往当前方向掺分量,
/// 不动摇状态机);新兵视界 0=还是瞎子,王牌 1.1 秒提前量;
/// - **告急回防**(v0.7.8.95):自己旗格的驻军分比上次采样下降 = 正在被啃,回防价值大幅加成;
/// - **个性原型**(v0.7.8.95):农夫/抢旗手/保镖/猎杀者,出生随机,只调效用权重不换骨架;
/// 行为计数走 BotStats(MCP cr_botstats 看分布调参)。
/// - **战术手法层**(v0.7.8.96):①借刺反杀——被追时朝"追兵撞上必爆"的刺借道(队友尖刺
/// 分身免疫可贴身借);②临终托孤——威胁贴脸时掉头朝安全队友吐质量,死也不白送;
/// ③主动炸草(v0.7.8.97 放开原型门)——质量烧得起的 bot 喂爆敌方目标旁边的刺,用弹幕白削对面。
/// - 猎物/威胁判定跳过队友(队友免伤,追了白追);猎物按"质量/距离"评分选性价比最高的。
/// M2 起 bot 由 host 拥有,IsProxy 端不执行。
/// </summary>
public sealed class BotBrain : Component
{
/// <summary> 一次决策收集的态势(威胁/猎物可能是球也可能是敌方分身——分身时 Ball 引用为 null,
/// 位置/半径/质量存独立字段,方向决策与动作层都只用这些字段取数据) </summary>
sealed class BotContext
{
public Ball Threat; // 最近的威胁球(威胁是分身时为 null)
public Vector3 ThreatPos; // 最近威胁位置(球或敌方分身)
public float ThreatRadius;
public Ball Prey; // 价值最高的猎物球(猎物是分身时为 null)
public Vector3 PreyPos;
public float PreyRadius;
public float PreyMass; // >0 = 有猎物(球或分身)
public Vector3 PreyVel; // 猎物当前速度(拦截预判用)
public Ball FeedTarget; // 团队赛:值得喂的队友(范围内最大且明显比我大)
public readonly List<Ball> Mates = new(); // 存活队友(集结用)
public Vector3 FleeDir; // 全部威胁的反向加权和(多威胁合成逃跑)
public int ThreatCount;
public Vector2 EarlyThreatDir; // TTC 预警(v0.7.8.95):视界内高速逼近的敌人的反向和
public int EarlyThreatCount; // 预警计数(>0 时当前方向掺避让分量,不进逃跑状态机)
public bool DangerAfterSplit; // 分裂减半后附近仍有够吃"减半我"的敌人
}
Ball _self;
TimeSince _sinceDecision;
TimeSince _sinceSplit;
TimeSince _sinceFeed;
TimeSince _sinceFeedSession; // 连续喂养时长(v0.7.8.94:喂满预算强制休整,不许无限蹲大哥)
bool _feedBreak; // 喂养休整中(休整期无视 FeedTarget,正常觅食回血)
float _orbitSign = 1f; // 停驻盘旋方向(v0.7.8.103:±1 每只随机,站桩对峙的解药)
Vector3 _wanderTarget;
bool _hasWander;
Vector2 _stickyFood; // 觅食粘性目标(食物点,防相邻目标间来回锯齿)
TimeSince _sinceStickyFood;
int _stickyBlobId = -1; // 觅食粘性目标(散落孢子,按 CellPiece.Id 锁定)
TimeSince _sinceStickyBlob;
Vector3 _flagTarget; // 领土目标旗点(M7.5,_hasFlagTarget 时有效)
bool _hasFlagTarget;
TimeSince _sinceFlagPick = 99f; // 选旗节流(BotFlagPickInterval 重估一次)
TimeSince _sinceFlagFeed; // 喂旗节流(BotFeedFlagCooldown,套在 DoEject 内置节流之上)
Vector3 _bombSpike; // 主动炸草目标刺位(v0.7.8.96,_hasBombTarget 时有效)
bool _hasBombTarget;
TimeSince _sinceBombPick = 99f; // 炸草目标重估节流
TimeSince _sinceBombFeed; // 喂刺节流(BotSpikeBombCooldown)
TimeSince _sinceBombArrive; // 伏击耐心计时(v0.7.8.99:从选刺起算,超时放弃)
/// <summary> 选旗预约表(v0.7.8.94 摊开扎堆):同队别的 bot 最近选中的格降权,把队内火力
/// 摊到不同旗上(旧版两只 bot 常选中同一格,一起蹲在旗心吐球=又一处"缩在一起")。
/// key = team<<32 | 格索引;值 = (选它的球 Id, 选中时刻)。TimeSince 默认 0=很久以前,天然过期 </summary>
static readonly Dictionary<long, (Guid BallId, TimeSince Since)> _flagClaims = new();
/// <summary> 难度档(OnStart 随机定档;决策节奏/感知范围/准度都按档取值) </summary>
public BotDifficulty Difficulty { get; private set; } = BotDifficulty.Veteran;
/// <summary> 个性原型(v0.7.8.95,OnStart 随机定档):只调效用权重不换骨架——
/// 农夫偏吃、抢旗手直奔旗、保镖贴大哥、猎杀者只认猎物 </summary>
public BotArchetype Archetype { get; private set; } = BotArchetype.Farmer;
BotStats.Entry _stats; // 行为计数(cr_botstats 看分布调参用)
float[] _cellScoreSeen; // 上次选旗时各格驻军分快照(判"正在被啃"→告急回防)
protected override void OnStart()
{
base.OnStart();
_self = GetComponent<Ball>();
Difficulty = RollDifficulty();
Archetype = (BotArchetype)Game.Random.Int( 0, 3 );
_orbitSign = Game.Random.Float( 0f, 1f ) < 0.5f ? -1f : 1f;
_sinceDecision = DecisionInterval(); // 首帧立即决策
}
BotDifficulty RollDifficulty()
{
var r = Game.Random.Float( 0f, 1f );
if ( r < GameConfig.BotAceFraction ) return BotDifficulty.Ace;
if ( r < GameConfig.BotAceFraction + GameConfig.BotVeteranFraction ) return BotDifficulty.Veteran;
return BotDifficulty.Rookie;
}
float DecisionInterval() => Difficulty switch
{
BotDifficulty.Rookie => GameConfig.BotRookieDecisionInterval,
BotDifficulty.Ace => GameConfig.BotAceDecisionInterval,
_ => GameConfig.BotDecisionInterval,
};
float RangeScale() => Difficulty switch
{
BotDifficulty.Rookie => GameConfig.BotRookieRangeScale,
BotDifficulty.Ace => GameConfig.BotAceRangeScale,
_ => 1f,
};
float AimError() => Difficulty switch
{
BotDifficulty.Rookie => GameConfig.BotRookieAimError,
BotDifficulty.Ace => GameConfig.BotAceAimError,
_ => GameConfig.BotVeteranAimError,
};
/// <summary> 进阶动作的施展概率(新兵笨拙少用,王牌几乎必放) </summary>
float ActionChance() => Difficulty switch
{
BotDifficulty.Rookie => 0.18f,
BotDifficulty.Ace => 0.85f,
_ => 0.5f,
};
/// <summary> 拦截提前量系数(v0.7.8.32):新手直线追(被风筝),老手打半拍,王牌几乎完美拦截 </summary>
float LeadFactor() => Difficulty switch
{
BotDifficulty.Rookie => 0f,
BotDifficulty.Ace => 0.75f,
_ => 0.45f,
};
/// <summary> TTC 预警视界(v0.7.8.95,秒):够吃我的敌人按"接近速度"算接触时间,小于视界
/// 就往当前方向掺避让分量(不进逃跑状态机)。新兵 0=关闭(还是瞎子),王牌看得最远 </summary>
float ThreatHorizon() => Difficulty switch
{
BotDifficulty.Rookie => 0f,
BotDifficulty.Ace => GameConfig.BotThreatHorizonAce,
_ => GameConfig.BotThreatHorizonVeteran,
};
// ---- 个性原型参数表(v0.7.8.95):都是效用权重,不改变优先级骨架 ----
/// <summary> 追猎感知倍率:猎杀者看得远,农夫懒得看 </summary>
float ArchHuntScale() => Archetype switch
{
BotArchetype.Killer => 1.3f,
BotArchetype.Farmer => 0.6f,
BotArchetype.FlagRusher => 0.8f,
BotArchetype.Bodyguard => 0.7f,
_ => 1f,
};
/// <summary> 猎物评分加成:猎杀者眼里猎物更香 </summary>
float ArchPreyBonus() => Archetype == BotArchetype.Killer ? 1.4f : 1f;
/// <summary> 选旗价值倍率:抢旗手拉满,农夫基本不碰(除非告急回防) </summary>
float ArchFlagScale() => Archetype switch
{
BotArchetype.FlagRusher => 1.6f,
BotArchetype.Farmer => 0.5f,
BotArchetype.Killer => 0.8f,
_ => 1f,
};
/// <summary> 喂大哥的体型门槛:保镖 1.08 倍就开始喂(原值 1.25),更恋家 </summary>
float ArchFeedThreshold() => Archetype == BotArchetype.Bodyguard ? 1.08f : 1.25f;
/// <summary> 猎杀者不喂人(独狼) </summary>
bool ArchFeeds() => Archetype != BotArchetype.Killer;
/// <summary> 集结距离门槛:保镖超远也去汇合,农夫/猎杀者基本散养 </summary>
float ArchRallyRange() => Archetype switch
{
BotArchetype.Bodyguard => 1800f,
BotArchetype.Farmer => 700f,
BotArchetype.Killer => 700f,
_ => GameConfig.BotRallyRange,
};
protected override void OnFixedUpdate()
{
base.OnFixedUpdate();
if ( IsProxy ) return;
if ( !_self.IsValid() || !_self.Alive ) return; // 死亡状态不决策
if ( MatchState.MatchOver ) return; // 结算期全场冻结
var interval = DecisionInterval();
if ( _sinceDecision < interval ) return;
_sinceDecision = 0;
_stats = BotStats.Track( _self, (byte)Difficulty, (byte)Archetype );
if ( _stats is not null ) _stats.Decisions++;
var ctx = Survey();
// 领土目标层(M7.5):节流重估目标旗(选旗/喂旗规则见 PickFlagTarget)
if ( _sinceFlagPick >= GameConfig.BotFlagPickInterval ) PickFlagTarget();
if ( _hasFlagTarget )
{
// 目标格已满驻军 = 任务完成,放下目标回归觅食
TerritoryManager.CellOf( _flagTarget, out var tcx, out var tcy );
TerritoryManager.CellState( tcx, tcy, out var o, out _, out var sc );
if ( o == _self.TeamIndex && sc >= GameConfig.TerritoryCaptureScore )
_hasFlagTarget = false;
}
// 主动炸草目标层(v0.7.8.96):节流重估(选刺/验靶规则见 PickBombTarget)
if ( _sinceBombPick >= GameConfig.BotSpikeBombPickInterval ) PickBombTarget();
_self.SetBotDirection( Aim( AvoidSpines( AvoidSpikes( DecideDir( ctx ) ) ) ) );
// 表情档(v0.7.8.36 像素风):逃跑=惊恐,追猎=兴奋(王牌=得意),闲逛=开心。
// 写 Ball.Mood [Sync(FromHost)],各端 [Change] 换脸;值不变不发包
byte mood = 0;
if ( ctx.ThreatCount > 0 ) mood = 2;
else if ( ctx.PreyMass > 0f ) mood = Difficulty == BotDifficulty.Ace ? (byte)5 : (byte)1;
if ( _self.Mood != mood ) _self.Mood = mood;
// 动作层(方向之外的能力):分裂逃命 > 托孤吐球 > 分裂捕猎 / 喂队友 / 喂旗 / 炸草 / 职业 Q
TrySplitEscape( ctx );
TryBequest( ctx );
TrySplitHunt( ctx );
TryFeedMate( ctx );
TryFeedFlag( ctx );
TrySpikeBomb( ctx );
TryCastClassSkill( ctx );
}
/// <summary>
/// 职业技能施放(M7.5b):按职业情境化使用,全部走 ClassSkillManager.Cast 权威入口
/// (冷却/校验内置,冷却按球 Id 记——bot 各球独立)。难度概率 gate,新兵不放。
/// 指挥官=被威胁时传到最大队友身边(逃生+集结);工兵=远距离赶旗疾行/被追加速;
/// 护卫=被威胁时原地落尖刺断追兵;坦克=被威胁时质量爆发硬刚。
/// </summary>
void TryCastClassSkill( BotContext ctx )
{
if ( !MatchState.IsTerritory ) return;
if ( _self.TerritoryClass > 3 ) return;
if ( Difficulty == BotDifficulty.Rookie ) return;
if ( Game.Random.Float( 0f, 1f ) > ActionChance() ) return;
var use = _self.TerritoryClass switch
{
0 => ctx.ThreatCount > 0,
1 => ( _hasFlagTarget && _self.WorldPosition.Distance( _flagTarget ) > 1200f ) || ctx.ThreatCount > 0,
2 => ctx.ThreatCount > 0,
3 => ctx.ThreatCount > 0,
_ => false,
};
if ( !use ) return;
if ( _stats is not null && ClassSkillManager.Cast( _self ) ) _stats.Skills++; // 冷却未好静默拒绝,无副作用
}
/// <summary>
/// 选旗(M7.5 领土目标层):16 格里挑"能动手"的旗——己方格(邻接已含)或邻接己方领土的
/// 中立/敌格(CanActOnCell 与 Feed 的入侵门同一规则)。价值÷距离评分:
/// 满驻军不碰;己方残血=回防加固;中立=无守军优先;敌格=驻军越薄越值得抢(王牌 ×1.5 更爱进攻)。
/// 新兵不参与(照旧觅食游荡);每 BotFlagPickInterval 重估一次,带队随机抖动分散扎堆。
/// </summary>
void PickFlagTarget()
{
_sinceFlagPick = 0;
_hasFlagTarget = false;
if ( !MatchState.IsTerritory || _self.TeamIndex < 0 ) return;
if ( Difficulty == BotDifficulty.Rookie ) return;
var grid = GameConfig.TerritoryGrid;
// 驻军分快照数组(热重载会给老实例留 null,这里懒加载自愈——每帧路径禁 NRE)
if ( _cellScoreSeen is null || _cellScoreSeen.Length != grid * grid )
_cellScoreSeen = new float[grid * grid];
var myPos = _self.WorldPosition;
var bestScore = 0f;
for ( int cy = 0; cy < grid; cy++ )
{
for ( int cx = 0; cx < grid; cx++ )
{
TerritoryManager.CellState( cx, cy, out var owner, out _, out var score );
if ( owner == _self.TeamIndex && score >= GameConfig.TerritoryCaptureScore ) continue; // 满驻军没活干
if ( !TerritoryManager.CanActOnCell( cx, cy, _self.TeamIndex ) ) continue; // 够不着不空想
float value;
if ( owner == _self.TeamIndex )
{
value = 1f + ( GameConfig.TerritoryCaptureScore - score ) / GameConfig.TerritoryCaptureScore; // 回防加固
// 告急检测(v0.7.8.95):自己格的驻军分比上次采样掉了 → 正在被啃,紧急回防
// (领土衰减已砍,分数只会被敌人吃掉才会降;首采样 seen=0 不会误触发)
var seen = _cellScoreSeen[cy * grid + cx];
if ( seen >= 0f && score < seen - GameConfig.BotCellDropThreshold )
value += GameConfig.BotUrgentDefendBoost;
}
else if ( owner < 0 )
value = 1.3f; // 中立无守军
else
value = 0.6f + ( GameConfig.TerritoryCaptureScore - score ) / GameConfig.TerritoryCaptureScore; // 敌格看残度
// 个性原型旗值倍率(v0.7.8.95):抢旗手 ×1.6 直奔胜利条件,农夫 ×0.5 基本不碰
value *= ArchFlagScale();
if ( Difficulty == BotDifficulty.Ace && owner >= 0 && owner != _self.TeamIndex ) value *= 1.5f;
// 队友刚选中的格降权(v0.7.8.94):别全队挤同一面旗;预约超时或换格自动失效
var claimKey = ( (long)_self.TeamIndex << 32 ) | (uint)( cy * grid + cx );
if ( _flagClaims.TryGetValue( claimKey, out var claim ) && claim.BallId != _self.Id
&& claim.Since < GameConfig.BotFlagClaimSeconds )
value *= GameConfig.BotFlagClaimPenalty;
var d = myPos.Distance( TerritoryManager.CellCenter( cx, cy ) );
var s = value * 1000f / ( 200f + d ) * Game.Random.Float( 0.8f, 1.2f ); // 价值÷距离 + 抖动防全队扎堆
if ( s > bestScore )
{
bestScore = s;
_flagTarget = TerritoryManager.CellCenter( cx, cy );
_hasFlagTarget = true;
}
}
}
// 登记本次选中的格(v0.7.8.94),队友下轮选旗时避开;顺带刷新全部格的驻军分快照
for ( int cy2 = 0; cy2 < grid; cy2++ )
{
for ( int cx2 = 0; cx2 < grid; cx2++ )
{
TerritoryManager.CellState( cx2, cy2, out _, out _, out var sc );
_cellScoreSeen[cy2 * grid + cx2] = sc;
}
}
if ( _hasFlagTarget )
{
TerritoryManager.CellOf( _flagTarget, out var fcx, out var fcy );
_flagClaims[( (long)_self.TeamIndex << 32 ) | (uint)( fcy * grid + fcx )] = ( _self.Id, 0f );
}
}
/// <summary>
/// 吐孢子喂旗(M7.5 领土目标层):在目标旗圈内朝旗心吐——走 DoEject 同一条权威路径
/// (内部自带 0.15s 节流;孢子进旗圈即计分消耗)。质量低于阈值不喂(饿瘦=白给),
/// 保命/吃优先,本方法冷却再套一层控节奏。
/// </summary>
void TryFeedFlag( BotContext ctx )
{
if ( !GameConfig.EnableEject || !_hasFlagTarget ) return;
if ( ctx.ThreatCount > 0 || ctx.PreyMass > 0f ) return;
if ( _self.Mass < GameConfig.BotFeedFlagMinMass ) return;
if ( _self.WorldPosition.Distance( _flagTarget ) > GameConfig.TerritoryFlagRadius * 1.2f ) return; // 没到位不空吐
if ( _sinceFlagFeed < GameConfig.BotFeedFlagCooldown ) return;
_sinceFlagFeed = 0;
CircleroyaleGame.Current?.DoEject( _self, new Vector2( _flagTarget.x, _flagTarget.y ) );
if ( _stats is not null ) _stats.FeedFlags++;
}
/// <summary>
/// 借刺反杀(v0.7.8.96 手法1):逃跑途中找"追兵撞上必爆、自己能安全绕过"的刺——
/// 追兵半径 > SpikeRadius 才会吃刺爆裂,自己半径必须 ≤ SpikeRadius(队友的尖刺分身
/// 对我免疫,可以贴身借)。命中就在逃跑方向上掺一个向刺分量,与既有 AvoidSpikes
/// 的推离平衡后,实际轨迹是贴着刺芯安全边绕行——追兵的追击线正好犁过去。
/// 按最近威胁判定(简化:更远的大威胁不计),新兵不借。
/// </summary>
Vector2 KiteBias( BotContext ctx, Vector2 flee )
{
var bias = Difficulty switch
{
BotDifficulty.Rookie => 0f,
BotDifficulty.Ace => GameConfig.BotKiteBiasAce,
_ => GameConfig.BotKiteBiasVeteran,
};
if ( bias <= 0f || ctx.ThreatCount == 0 ) return Vector2.Zero;
// 自己比刺大:撞刺爆的是我,不借;追兵不比刺大:借了也没收益
var myRadius = _self.Radius;
if ( myRadius > GameConfig.SpikeRadius ) return Vector2.Zero;
if ( ctx.ThreatRadius <= GameConfig.SpikeRadius ) return Vector2.Zero;
var game = CircleroyaleGame.Current;
if ( game is null ) return Vector2.Zero;
var myPos = _self.WorldPosition;
var best = Vector2.Zero;
var bestAlign = 0.3f; // 只顺路借刺(与逃跑方向夹角约 <72°),不为借刺大绕路
if ( game.Spikes is not null )
{
foreach ( var s in game.Spikes )
{
var to = new Vector2( s.x - myPos.x, s.y - myPos.y );
var d = to.Length;
if ( d < 0.001f || d > GameConfig.BotKiteRange ) continue;
var align = Vector2.Dot( to / d, flee );
if ( align <= bestAlign ) continue;
bestAlign = align;
best = to / d;
}
}
// 队友的尖刺分身:自己免疫,最优借刺对象(敌方分身与野刺同规则,也纳入)
var cells = game.Cells;
if ( cells is not null )
{
foreach ( var c in cells )
{
if ( c.PieceKind != CellPiece.Kind.SpikeMinion ) continue;
var ob = c.OwnerBall;
bool friendly = MatchState.IsTeam && ob.IsValid() && ob.Alive && ob.TeamIndex == _self.TeamIndex;
if ( !friendly && myRadius > GameConfig.SpikeRadius ) continue; // 敌方分身:自己大了不敢贴
var to = new Vector2( c.Pos.x - myPos.x, c.Pos.y - myPos.y );
var d = to.Length;
if ( d < 0.001f || d > GameConfig.BotKiteRange ) continue;
var align = Vector2.Dot( to / d, flee ) * ( friendly ? 1.3f : 1f );
if ( align <= bestAlign ) continue;
bestAlign = align;
best = to / d;
}
}
if ( best.LengthSquared <= 0f ) return Vector2.Zero;
if ( _stats is not null ) _stats.Kites++;
return best * bias;
}
/// <summary>
/// 临终托孤(v0.7.8.96 手法2):威胁已贴脸(距离 < 双方半径+BotBequestMouth,且分裂
/// 逃命未必来得及)时找"逃跑半区内、自身离威胁足够远"的最近队友——往他身上吐质量,
/// 死也不给追兵白吃。新兵不会这手(只会慌),质量太薄不值得折腾。
/// </summary>
Ball FindBequestMate( BotContext ctx )
{
if ( !GameConfig.EnableEject || Difficulty == BotDifficulty.Rookie ) return null;
if ( ctx.Mates.Count == 0 || _self.Mass < GameConfig.BotBequestMinMass ) return null;
var mouth = ctx.ThreatRadius + _self.Radius + GameConfig.BotBequestMouth;
if ( _self.WorldPosition.Distance( ctx.ThreatPos ) > mouth ) return null; // 还没到绝路
var myPos = _self.WorldPosition;
var away = myPos - ctx.ThreatPos;
Ball best = null;
var bestD = float.MaxValue;
foreach ( var m in ctx.Mates )
{
if ( !m.IsValid() || !m.Alive ) continue;
var to = m.WorldPosition - myPos;
if ( Vector3.Dot( to, away ) < 0f ) continue; // 只在逃跑半区找,别掉头冲进威胁怀里
if ( m.WorldPosition.Distance( ctx.ThreatPos ) < GameConfig.BotBequestMateSafety ) continue;
var d = to.Length;
if ( d < bestD )
{
bestD = d;
best = m;
}
}
return best;
}
/// <summary> 托孤动作层:贴脸状态持续期间朝队友连吐孢子(DoEject 自带 0.15s 节流) </summary>
void TryBequest( BotContext ctx )
{
if ( !GameConfig.EnableEject || ctx.ThreatCount == 0 ) return;
var mate = FindBequestMate( ctx );
if ( !mate.IsValid() ) return;
CircleroyaleGame.Current?.DoEject( _self,
new Vector2( mate.WorldPosition.x, mate.WorldPosition.y ) );
if ( _stats is not null ) _stats.Bequests++;
}
/// <summary>
/// 调试探针(临时,v0.7.8.101,cr_bombprobe 用):每只够格 bot 的炸草管线实况——
/// 是否有目标/距刺多远/开喂圈内有几个敌人/耐心剩余。随手法稳定后删。
/// </summary>
public static string DebugBombProbe()
{
var game = CircleroyaleGame.Current;
if ( game is null ) return "no game";
var sb = new StringBuilder();
foreach ( var b in game.Balls )
{
if ( !b.IsValid() || !b.Alive || !b.IsBot ) continue;
if ( b.Mass < GameConfig.BotSpikeBombMinMass ) continue;
var brain = b.GetComponent<BotBrain>();
if ( brain is null ) continue;
sb.AppendLine( $"'{b.PlayerName}' m={b.Mass:0} hasTarget={brain._hasBombTarget}" );
if ( !brain._hasBombTarget ) continue;
var d = b.WorldPosition.Distance( brain._bombSpike );
int eRing = 0, eArea = 0;
foreach ( var o in game.Balls )
{
if ( !o.IsValid() || !o.Alive || o == b ) continue;
if ( MatchState.IsTeam && o.TeamIndex >= 0 && o.TeamIndex == b.TeamIndex ) continue;
var od = o.WorldPosition.Distance( brain._bombSpike );
if ( od <= GameConfig.BotSpikeBombRadius && o.Mass >= GameConfig.BotSpikeBombEnemyMinMass ) eRing++;
if ( od <= GameConfig.BotSpikeBombAreaRadius ) eArea++;
}
sb.AppendLine( $" spikeD={d:0} feedR={b.Radius + GameConfig.BotFeedEjectReach:0} enemiesInRing={eRing} inArea={eArea} patienceLeft={GameConfig.BotSpikeBombPatience - brain._sinceBombArrive:0}" );
}
return sb.Length == 0 ? "no eligible bot (mass < MinMass)" : sb.ToString();
}
/// <summary>
/// 选炸草目标刺(v0.7.8.99/101 伏击制):质量烧得起即可选刺(v0.7.8.101 放开新兵门禁——
/// 实测领土局能攒到 fat 的常是蹲角落的新兵,排除他们 = 炸草永远凑不齐人)。
/// 选刺时**不要求**有敌情(3 倍场地 16 球摊得稀,"肥 bot+敌群+刺"同框是小概率事件;
/// 真打法是守在刺旁等敌人路过),敌情只作加分;队友都在危险圈外是硬条件。
/// 价值按敌情加成÷路程评分。
/// </summary>
void PickBombTarget()
{
_sinceBombPick = 0;
_hasBombTarget = false;
if ( !GameConfig.EnableEject ) return;
if ( _self.Mass < GameConfig.BotSpikeBombMinMass ) return;
var game = CircleroyaleGame.Current;
var spikes = game?.Spikes;
if ( spikes is null || spikes.Length == 0 ) return;
var myPos = _self.WorldPosition;
var bestScore = 0f;
foreach ( var s in spikes )
{
var sp = new Vector3( s.x, s.y, 0f );
// 队友必须都在弹幕圈外(自己进场喂刺是计划内风险)
bool mateClear = true;
foreach ( var b in game.Balls )
{
if ( !b.IsValid() || !b.Alive || b == _self ) continue;
if ( !( MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex ) ) continue;
if ( b.WorldPosition.Distance( sp ) < GameConfig.BotSpikeBombMateClear )
{
mateClear = false;
break;
}
}
if ( !mateClear ) continue;
// 敌情只加分不设门槛:动静圈(1800)内有大目标 ×2、任意敌人 ×1.2
float traffic = 0f;
foreach ( var b in game.Balls )
{
if ( !b.IsValid() || !b.Alive || b == _self ) continue;
if ( MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex ) continue;
if ( b.WorldPosition.Distance( sp ) > GameConfig.BotSpikeBombAreaRadius ) continue;
traffic = MathF.Max( traffic, b.Mass >= GameConfig.BotSpikeBombEnemyMinMass ? 2f : 1.2f );
}
var d = myPos.Distance( sp );
if ( d > GameConfig.BotSpikeBombRange ) continue; // 太远不赶场,走到的路上目标早换了
var sc = traffic * 1000f / ( 200f + d );
if ( sc > bestScore )
{
bestScore = sc;
_bombSpike = sp;
_hasBombTarget = true;
}
}
if ( _hasBombTarget ) _sinceBombArrive = 0; // 新目标重置耐心计时
}
/// <summary> 炸草目标复核(每次决策):刺还在原位 + 队友没进圈 + 自己没喂瘦 + 没超耐心。
/// (v0.7.8.99 伏击制:走到/蹲守期间不要求敌人在场——敌人进圈才开喂是 TrySpikeBomb 的事) </summary>
bool VerifyBombTarget()
{
if ( _self.Mass < GameConfig.BotSpikeBombFloorMass ) return false;
if ( _sinceBombArrive > GameConfig.BotSpikeBombPatience ) return false;
var game = CircleroyaleGame.Current;
var spikes = game?.Spikes;
if ( spikes is null ) return false;
bool spikeAlive = false;
foreach ( var s in spikes )
{
var sp = new Vector3( s.x, s.y, 0f );
if ( sp.Distance( _bombSpike ) < 80f )
{
spikeAlive = true;
break;
}
}
if ( !spikeAlive ) return false; // 已被爆掉/换位
foreach ( var b in game.Balls )
{
if ( !b.IsValid() || !b.Alive || b == _self ) continue;
if ( MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex )
{
if ( b.WorldPosition.Distance( _bombSpike ) < GameConfig.BotSpikeBombMateClear )
return false;
}
}
return true;
}
/// <summary> 炸草动作层:到刺旁候着,敌方目标进弹幕圈才开喂(白等不白喂);
/// 有威胁/有猎物立即让位 </summary>
void TrySpikeBomb( BotContext ctx )
{
if ( !GameConfig.EnableEject || !_hasBombTarget ) return;
if ( ctx.ThreatCount > 0 || ctx.PreyMass > 0f ) return;
if ( _self.Mass < GameConfig.BotSpikeBombFloorMass ) return;
if ( _self.WorldPosition.Distance( _bombSpike ) > _self.Radius + GameConfig.BotFeedEjectReach ) return;
// 严格圈:目标真进了弹幕圈才烧自己的质量
bool enemyInRing = false;
var game = CircleroyaleGame.Current;
if ( game is not null )
{
foreach ( var b in game.Balls )
{
if ( !b.IsValid() || !b.Alive || b == _self ) continue;
if ( MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex ) continue;
if ( b.Mass < GameConfig.BotSpikeBombEnemyMinMass ) continue;
if ( b.WorldPosition.Distance( _bombSpike ) > GameConfig.BotSpikeBombRadius ) continue;
enemyInRing = true;
break;
}
}
if ( !enemyInRing ) return;
if ( _sinceBombFeed < GameConfig.BotSpikeBombCooldown ) return;
_sinceBombFeed = 0;
CircleroyaleGame.Current?.DoEject( _self, new Vector2( _bombSpike.x, _bombSpike.y ) );
if ( _stats is not null ) _stats.SpikeBombs++;
}
/// <summary> 收集态势:威胁/猎物(球 + 敌方分身,跳过队友)+ 喂养目标 + 队友列表 </summary>
BotContext Survey()
{
var ctx = new BotContext();
var game = CircleroyaleGame.Current;
if ( game is null ) return ctx;
float threatDist = float.MaxValue;
float preyScore = -1f; // 猎物按"质量回报/距离"评分,不再单纯取最近
float feedMass = -1f;
var myPos = _self.WorldPosition;
var myMass = _self.Mass;
var shielded = _self.HasBuff( PowerUpManager.Kind.Shield ); // 护盾期连分身都吞不了我
foreach ( var b in game.Balls )
{
if ( !b.IsValid() || !b.Alive || b == _self ) continue;
bool mate = MatchState.IsTeam && b.TeamIndex >= 0 && b.TeamIndex == _self.TeamIndex;
if ( mate ) ctx.Mates.Add( b );
var d = myPos.Distance( b.WorldPosition );
// 分裂风险评估(v0.7.8.32,独立于威胁分支):减半后的主体会不会被它近身吃掉——
// 能吃"减半我"但吃不了"现在的我"的中等敌人恰恰是分裂后的头号杀手
if ( !mate && b.Mass >= myMass * 0.5f * GameConfig.EatRatio
&& d < b.Radius + _self.Radius * 0.75f + GameConfig.BotSplitRiskRange )
{
ctx.DangerAfterSplit = true;
}
// 威胁:质量够吃我(≥ EatRatio,与权威吃取同式)、不是队友、我不在保护/护盾期
if ( !mate && b.Mass >= myMass * GameConfig.EatRatio && !_self.IsProtected && !shielded )
{
var range = b.Radius + _self.Radius + GameConfig.BotFleeRange * RangeScale();
if ( d < range )
{
ctx.ThreatCount++;
ctx.FleeDir += ( myPos - b.WorldPosition ).Normal * ( 1f - d / range ); // 越近推力越大
if ( d < threatDist )
{
threatDist = d;
ctx.Threat = b;
ctx.ThreatPos = b.WorldPosition;
ctx.ThreatRadius = b.Radius;
}
}
// TTC 预警(v0.7.8.95):还没进逃跑圈、但正高速逼近的致命敌人——记进预警和,
// 由 DecideDir 往当前方向掺避让分量(老手/王牌提前走位,新兵视界 0=看不见)
else
{
var horizon = ThreatHorizon();
if ( horizon > 0f )
{
var closing = Vector3.Dot( ( b.WorldPosition - myPos ).Normal, b.Velocity - _self.Velocity );
if ( closing > 60f )
{
var ttc = d / closing;
if ( ttc < horizon )
{
var away = ( myPos - b.WorldPosition ).Normal;
ctx.EarlyThreatDir += new Vector2( away.x, away.y ) * ( 1f - ttc / horizon );
ctx.EarlyThreatCount++;
}
}
}
}
}
// 猎物:我够吃它、不是队友、双方都不在保护期(护盾期免战,追了也白追);
// 道具护盾同理——追也吃不到,跳过不浪费时间
else if ( !mate && myMass >= b.Mass * GameConfig.EatRatio && !b.IsProtected && !_self.IsProtected
&& !b.HasBuff( PowerUpManager.Kind.Shield ) )
{
var range = GameConfig.BotHuntRange * RangeScale() * ArchHuntScale();
if ( d < range && CanCatch( b.WorldPosition, b.Velocity, myPos ) )
{
var score = b.Mass / ( 100f + d ) * ArchPreyBonus();
if ( score > preyScore )
{
ctx.Prey = b;
ctx.PreyPos = b.WorldPosition;
ctx.PreyRadius = b.Radius;
ctx.PreyMass = b.Mass;
ctx.PreyVel = b.Velocity;
preyScore = score;
}
}
}
// 喂养目标(团队赛,养大哥):范围内最大的、明显比我大的队友
// (v0.7.8.95:门槛按原型取——保镖 1.08 倍就喂;猎杀者不喂人)
if ( mate && GameConfig.EnableEject && ArchFeeds() && myMass >= GameConfig.BotFeedMinMass
&& b.Mass > myMass * ArchFeedThreshold()
&& d < GameConfig.BotFeedRange * RangeScale()
&& b.Mass > feedMass )
{
ctx.FeedTarget = b;
feedMass = b.Mass;
}
}
// 敌方分身感知(v0.7.8.32):分身能吞无盾主球也能被吞——此前 bot 完全看不见它们。
// 阵营按主人球判(TickCells 自愈保证 OwnerBall 有效;主人真死时分身本来也会被清)
var cells = game.Cells;
if ( cells is not null )
{
foreach ( var c in cells )
{
if ( c.PieceKind != CellPiece.Kind.SplitPiece ) continue;
if ( c.OwnerSteamId == _self.OwnerSteamId ) continue;
var ob = c.OwnerBall;
if ( MatchState.IsTeam && ob.IsValid() && ob.TeamIndex >= 0 && ob.TeamIndex == _self.TeamIndex ) continue;
var d = myPos.Distance( c.Pos );
// 分裂风险评估(同球规则,独立于威胁分支)
if ( c.Mass >= myMass * 0.5f * GameConfig.EatRatio
&& d < c.Radius + _self.Radius * 0.75f + GameConfig.BotSplitRiskRange )
{
ctx.DangerAfterSplit = true;
}
if ( c.Mass >= myMass * GameConfig.EatRatio && !_self.IsProtected && !shielded )
{
var range = c.Radius + _self.Radius + GameConfig.BotFleeRange * RangeScale();
if ( d < range )
{
ctx.ThreatCount++;
ctx.FleeDir += ( myPos - c.Pos ).Normal * ( 1f - d / range );
if ( d < threatDist )
{
threatDist = d;
ctx.Threat = null;
ctx.ThreatPos = c.Pos;
ctx.ThreatRadius = c.Radius;
}
}
else
{
// 分身的 TTC 预警(v0.7.8.95,同主球规则):分身速度 = 冲量 + 跟随转向
var horizon = ThreatHorizon();
if ( horizon > 0f )
{
var cv = c.Impulse + c.SteerVel;
var closing = Vector3.Dot( ( c.Pos - myPos ).Normal, cv - _self.Velocity );
if ( closing > 60f )
{
var ttc = d / closing;
if ( ttc < horizon )
{
var away = ( myPos - c.Pos ).Normal;
ctx.EarlyThreatDir += new Vector2( away.x, away.y ) * ( 1f - ttc / horizon );
ctx.EarlyThreatCount++;
}
}
}
}
}
else if ( myMass >= c.Mass * GameConfig.EatRatio )
{
var range = GameConfig.BotHuntRange * RangeScale() * ArchHuntScale();
var cv = c.Impulse + c.SteerVel; // 分身当前速度(冲量+跟随转向)
if ( d < range && CanCatch( c.Pos, cv, myPos ) )
{
var score = c.Mass / ( 100f + d ) * ArchPreyBonus();
if ( score > preyScore )
{
ctx.Prey = null;
ctx.PreyPos = c.Pos;
ctx.PreyRadius = c.Radius;
ctx.PreyMass = c.Mass;
ctx.PreyVel = cv;
preyScore = score;
}
}
}
}
}
return ctx;
}
/// <summary>
/// 方向决策入口(v0.7.8.95):核心状态机外面套一层 TTC 预警避让——预警不动摇状态机
/// (该追还追、该赶旗还赶旗),只往算出的方向里掺一个"避开正在逼近的致命敌人"的分量。
/// 停驻状态(方向≈0,如圈内喂旗/站位喂大哥)不掺:预警视界只有 1 秒左右,真到嘴边
/// 逃跑层自然会接管,别让喂旗 bot 被远处的敌人吓跑。
/// </summary>
Vector2 DecideDir( BotContext ctx )
{
var dir = DecideDirCore( ctx );
if ( ctx.EarlyThreatCount > 0 && dir.Length > 0.001f )
{
var away = Norm( new Vector3( ctx.EarlyThreatDir.x, ctx.EarlyThreatDir.y, 0f ) );
dir = Norm( new Vector3(
dir.x + away.x * GameConfig.BotEarlyThreatBlend,
dir.y + away.y * GameConfig.BotEarlyThreatBlend, 0f ) );
}
return dir;
}
/// <summary> 方向决策核心:逃跑 > 追猎 > 靠近喂养目标 > 觅食 > 游荡(团队赛偏集结) </summary>
Vector2 DecideDirCore( BotContext ctx )
{
var myPos = _self.WorldPosition;
// 逃跑:全部威胁反向合成(越近权重越大,v0.7.8.32)+ 朝场地中心分量(防被逼进角落)
if ( ctx.ThreatCount > 0 )
{
if ( _stats is not null ) _stats.Flees++;
// 临终托孤(v0.7.8.96):威胁贴脸、逃生无望,且逃跑半区有安全队友——
// 别直线送死,掉头朝队友把质量送出去再死(动作层 TryBequest 配合吐球)
var bequest = FindBequestMate( ctx );
if ( bequest.IsValid() ) return Norm( bequest.WorldPosition - myPos );
var flee = ctx.FleeDir.Length > 0.01f
? Norm( ctx.FleeDir )
: new Vector2( myPos.x - ctx.ThreatPos.x, myPos.y - ctx.ThreatPos.y );
var toCenter = new Vector2( -myPos.x, -myPos.y );
var len = toCenter.Length;
toCenter = len > 0.001f ? toCenter / len : Vector2.Zero;
var dir = Norm( new Vector3( flee.x + toCenter.x * 0.45f, flee.y + toCenter.y * 0.45f, 0f ) );
// 借刺反杀(v0.7.8.96):追兵撞刺必爆、自己能安全绕过 → 逃跑方向掺向刺分量,
// 让追兵的追击线犁过刺芯(大球转向笨拙,人类玩家更容易上当)
var kite = KiteBias( ctx, dir );
if ( kite.LengthSquared > 0.0001f )
dir = Norm( new Vector3( dir.x + kite.x, dir.y + kite.y, 0f ) );
return dir;
}
// 追猎:按猎物当前速度打拦截提前量(v0.7.8.32)——直线追永远追不上会跑的猎物
if ( ctx.PreyMass > 0f )
{
if ( _stats is not null ) _stats.Hunts++;
var target = ctx.PreyPos;
var lead = LeadFactor();
if ( lead > 0f && ctx.PreyVel.LengthSquared > 1f )
{
var mySpeed = GameConfig.BaseSpeed
* MathF.Pow( GameConfig.StartMass / MathF.Max( 1f, _self.Mass ), GameConfig.SpeedCurve );
var d = myPos.Distance( ctx.PreyPos );
var leadVec = ctx.PreyVel * ( d / MathF.Max( 60f, mySpeed ) ) * lead;
var leadLen = leadVec.Length;
if ( leadLen > _self.Radius * 1.5f ) leadVec *= _self.Radius * 1.5f / leadLen; // 封顶别冲过头
target = ctx.PreyPos + leadVec;
}
return Norm( target - myPos );
}
// 主动炸草导航(v0.7.8.99):**压在旗目标层上面**——非新兵几乎永远有旗目标
// (每 2s 重选总有格可打),炸草层放它后面就永远轮不到(bomb 全场为 0 的实测根因)。
// 质量够 + 附近有动静圈合格的刺才偏离旗任务,偏差约 10 秒;目标失效当场放弃
if ( _hasBombTarget && !VerifyBombTarget() )
_hasBombTarget = false;
if ( _hasBombTarget )
{
var d = myPos.Distance( _bombSpike );
if ( d > _self.Radius + GameConfig.BotFeedEjectReach ) return Norm( _bombSpike - myPos );
return OrbitDir( _bombSpike ); // 绕刺盘旋候敌:AvoidSpikes 钳安全距离,敌人进圈就开喂
}
// 领土目标导航(M7.5):无威胁无猎物时朝目标旗赶路——压过喂大哥/捡道具/觅食
// (旗是胜利条件);饿瘦到喂旗阈值以下就放下目标,落回觅食层恢复
if ( _hasFlagTarget )
{
if ( _self.Mass < GameConfig.BotFeedFlagMinMass )
{
_hasFlagTarget = false;
}
else
{
// v0.7.8.94:进旗圈站稳(圈内出生的孢子即计分),不再直奔旗心;
// v0.7.8.103:站桩改缓速盘旋(两只相近 bot 同旗站桩=对峙卡死的死相)
var d = myPos.Distance( _flagTarget );
if ( d > GameConfig.BotFlagStandoff ) return Norm( _flagTarget - myPos );
return OrbitDir( _flagTarget );
}
}
// 喂养:靠近大哥到"孢子够得着"的距离即停(v0.7.8.94 修"两 bot 缩在一起"——
// 旧版直奔大哥球心贴脸推挤,且越喂条件越成立,一蹲几十秒);
// 连续喂养有时长预算,喂一阵强制休整去觅食回血,行为不再是"吸附"在大哥身上
if ( ctx.FeedTarget.IsValid() )
{
if ( _sinceFeedSession > ( _feedBreak ? GameConfig.BotFeedBreakSeconds : GameConfig.BotFeedMaxSeconds ) )
{
_feedBreak = !_feedBreak;
_sinceFeedSession = 0;
}
if ( !_feedBreak )
{
var d = myPos.Distance( ctx.FeedTarget.WorldPosition );
var stand = ctx.FeedTarget.Radius + _self.Radius + GameConfig.BotFeedStandoff;
if ( d > stand ) return Norm( ctx.FeedTarget.WorldPosition - myPos );
return OrbitDir( ctx.FeedTarget.WorldPosition ); // 到位即绕大哥缓转:圈内正好接孢子,不贴脸推挤
}
}
else if ( _sinceFeedSession > 1f )
{
_sinceFeedSession = 0; // 长时间没目标:喂养会话清零,下次重新计时
_feedBreak = false;
}
// 顺路捡道具(v0.7.8.7):无威胁无猎物时附近场上有道具就冲过去(bot 拾取即用,
// 捡到立刻生效);喂大哥/觅食都让位给这个
var pu = NearestPowerUpPos( myPos );
if ( pu.HasValue )
return Norm( new Vector3( pu.Value.x, pu.Value.y, 0f ) - myPos );
// 觅食:先看散落孢子(质量远大于食物点,值得绕路),没有再找最近食物;
// 两层各自带粘性(v0.7.8.32):目标活着就追到底,不在两个目标间来回锯齿
var blob = NearestBlob( myPos );
var food = NearestFoodPos( myPos );
Vector2? target2 = null;
bool blobChosen = false;
if ( blob is not null && food is null )
{
target2 = new Vector2( blob.Pos.x, blob.Pos.y );
blobChosen = true;
}
else if ( blob is not null && food.HasValue )
{
// 孢子比食物点值钱好几倍:绕路 2.5 倍距离以内都值得去拿
var bd = myPos.Distance( blob.Pos );
var fd = myPos.Distance( new Vector3( food.Value.x, food.Value.y, 0f ) );
if ( bd < fd * 2.5f + 160f )
{
target2 = new Vector2( blob.Pos.x, blob.Pos.y );
blobChosen = true;
}
}
if ( !blobChosen && food.HasValue ) target2 = food.Value;
if ( target2.HasValue )
{
if ( blobChosen )
{
_stickyBlobId = blob.Id;
_sinceStickyBlob = 0;
_sinceStickyFood = 99f; // 失效化
}
else
{
_stickyFood = target2.Value;
_sinceStickyFood = 0;
_stickyBlobId = -1;
}
return Norm( new Vector3( target2.Value.x, target2.Value.y, 0f ) - myPos );
}
// 游荡:团队赛只在"离队友远"时才集结过去(v0.7.8.94:旧版无条件偏向随机队友 ±900,
// 两个闲 bot 互相选对方就在原地 hover 成一团);已经挨着队友就随机散开
if ( !_hasWander || myPos.Distance( _wanderTarget ) < 120f )
{
float half = GameConfig.ArenaHalfSize - 256f;
var mh = GameConfig.ArenaHalfSize - 512f;
var mate = ( MatchState.IsTeam && ctx.Mates.Count > 0 )
? ctx.Mates[Game.Random.Int( 0, ctx.Mates.Count - 1 )]
: null;
if ( mate.IsValid() && myPos.Distance( mate.WorldPosition ) > ArchRallyRange() )
{
_wanderTarget = new Vector3(
Math.Clamp( mate.WorldPosition.x + Game.Random.Float( -900f, 900f ), -mh, mh ),
Math.Clamp( mate.WorldPosition.y + Game.Random.Float( -900f, 900f ), -mh, mh ), 0f );
}
else
{
_wanderTarget = new Vector3( Game.Random.Float( -half, half ), Game.Random.Float( -half, half ), 0f );
}
_hasWander = true;
}
return Norm( _wanderTarget - myPos );
}
/// <summary>
/// 分裂逃命(v0.7.8.7,agar 保命手法):威胁即将咬到(距离小于双方半径之和 + 一口余量)且
/// 自身分裂后仍是像样的身体 → 朝逃跑方向分裂——分身带冲量瞬间前窜、主体变轻提速,
/// 脱险后冷却一到自然合体。与捕猎共用 _sinceSplit 冷却,逃命优先判定。
/// v0.7.8.32:威胁可以是敌方分身(它们同样能吞主球)。
/// </summary>
void TrySplitEscape( BotContext ctx )
{
if ( !GameConfig.EnableSplit || ctx.ThreatCount == 0 ) return;
if ( _self.Mass < GameConfig.BotSplitMinMass * 2f ) return; // 太小分裂=白送两份
if ( _sinceSplit < GameConfig.BotSplitCooldown ) return;
var to = _self.WorldPosition - ctx.ThreatPos;
var d = MathF.Max( to.Length, 0.001f );
if ( d > ctx.ThreatRadius + _self.Radius + 60f ) return; // 还没到嘴边不浪费质量
if ( Game.Random.Float( 0f, 1f ) > ActionChance() ) return;
_sinceSplit = 0;
// 逃跑方向 = 反向 + 朝场地中心分量(与 DecideDir 逃跑同一套);传"瞄准点"语义
var away = Norm( to + ( Vector3.Zero - _self.WorldPosition ).Normal * 0.45f );
var aimPoint = new Vector2( _self.WorldPosition.x + away.x * 2048f,
_self.WorldPosition.y + away.y * 2048f );
CircleroyaleGame.Current?.DoSplit( _self, aimPoint );
if ( _stats is not null ) _stats.SplitEscapes++;
GameLog.Info( $"[bot] split-escape '{_self.PlayerName}' <- '{ctx.Threat?.PlayerName ?? "cell"}'" );
}
/// <summary>
/// 分裂捕猎(v0.6.6.0):贴身(半径+打击距离内)、分出去的一半仍够吃猎物、过冷却与
/// 难度概率门槛 → 朝猎物分裂。分身带冲量飞出去,之后跟随主人转向继续压猎物。
/// v0.7.8.7:传猎物位置为瞄准点——每颗身体各自朝猎物扇形合围。
/// v0.7.8.32:猎物太小不烧冷却(BotSplitPreyFraction);减半后 nearby 有够吃我的敌人就放弃
/// (送命分裂,DangerAfterSplit);猎物可以是敌方分身。
/// </summary>
void TrySplitHunt( BotContext ctx )
{
if ( !GameConfig.EnableSplit || ctx.PreyMass <= 0f ) return;
if ( _self.Mass < GameConfig.BotSplitMinMass ) return; // 太小分裂=白送质量
if ( ctx.PreyMass < _self.Mass * GameConfig.BotSplitPreyFraction ) return; // 太小的猎物不值得烧冷却
if ( _self.Mass * 0.5f < ctx.PreyMass * GameConfig.EatRatio ) return; // 分身必须还能吃掉猎物
if ( ctx.DangerAfterSplit ) return; // 减半=送命,不干
if ( _sinceSplit < GameConfig.BotSplitCooldown ) return;
var d = _self.WorldPosition.Distance( ctx.PreyPos );
if ( d > _self.Radius + GameConfig.BotSplitRange * RangeScale() ) return;
if ( Game.Random.Float( 0f, 1f ) > ActionChance() ) return;
_sinceSplit = 0;
CircleroyaleGame.Current?.DoSplit( _self, new Vector2( ctx.PreyPos.x, ctx.PreyPos.y ) );
if ( _stats is not null ) _stats.SplitHunts++;
GameLog.Info( $"[bot] split-hunt '{_self.PlayerName}' -> '{ctx.Prey?.PlayerName ?? "cell"}'" );
}
/// <summary>
/// 吐孢子喂队友(团队赛,v0.6.6.0 养大哥):向范围内最大的队友吐孢子,
/// 走 DoEject 同一条权威路径(内部自带 0.15s 节流;多身体一起吐)。
/// 决策间隔 + 本方法冷却 + 难度概率三重节流,喂瘦到对方不足我 1.25 倍自动停。
/// </summary>
void TryFeedMate( BotContext ctx )
{
if ( !GameConfig.EnableEject || ctx.FeedTarget is null || !ctx.FeedTarget.IsValid() ) return;
if ( _feedBreak ) return; // 休整期不喂(DecideDir 也不靠近)
if ( ctx.ThreatCount > 0 || ctx.PreyMass > 0f ) return; // 保命/吃优先
if ( _sinceFeed < GameConfig.BotFeedCooldown ) return;
// 距离门(v0.7.8.94):孢子飞行约 350 就停,超远吐=白扔地上(还便宜路过的敌人)
if ( _self.WorldPosition.Distance( ctx.FeedTarget.WorldPosition ) >
ctx.FeedTarget.Radius + GameConfig.BotFeedEjectReach ) return;
if ( Game.Random.Float( 0f, 1f ) > ActionChance() ) return;
_sinceFeed = 0;
// 传大哥位置为瞄准点(v0.7.8.7):每颗身体各自朝大哥吐——远处的分身不再沿
// 主球方向平行走空,孢子真正落进大哥嘴里
CircleroyaleGame.Current?.DoEject( _self,
new Vector2( ctx.FeedTarget.WorldPosition.x, ctx.FeedTarget.WorldPosition.y ) );
if ( _stats is not null ) _stats.FeedMates++;
}
/// <summary> 避刺:任何体积都得绕(感知距离 = 自身半径 + SpikeRadius×2.2)。
/// v0.7.8.103 修"bot 在刺旁左右抖、原地钉死":切向绕行符号改用每 bot 固定旋向
/// (旧版按"期望方向"逐帧选边——正对刺走近时对齐量≈0,符号每帧翻转 = 左右横跳);
/// 贴得越近切向增重越压过径向推离,把"顶死"稳定变成绕圈。 </summary>
Vector2 AvoidSpikes( Vector2 dir )
{
var spikes = CircleroyaleGame.Current?.Spikes;
if ( spikes is null ) return dir;
var myPos = _self.WorldPosition;
var aware = _self.Radius + GameConfig.SpikeRadius * GameConfig.BotSpikeAwareness;
foreach ( var s in spikes )
{
var away = new Vector2( myPos.x - s.x, myPos.y - s.y );
var d = away.Length;
if ( d >= aware || d < 0.001f ) continue;
away /= d;
var tx = -away.y * _orbitSign;
var ty = away.x * _orbitSign;
var near = 1f - d / aware;
var push = 0.55f + 0.45f * near;
var tangential = 0.9f + 1.3f * near; // 贴近时绕行压过推离 → 稳定弧线不顶死
dir = Norm( new Vector3(
dir.x + ( away.x * 1.6f + tx * tangential ) * push,
dir.y + ( away.y * 1.6f + ty * tangential ) * push, 0f ) );
}
return dir;
}
/// <summary> 躲刺爆弹幕(v0.7.8.32):飞行中的刺也是真伤害(命中 -15% 质量),近距离侧向闪避 </summary>
Vector2 AvoidSpines( Vector2 dir )
{
var spines = CircleroyaleGame.Current?.Spines;
if ( spines is null || spines.Count == 0 ) return dir;
var myPos = _self.WorldPosition;
var aware = _self.Radius + 96f;
foreach ( var s in spines )
{
if ( s.SinceSpawn > 0.5f ) continue; // 后半程动能耗尽,不管
var dx = myPos.x - s.Pos.x;
var dy = myPos.y - s.Pos.y;
var d = MathF.Sqrt( dx * dx + dy * dy );
if ( d >= aware || d < 0.001f ) continue;
dir = Norm( new Vector3( dir.x + dx / d * 1.2f, dir.y + dy / d * 1.2f, 0f ) );
}
return dir;
}
/// <summary> 瞄准噪声:低难度方向打不准(±弧度内随机偏转),王牌几乎指哪打哪 </summary>
Vector2 Aim( Vector2 dir )
{
var err = AimError();
if ( err <= 0f || dir.Length <= 0.001f ) return dir;
var a = Game.Random.Float( -err, err );
var cos = MathF.Cos( a );
var sin = MathF.Sin( a );
return new Vector2( dir.x * cos - dir.y * sin, dir.x * sin + dir.y * cos );
}
Vector2? NearestFoodPos( Vector3 myPos )
{
var foods = CircleroyaleGame.Current.Food?.Foods;
if ( foods is null ) return null;
var best = float.MaxValue;
Vector2? bestPos = null;
var seek = GameConfig.BotSeekFoodRange * RangeScale();
var seekSq = seek * seek;
// 粘性折扣(v0.7.8.32):上次的目标还活着就当它更近(圈内匹配),防相邻食物间来回锯齿
var sticky = _sinceStickyFood < 2.5f ? (Vector2?)_stickyFood : null;
for ( int i = 0; i < foods.Length; i++ )
{
if ( !foods[i].Alive ) continue;
var dx = foods[i].Pos.x - myPos.x;
var dy = foods[i].Pos.y - myPos.y;
var dSq = dx * dx + dy * dy;
if ( sticky.HasValue )
{
var sx = foods[i].Pos.x - sticky.Value.x;
var sy = foods[i].Pos.y - sticky.Value.y;
if ( sx * sx + sy * sy < 64f * 64f ) dSq *= 0.45f;
}
if ( dSq > seekSq || dSq >= best ) continue;
best = dSq;
bestPos = foods[i].Pos;
}
return bestPos;
}
/// <summary> 感知范围内最近的场上道具(v0.7.8.7:bot 会专程去捡,拾取即用) </summary>
Vector2? NearestPowerUpPos( Vector3 myPos )
{
var mgr = CircleroyaleGame.Current?.PowerUps;
if ( mgr is null || !mgr.Ready ) return null;
var best = float.MaxValue;
Vector2? bestPos = null;
var seek = GameConfig.BotSeekFoodRange * 0.75f * RangeScale();
var seekSq = seek * seek;
foreach ( var s in mgr.Slots )
{
if ( !s.Alive ) continue;
var dx = s.Pos.x - myPos.x;
var dy = s.Pos.y - myPos.y;
var dSq = dx * dx + dy * dy;
if ( dSq > seekSq || dSq >= best ) continue;
best = dSq;
bestPos = s.Pos;
}
return bestPos;
}
/// <summary> 感知范围内最值得的散落孢子(v0.7.8.7 起不再无视;v0.7.8.32:按质量折算距离 + 目标粘性) </summary>
CellPiece NearestBlob( Vector3 myPos )
{
var cells = CircleroyaleGame.Current?.Cells;
if ( cells is null || cells.Count == 0 ) return null;
CellPiece best = null;
var bestScore = float.MaxValue;
var seek = GameConfig.BotSeekFoodRange * RangeScale();
var seekSq = seek * seek;
// 粘性:还在追同一团(Id 未变)就打距离折扣
var stickyId = _sinceStickyBlob < 3f ? _stickyBlobId : -1;
foreach ( var c in cells )
{
if ( c.PieceKind != CellPiece.Kind.EjectedMass ) continue;
var dx = c.Pos.x - myPos.x;
var dy = c.Pos.y - myPos.y;
var dSq = dx * dx + dy * dy;
// 大孢子值得绕路:按质量折算等效距离(30 质量≈距离减半)
dSq /= 1f + c.Mass / 30f;
if ( c.Id == stickyId ) dSq *= 0.5f;
if ( dSq > seekSq || dSq >= bestScore ) continue;
bestScore = dSq;
best = c;
}
return best;
}
/// <summary>
/// 可追性检查(v0.7.8.95):猎物速度超过我方极速×余量且正在远离 → 永远追不上,
/// 不进猎物池(旧版会傻追十几秒,观感极差)。速度公式与移动层同式(质量越大越慢)。
/// 猎物朝我方向有速度分量(dot > 0)视为"正在远离"判定——它自己撞过来另说。
/// </summary>
bool CanCatch( Vector3 preyPos, Vector3 preyVel, Vector3 myPos )
{
var preySpeed = preyVel.Length;
if ( preySpeed < 1f ) return true; // 基本没动:肯定追得上
var mySpeed = SpeedOf( _self.Mass );
if ( preySpeed <= mySpeed * GameConfig.BotCatchSpeedMargin ) return true;
var toPrey = preyPos - myPos;
var len = toPrey.Length;
if ( len < 0.001f ) return true;
return Vector3.Dot( toPrey / len, preyVel ) <= 0f; // 在逃才放弃;迎面/横移照追
}
/// <summary> 该质量下的极速(与 Ball 移动层同式,追猎提前量计算早已在用) </summary>
float SpeedOf( float mass ) => GameConfig.BaseSpeed
* MathF.Pow( GameConfig.StartMass / MathF.Max( 1f, mass ), GameConfig.SpeedCurve );
/// <summary>
/// 停驻层的缓速盘旋(v0.7.8.103,"对峙卡住"的解药):绕锚点切向慢移(移动力 0.45),
/// 每只 bot 固定旋向。此前喂旗/喂大哥/炸草三处停驻都是 Vector2.Zero 站桩——两只质量
/// 相近的 bot(互相吃不动对方,谁也不触发谁的威胁/猎物判定)在同一锚点双双站桩
/// = 玩家看到的"对峙谁都不动"。盘旋后保持喂食位置(孢子照常命中锚点)又永远有动态。
/// </summary>
Vector2 OrbitDir( Vector3 center )
{
var to = new Vector2( _self.WorldPosition.x - center.x, _self.WorldPosition.y - center.y );
var len = to.Length;
if ( len < 0.001f ) return new Vector2( _orbitSign, 0f ) * 0.45f;
return new Vector2( -to.y, to.x ) / len * _orbitSign * 0.45f;
}
static Vector2 Norm( Vector3 v )
{
var v2 = new Vector2( v.x, v.y );
var len = v2.Length;
return len > 0.001f ? v2 / len : Vector2.Zero;
}
}