diff --git a/Assets/Tests/EditMode/Core.meta b/Assets/Tests/EditMode/Core.meta
new file mode 100644
index 00000000..05b7a034
--- /dev/null
+++ b/Assets/Tests/EditMode/Core.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ea96392988606754282c4f14deffc513
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/EditMode/Core/WeightedPickTests.cs b/Assets/Tests/EditMode/Core/WeightedPickTests.cs
new file mode 100644
index 00000000..d2f99280
--- /dev/null
+++ b/Assets/Tests/EditMode/Core/WeightedPickTests.cs
@@ -0,0 +1,98 @@
+using System.Collections.Generic;
+using NUnit.Framework;
+using BaseGames.Core;
+
+namespace BaseGames.Tests.EditMode.Core
+{
+ ///
+ /// WeightedPick 加权随机取索引的共享原语测试。
+ /// 该原语被敌人选招 / Boss 选招 / 战利品掉落共用,故对"绝不选中零权重项"等
+ /// 确定性性质做强断言(多次采样),避免任一消费端出现选到不合格项的缺陷。
+ ///
+ public class WeightedPickTests
+ {
+ static List W(params float[] w) => new List(w);
+
+ [Test]
+ public void Null_ReturnsMinusOne()
+ {
+ Assert.AreEqual(-1, WeightedPick.Index(null));
+ }
+
+ [Test]
+ public void Empty_ReturnsMinusOne()
+ {
+ Assert.AreEqual(-1, WeightedPick.Index(W()));
+ }
+
+ [Test]
+ public void AllZero_ReturnsMinusOne()
+ {
+ Assert.AreEqual(-1, WeightedPick.Index(W(0f, 0f, 0f)));
+ }
+
+ [Test]
+ public void AllNegative_ReturnsMinusOne()
+ {
+ // 负权重按 0 处理(非法输入不应造成负总和/错误索引)
+ Assert.AreEqual(-1, WeightedPick.Index(W(-1f, -5f)));
+ }
+
+ [Test]
+ public void SinglePositive_AlwaysThatIndex()
+ {
+ var w = W(0f, 3f, 0f);
+ for (int i = 0; i < 50; i++)
+ Assert.AreEqual(1, WeightedPick.Index(w));
+ }
+
+ [Test]
+ public void NeverPicksZeroWeight()
+ {
+ // 索引 0/2 权重为 0,无论抽多少次都不该被选中
+ var w = W(0f, 1f, 0f, 2f);
+ for (int i = 0; i < 200; i++)
+ {
+ int idx = WeightedPick.Index(w);
+ Assert.IsTrue(idx == 1 || idx == 3, $"选中了零权重索引 {idx}");
+ }
+ }
+
+ [Test]
+ public void NeverPicksNegativeWeight()
+ {
+ var w = W(-10f, 1f);
+ for (int i = 0; i < 200; i++)
+ Assert.AreEqual(1, WeightedPick.Index(w));
+ }
+
+ [Test]
+ public void AllPositive_EveryIndexReachable()
+ {
+ // 等权重下,足够多次采样应覆盖所有索引(验证不会恒定只返回首个/末个)
+ var w = W(1f, 1f, 1f);
+ var seen = new HashSet();
+ for (int i = 0; i < 500; i++) seen.Add(WeightedPick.Index(w));
+ CollectionAssert.AreEquivalent(new[] { 0, 1, 2 }, seen);
+ }
+
+ [Test]
+ public void HeavyWeight_DominatesDistribution()
+ {
+ // 权重 99:1 时,重权重索引应占绝大多数(宽松阈值,避免随机性造成偶发失败)
+ var w = W(99f, 1f);
+ int heavy = 0;
+ const int N = 1000;
+ for (int i = 0; i < N; i++) if (WeightedPick.Index(w) == 0) heavy++;
+ Assert.Greater(heavy, N * 0.9f, $"重权重仅被选中 {heavy}/{N} 次,分布不符合权重");
+ }
+
+ [Test]
+ public void WorksWithArray()
+ {
+ // IReadOnlyList 契约:数组同样可用(消费端可复用缓存数组,免 GC)
+ float[] w = { 0f, 5f };
+ Assert.AreEqual(1, WeightedPick.Index(w));
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Core/WeightedPickTests.cs.meta b/Assets/Tests/EditMode/Core/WeightedPickTests.cs.meta
new file mode 100644
index 00000000..925ae9c5
--- /dev/null
+++ b/Assets/Tests/EditMode/Core/WeightedPickTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4a561ea40b19539439c8426cf0ccfe2c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/Utils.meta b/Assets/_Game/Scripts/Core/Utils.meta
new file mode 100644
index 00000000..10357420
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Utils.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: a1b254de5372a8242b7447d1acefafd7
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs b/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs
new file mode 100644
index 00000000..21affd6b
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace BaseGames.Core
+{
+ ///
+ /// 加权随机取索引的共享原语(纯逻辑、无状态、可单测)。
+ ///
+ /// 项目中"按权重随机选一个"的场景(敌人选招 / Boss 选招 / 战利品掉落)此前各写了一份
+ /// 累加-掷点-回退的循环,语义相同却各自维护。统一到本处,消除平行实现。
+ ///
+ /// 调用方职责:把候选的**有效权重**填入一个列表/数组(不合格候选填 0),
+ /// 本原语只负责"按权重挑一个索引",不关心候选是什么、为何不合格。
+ /// 列表可由调用方缓存复用 → 选取过程零 GC 分配。
+ ///
+ public static class WeightedPick
+ {
+ ///
+ /// 按 加权随机返回一个索引。
+ /// 权重 ≤ 0 的项永不被选中(负权重按 0 处理);无任何正权重时返回 -1。
+ ///
+ public static int Index(IReadOnlyList weights)
+ {
+ if (weights == null) return -1;
+
+ int count = weights.Count;
+ float total = 0f;
+ for (int i = 0; i < count; i++)
+ {
+ float w = weights[i];
+ if (w > 0f) total += w;
+ }
+ if (total <= 0f) return -1;
+
+ float roll = Random.value * total;
+ float accum = 0f;
+ for (int i = 0; i < count; i++)
+ {
+ float w = weights[i];
+ if (w <= 0f) continue;
+ accum += w;
+ if (roll <= accum) return i;
+ }
+
+ // 浮点累加误差兜底:返回最后一个正权重项(绝不返回零权重项)
+ for (int i = count - 1; i >= 0; i--)
+ if (weights[i] > 0f) return i;
+ return -1;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs.meta b/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs.meta
new file mode 100644
index 00000000..28f27c93
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Utils/WeightedPick.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9c93d1e517eba4e4a9240dd443c0c992
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs
index d10ee61c..d69ef58e 100644
--- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs
+++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs
@@ -10,9 +10,14 @@ namespace BaseGames.Enemies.Abilities
public sealed class EnemyAttackSelector
{
private readonly List _candidates;
+ // 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC
+ private readonly List _weightBuf;
public EnemyAttackSelector(IEnumerable candidates)
- => _candidates = new List(candidates);
+ {
+ _candidates = new List(candidates);
+ _weightBuf = new List(_candidates.Count);
+ }
public int Count => _candidates.Count;
@@ -47,22 +52,19 @@ namespace BaseGames.Enemies.Abilities
private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded)
{
- float total = 0f;
+ // 有效权重:不合格候选填 0(由共享原语保证零权重项绝不被选中)。缓冲区复用 → 选招零 GC。
+ _weightBuf.Clear();
for (int i = 0; i < _candidates.Count; i++)
{
var c = _candidates[i];
- if (Eligible(c, hasLOS, grounded)) total += Mathf.Max(0f, c.Weight);
+ _weightBuf.Add(Eligible(c, hasLOS, grounded) ? Mathf.Max(0f, c.Weight) : 0f);
}
- if (total <= 0f) return SelectByPriority(hasLOS, grounded); // 权重全 0 → 退化为按 Priority 选(并列取首个)
- float roll = Random.value * total;
- for (int i = 0; i < _candidates.Count; i++)
- {
- var c = _candidates[i];
- if (!Eligible(c, hasLOS, grounded)) continue;
- roll -= Mathf.Max(0f, c.Weight);
- if (roll <= 0f) return c;
- }
- return null; // 理论不达(浮点边界兜底)
+
+ int idx = BaseGames.Core.WeightedPick.Index(_weightBuf);
+ if (idx >= 0) return _candidates[idx];
+
+ // 无正权重(合格候选权重全 0)→ 退化为按 Priority 选(并列取首个)
+ return SelectByPriority(hasLOS, grounded);
}
}
}
diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs
index 27d0d9f9..afbcfc5d 100644
--- a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs
+++ b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs
@@ -39,8 +39,9 @@ namespace BaseGames.Enemies
public int CurrentPhase => _currentPhase;
private Coroutine _counterStaggerCoroutine;
- // 缓存加权候选列表,避免 UseBossSkillWeighted() 每次 new List → GC 分配
- private readonly List<(BossSkillSO skill, float w)> _weightedCandidates = new(8);
+ // 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配
+ private readonly List _weightedCandidates = new(8);
+ private readonly List _candidateWeights = new(8);
// 单元素缓冲数组,供 ApplyCounterResponse 缓存当前技能,避免 new[] 分配
private readonly BossSkillSO[] _singleSkillBuf = new BossSkillSO[1];
@@ -110,31 +111,22 @@ namespace BaseGames.Enemies
// 筛选:在当前阶段可用 + 冷却就绪 + weight > 0
_weightedCandidates.Clear();
- float totalWeight = 0f;
+ _candidateWeights.Clear();
foreach (var s in skills)
{
if (s == null || s.weight <= 0f) continue;
if (!_skillExecutor.CanUseSkill(s.skillId)) continue;
if (!IsSkillAvailableInPhase(s)) continue;
+ _weightedCandidates.Add(s);
// 防重复:上一个技能权重打折
- float w = s.skillId == LastUsedSkillId ? s.weight * 0.3f : s.weight;
- _weightedCandidates.Add((s, w));
- totalWeight += w;
+ _candidateWeights.Add(s.skillId == LastUsedSkillId ? s.weight * 0.3f : s.weight);
}
- if (_weightedCandidates.Count == 0 || totalWeight <= 0f) return false;
-
- // 加权随机抽取
- float roll = UnityEngine.Random.Range(0f, totalWeight);
- BossSkillSO selected = null;
- float accum = 0f;
- foreach (var (skill, w) in _weightedCandidates)
- {
- accum += w;
- if (roll <= accum) { selected = skill; break; }
- }
- selected ??= _weightedCandidates[_weightedCandidates.Count - 1].skill;
+ // 加权随机抽取(共享原语:无正权重/无候选时返回 -1)
+ int idx = BaseGames.Core.WeightedPick.Index(_candidateWeights);
+ if (idx < 0) return false;
+ BossSkillSO selected = _weightedCandidates[idx];
if (!CheckResourceCost(selected)) return false;
diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs b/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs
index 5543ac2c..9be6312a 100644
--- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs
+++ b/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs
@@ -127,37 +127,6 @@ namespace BaseGames.Boss
/// Inspector 中注册的全部技能 SO(只读)。
public BossSkillSO[] Skills => _skills;
- ///
- /// 从候选技能列表中按 weight 加权随机选择一个技能。
- /// 权重为 0 的技能不参与选择;所有候选权重均为 0 时返回 null。
- ///
- public BossSkillSO SelectWeightedSkill(System.Collections.Generic.IList candidates)
- {
- if (candidates == null || candidates.Count == 0) return null;
-
- float totalWeight = 0f;
- for (int i = 0; i < candidates.Count; i++)
- {
- var s = candidates[i];
- if (s != null && s.weight > 0f) totalWeight += s.weight;
- }
- if (totalWeight <= 0f) return null;
-
- float roll = UnityEngine.Random.value * totalWeight;
- float acc = 0f;
- for (int i = 0; i < candidates.Count; i++)
- {
- var s = candidates[i];
- if (s == null || s.weight <= 0f) continue;
- acc += s.weight;
- if (roll <= acc) return s;
- }
- // 浮点精度兜底:返回最后一个有效候选
- for (int i = candidates.Count - 1; i >= 0; i--)
- if (candidates[i]?.weight > 0f) return candidates[i];
- return null;
- }
-
///
/// 执行一个 Boss 技能。若当前正在执行或技能冷却未就绪则返回。
///
diff --git a/Assets/_Game/Scripts/Enemies/LootResolver.cs b/Assets/_Game/Scripts/Enemies/LootResolver.cs
index 7f9d986b..f4c1dac6 100644
--- a/Assets/_Game/Scripts/Enemies/LootResolver.cs
+++ b/Assets/_Game/Scripts/Enemies/LootResolver.cs
@@ -33,32 +33,24 @@ namespace BaseGames.Enemies
bool isHard = dm != null &&
(int)dm.CurrentLevel >= (int)DifficultyLevel.Hard;
- float totalWeight = 0f;
- foreach (var entry in table.Entries)
+ // 有效权重只算一遍(难度加成),再交由共享原语加权抽取;无正权重时不掉落。
+ var weights = new float[table.Entries.Length];
+ for (int i = 0; i < table.Entries.Length; i++)
{
+ var entry = table.Entries[i];
float w = entry.BaseWeight;
if (isHard && entry.ScaleWithDifficulty) w *= 1.5f;
- totalWeight += w;
+ weights[i] = w;
}
- if (totalWeight <= 0f) return;
+ int idx = WeightedPick.Index(weights);
+ if (idx < 0) return;
- float roll = Random.Range(0f, totalWeight);
- float accum = 0f;
- foreach (var entry in table.Entries)
- {
- float w = entry.BaseWeight;
- if (isHard && entry.ScaleWithDifficulty) w *= 1.5f;
- accum += w;
- if (roll <= accum)
- {
- if (!string.IsNullOrEmpty(entry.ItemId))
- CollectibleSpawner.SpawnItem(worldPosition, entry.ItemId);
- else if (entry.LingZhuAmount > 0)
- CollectibleSpawner.SpawnLingZhu(worldPosition, entry.LingZhuAmount);
- return;
- }
- }
+ var picked = table.Entries[idx];
+ if (!string.IsNullOrEmpty(picked.ItemId))
+ CollectibleSpawner.SpawnItem(worldPosition, picked.ItemId);
+ else if (picked.LingZhuAmount > 0)
+ CollectibleSpawner.SpawnLingZhu(worldPosition, picked.LingZhuAmount);
}
private static void ApplyDifficultyLingZhuScale(ref int lingZhu)
diff --git a/Docs_Dev/superpowers/specs/2026-07-27-shared-primitives-decision.md b/Docs_Dev/superpowers/specs/2026-07-27-shared-primitives-decision.md
new file mode 100644
index 00000000..cee884ef
--- /dev/null
+++ b/Docs_Dev/superpowers/specs/2026-07-27-shared-primitives-decision.md
@@ -0,0 +1,92 @@
+# 小怪 / Boss 共享底座:抽取决策记录
+
+> 日期:2026-07-27 状态:已实施(Layer A),B/C 明确否决
+> 背景:小怪走 `EnemyAiBrain + PerceptionStateMachine + EnemyAbilityBase`,Boss 走
+> `BossBase + BossSkillExecutor + BossSkillSO`。两套决策/技能层并行演进,出现机制原语的平行实现。
+
+## 1. 结论摘要
+
+| 层 | 内容 | 决策 | 依据 |
+|---|---|---|---|
+| **A** | 加权随机取索引 | ✅ **已抽取** `BaseGames.Core.WeightedPick` | **4 份**平行实现,且已出现分歧 |
+| **B** | 冷却记账 | ❌ **否决** | 全项目冷却是**异构**的,符合该形态的只有 1 个消费者 |
+| **C** | HitBox 时序窗口 | ❌ **否决** | 仅 2 个**形态不同**的消费者,且两处均无测试覆盖 |
+
+核心原则:**只抽"两边同构且会漂移"的原语;不为"看起来像"的浅层重复付重构风险**。
+
+## 2. Layer A:已实施
+
+### 抽取前的 4 份平行实现
+1. `EnemyAttackSelector.SelectByWeight`(小怪选招)
+2. `BossBase.UseBossSkillWeighted`(Boss 选招,带上一招 0.3× 防重复惩罚)
+3. `BossSkillExecutor.SelectWeightedSkill`(**零调用者的死代码**,与 2 近似重复)
+4. `LootResolver.Resolve`(战利品掉落;**权重被算了两遍**——累加一遍、抽取时再算一遍,
+ 两处公式若改动不同步即产生分布偏差)
+
+### 抽取结果
+```csharp
+namespace BaseGames.Core
+{
+ /// 调用方把候选的"有效权重"填入列表(不合格候选填 0),本原语只负责按权重挑索引。
+ public static class WeightedPick { public static int Index(IReadOnlyList weights); }
+}
+```
+- 语义:权重 ≤ 0 的项**永不被选中**(负权重按 0 处理);无正权重返回 `-1`;含浮点累加误差兜底。
+- 放 `BaseGames.Core`(非 Combat)——因为消费者含**战利品掉落**,并非战斗专属。
+- 零 GC:调用方复用权重缓冲区(`EnemyAttackSelector._weightBuf`、`BossBase._candidateWeights`)。
+- 覆盖:10 条 EditMode 单测(含"绝不选中零/负权重项"200 次采样断言、权重分布断言)。
+- 顺带清理:删除死代码 `SelectWeightedSkill`;消除 `LootResolver` 权重重复计算。
+
+### 为何不做 `ISelectable` 接口 + `WeightedSelector`(初版草案)
+深入代码后否决:
+- `BossSkillSO` **没有** `priority` 字段(Boss 只用加权随机)→ 强加接口会产生无意义成员;
+- `LootTableSO.Entries` 的权重是**动态计算**的(难度加成),无法用静态接口属性表达;
+- 候选类型/合格判定两边完全不同,抽到接口层只会造出"上帝抽象"。
+→ 改为**更低层、更通用的 `Index(weights)`**,合格性与权重加成留在各自领域内。
+
+## 3. Layer B(冷却):否决理由
+
+全项目冷却实现是**异构**的,不存在可统一的单一形态:
+
+| 系统 | 形态 |
+|---|---|
+| `ParrySystem` / `DashState` / `ToolSlotManager` / `SkillManager` | 每帧递减的**倒计时器** |
+| `BossSkillExecutor` | `Dictionary` **截止时刻** |
+| `EnemyAbilityBase` | 单个 float **截止时刻**(每组件一个,判定仅 1 行) |
+
+符合"多 key 截止时刻字典"形态的**只有 `BossSkillExecutor` 一处**。为单一消费者建抽象=纯仪式;
+强行统一则要重写玩家闪避/弹反/道具/技能 4 个无关系统,风险与收益完全不成比例。
+
+## 4. Layer C(HitBox 时序窗口):否决理由
+
+两个消费者**形态本质不同**:
+- `MeleeAttackAbility.PlayAttackStep`:**归一化**(`hitBoxEnterT/ExitT` × duration)、**单** HitBox、逐帧 `Time.deltaTime` 推进、需跑满整段时长。
+- `BossSkillExecutor.ExecutePatternCoroutine`:**绝对秒数**(windup/active/recovery)、**多** HitBox 齐开齐关、`WaitForSeconds` 推进。
+- 玩家侧 `WeaponHitBoxInstance`(外部动画事件驱动开关)/ `SkillHitBoxInstance`(池化实例生命周期)是**另一种语义**,不是同一回事。
+
+叠加关键风险:**两处都无自动化测试覆盖**。重构等于在无测试保护下改动战斗手感时序,
+一个细微的时序回归难以发现。重复本身很浅(开/等/关约 5 行),不构成维护危害。
+→ 判定:**收益 < 风险,不做**。若将来出现第 3 个同形态消费者,可重新评估。
+
+## 5. 后续路线:Boss 决策层走 BrainGraph(延后,按需触发)
+
+**可行性高**:`BossBase` 已暴露决策所需 API(`UseBossSkillWeighted` / `IsBossSkillExecuting` /
+`IsHPBelow` / `CurrentPhase` / `BeginPhaseTransition` / `IsPhaseTransitioning`),
+只需一个 `IBossControl` facet 挂进 `IAiContext`(同 `ICombatant` 模式,保证 lambda 不捕获具体实例),
+`BossSkillExecutor` 的富执行层**原样保留**。
+
+**当前不必要**。触发条件(任一出现再做):
+- 出现第 2/3 个 Boss,或需要"精英怪 / mini-boss"中间形态(要混用小怪状态 + Boss 技能);
+- 要清理 BD(Behavior Designer)迁移债——现存 `StopBehaviorTree`、`BD_*` 注释残留;
+- 希望 Boss 也吃上小怪那套工具链(状态 Inspector、`TransitionRecord`、EditMode 测试)。
+
+**执行建议**:不要拿已能跑的 ChaoFeng 开刀验证;**等下一个新 Boss 时用 BrainGraph 从零搭**
+(决策走图 + `IBossControl`,执行仍用 `BossSkillExecutor`),验证顺畅后再回迁 ChaoFeng。
+
+**终态**:决策层统一 BrainGraph + 机制原语统一底座 + 执行层按复杂度分层
+(小怪 `EnemyAbilityBase` / Boss `BossSkillExecutor`)——既消重复,又保留 Boss 编排自由。
+
+## 6. 注记
+
+- `UseBossSkillWeighted` 目前**无调用者**(原 BD 任务已移除),是等待决策层接入的 Boss API,故保留。
+- 验证:编译 0 错误 0 警告;EditMode **177/177 通过**(原 167 + 新增 10)。