补上池化路径的防重复记忆重置——ResetRepeatMemory 此前文档承诺了 「对象池复用时清除」却无任何调用点,而它要取代的 BossBase.LastUsedSkillId 恰好在 OnSpawn 里有重置。放 EnemyBase.OnSpawn 以覆盖全部池化敌人。 折扣系数改为每次选招前刷新,与 attackSelectionMode 的读取时机对齐—— 此前策划在播放模式下调系数无效、调模式却立即生效,容易误判为功能没生效。 默认值收敛为 EnemyAttackSelector.DefaultAntiRepeatFactor 单一常量。 测试:IneligiblePrevious 用例改用系数 0.5——原先用 0 时,把折扣错写在 eligibility 归零之前也照样通过,是个永远不会失败的测试;统计用例边界由 7σ 收紧到 5σ 使其真正验证该系数;补 ResetRepeatMemory 的确定性用例、 系数 1.0 无惩罚、三候选三条。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
5.0 KiB
C#
114 lines
5.0 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace BaseGames.Enemies.Abilities
|
|
{
|
|
/// <summary>
|
|
/// 多攻击选招器:从候选(category==Attack)里按"射程 + 冷却 + LOS + 着地"过滤,
|
|
/// 再按 <see cref="AttackSelectionMode"/> 选一个。纯逻辑、无 MonoBehaviour 依赖,可单测。
|
|
/// </summary>
|
|
public sealed class EnemyAttackSelector
|
|
{
|
|
/// <summary>防重复折扣系数的默认值。EnemyStatsSO 字段初值与 EnemyBase 的兜底分支共用此常量。</summary>
|
|
public const float DefaultAntiRepeatFactor = 0.3f;
|
|
|
|
private readonly List<IAttackCandidate> _candidates;
|
|
// 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC
|
|
private readonly List<float> _weightBuf;
|
|
// 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚
|
|
private float _antiRepeatFactor;
|
|
|
|
private IAttackCandidate _lastPicked;
|
|
|
|
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates,
|
|
float antiRepeatFactor = DefaultAntiRepeatFactor)
|
|
{
|
|
_candidates = new List<IAttackCandidate>(candidates);
|
|
_weightBuf = new List<float>(_candidates.Count);
|
|
AntiRepeatFactor = antiRepeatFactor; // 经属性赋值,夹紧逻辑只有一处
|
|
}
|
|
|
|
public int Count => _candidates.Count;
|
|
|
|
/// <summary>防重复折扣系数。与 attackSelectionMode 一样每次选招前由调用方刷新,
|
|
/// 使策划在播放模式下调参能立即生效。</summary>
|
|
public float AntiRepeatFactor
|
|
{
|
|
get => _antiRepeatFactor;
|
|
set => _antiRepeatFactor = Mathf.Clamp01(value);
|
|
}
|
|
|
|
/// <summary>上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。</summary>
|
|
public IAttackCandidate LastPicked => _lastPicked;
|
|
|
|
/// <summary>对象池复用 / 阶段切换时清除防重复记忆。</summary>
|
|
public void ResetRepeatMemory() => _lastPicked = null;
|
|
|
|
private static bool Eligible(IAttackCandidate c, bool hasLOS, bool grounded)
|
|
=> c != null && c.CanUse && c.InAttackRange()
|
|
&& (!c.RequiresLineOfSight || hasLOS)
|
|
&& (!c.RequiresGrounded || grounded);
|
|
|
|
public bool HasEligible(bool hasLOS, bool grounded)
|
|
{
|
|
for (int i = 0; i < _candidates.Count; i++)
|
|
if (Eligible(_candidates[i], hasLOS, grounded)) return true;
|
|
return false;
|
|
}
|
|
|
|
public IAttackCandidate Select(bool hasLOS, bool grounded, AttackSelectionMode mode)
|
|
{
|
|
switch (mode)
|
|
{
|
|
case AttackSelectionMode.Priority:
|
|
return Remember(SelectByPriority(hasLOS, grounded));
|
|
case AttackSelectionMode.WeightedRandomAntiRepeat:
|
|
return Remember(SelectByWeight(hasLOS, grounded, antiRepeat: true));
|
|
default:
|
|
return Remember(SelectByWeight(hasLOS, grounded, antiRepeat: false));
|
|
}
|
|
}
|
|
|
|
// 只在真选中了东西时更新记忆:选空时保留上一招,避免"空一帧就清掉防重复"。
|
|
private IAttackCandidate Remember(IAttackCandidate pick)
|
|
{
|
|
if (pick != null) _lastPicked = pick;
|
|
return pick;
|
|
}
|
|
|
|
private IAttackCandidate SelectByPriority(bool hasLOS, bool grounded)
|
|
{
|
|
IAttackCandidate best = null;
|
|
for (int i = 0; i < _candidates.Count; i++)
|
|
{
|
|
var c = _candidates[i];
|
|
if (!Eligible(c, hasLOS, grounded)) continue;
|
|
if (best == null || c.Priority > best.Priority) best = c; // 并列取首个
|
|
}
|
|
return best;
|
|
}
|
|
|
|
private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded, bool antiRepeat)
|
|
{
|
|
// 有效权重:不合格候选填 0(由共享原语保证零权重项绝不被选中)。缓冲区复用 → 选招零 GC。
|
|
_weightBuf.Clear();
|
|
for (int i = 0; i < _candidates.Count; i++)
|
|
{
|
|
var c = _candidates[i];
|
|
float w = Eligible(c, hasLOS, grounded) ? Mathf.Max(0f, c.Weight) : 0f;
|
|
// 防重复:上一招权重打折。折扣可把权重压到 0——此时若它是唯一候选,
|
|
// 下面的 Priority 退化路径会把它选出来,不会出现"敌人永远不出手"。
|
|
if (antiRepeat && w > 0f && ReferenceEquals(c, _lastPicked))
|
|
w *= _antiRepeatFactor;
|
|
_weightBuf.Add(w);
|
|
}
|
|
|
|
int idx = BaseGames.Core.WeightedPick.Index(_weightBuf);
|
|
if (idx >= 0) return _candidates[idx];
|
|
|
|
// 无正权重(合格候选权重全 0,或全被防重复折扣压平)→ 退化为按 Priority 选(并列取首个)
|
|
return SelectByPriority(hasLOS, grounded);
|
|
}
|
|
}
|
|
}
|