From 3d85f6596548a82dee3fe0da0ce2c2079385cc7b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 27 Jul 2026 09:54:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=20WeightedPick?= =?UTF-8?q?=20=E5=8A=A0=E6=9D=83=E9=9A=8F=E6=9C=BA=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=8E=9F=E8=AF=AD(+10=20=E5=8D=95=E6=B5=8B),=E4=B8=BA=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E5=9B=9B=E5=A4=84=E5=B9=B3=E8=A1=8C=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=81=9A=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/EditMode/Core/WeightedPickTests.cs | 98 +++++++++++++++++++ .../_Game/Scripts/Core/Utils/WeightedPick.cs | 51 ++++++++++ 2 files changed, 149 insertions(+) create mode 100644 Assets/Tests/EditMode/Core/WeightedPickTests.cs create mode 100644 Assets/_Game/Scripts/Core/Utils/WeightedPick.cs 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/_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; + } + } +}