把章节变成可单步观察的过程
分类:算法
先说结论
启发式打分把多个“不完全确定但有偏好”的条件变成同一尺度的分数,然后选择当前最高分候选。过滤负责排除绝对不能选的对象,打分只比较剩余对象,执行前还要再次验证世界状态。
三个候选怎样得出结果
| 怪物 | 距离分 | 朝向分 | 等待奖励 | 总分 |
|---|---|---|---|---|
| A | 80 | 10 | 0 | 90 |
| B | 60 | 20 | 20 | 100 |
| C | 95 | 0 | 0 | 95 |
B 虽然不是最近的,但等待奖励让它得到 100 分并获选。若 B 在真正执行前已经死亡或失去攻击槽位,系统必须重新选择,不能因为旧分数最高就强行执行。
解决的问题
一群怪物都想攻击玩家,但不能所有怪一起攻击。
系统要决定:
这一刻,谁最适合拿到攻击机会?
如果直接随机挑一个,会出现很多问题:
远处怪可能抢到攻击机会
同一只怪可能连续攻击
近战、远程、Boss、小怪节奏混乱
玩家体验像被乱打
所以代码没有直接随机选择,而是做成了一条决策流水线:
刷新成员状态
收集攻击请求
释放已完成令牌
过滤候选
给候选打分
选择最高分
发放攻击令牌
给没拿到令牌的怪分配非攻击行为
跟着代码选出一次攻击者
先准备三个具体候选。把类型放在 Candidate.cs:
using System;
record Candidate(
string Name,
float Distance,
float WaitSeconds,
bool CanAttack,
bool AttackedLastTime);
再在 Program.cs 构造本轮候选快照:
using System.Collections.Generic;
List<Candidate> candidates = new()
{
new("A", Distance: 2f, WaitSeconds: 1f, CanAttack: true, AttackedLastTime: true),
new("B", Distance: 4f, WaitSeconds: 5f, CanAttack: true, AttackedLastTime: false),
new("C", Distance: 1f, WaitSeconds: 8f, CanAttack: false, AttackedLastTime: false)
};
第一步:先过滤,不能靠低分代替非法
List<Candidate> valid = candidates.FindAll(candidate =>
candidate.CanAttack && candidate.Distance <= 5f);
结果是:
A 保留:可以攻击,距离 2
B 保留:可以攻击,距离 4
C 删除:CanAttack = false
即使 C 距离最近、等待最久,它也不能进入打分阶段。过滤表达硬规则,打分只比较合法候选之间的偏好。
第二步:把每个分项单独算出来
static float Score(Candidate candidate)
{
float distanceScore = (5f - candidate.Distance) * 10f;
float waitScore = candidate.WaitSeconds * 3f;
float repeatPenalty = candidate.AttackedLastTime ? 25f : 0f;
return distanceScore + waitScore - repeatPenalty;
}
代入数值:
A:距离分 (5-2)*10 = 30,等待分 1*3 = 3,连续攻击惩罚 25
总分 = 30 + 3 - 25 = 8
B:距离分 (5-4)*10 = 10,等待分 5*3 = 15,无连续攻击惩罚
总分 = 10 + 15 = 25
A 更近,但刚攻击过;B 等得更久,因此 B 获胜。分项日志能解释结果,而只记录“B 分数 25”很难排查权重问题。
第三步:单次遍历选最高分
Candidate? winner = null;
float bestScore = float.NegativeInfinity;
foreach (Candidate candidate in valid)
{
float score = Score(candidate);
Console.WriteLine($"{candidate.Name}: {score}");
if (score > bestScore)
{
winner = candidate;
bestScore = score;
}
}
Console.WriteLine($"攻击令牌 -> {winner?.Name ?? "无人"}");
输出:
A: 8
B: 25
攻击令牌 -> B
第四步:执行前再检查一次
打分和真正发放令牌之间,B 可能被控制或死亡:
if (winner is null || !winner.CanAttack)
{
// 本轮不发令牌,下一调度 Tick 重新收集候选。
return;
}
GrantAttackToken(winner);
教学记录里的 Candidate 是不可变快照,真实项目应在发放前通过实体 ID 查询最新状态,而不是相信旧快照。后文的距离分、等待分、随机分和各种惩罚项,都是在这条“过滤 -> 分项打分 -> 选最大值 -> 执行前复验”的代码链上增加新因素。
核心模型
这类问题可以抽象为:
多个对象都可以做某件事,但资源有限,只能选一个或少数几个。
通用解法是:
1. Filter:谁有资格?
2. Score:谁更合适?
3. Select:谁分最高?
4. Guard:现在真的能执行吗?
5. Execute:执行并记录历史,影响下一轮决策。
这个模型比一坨 if else 更好,因为它把复杂判断分层了:
资格问题归资格问题
偏好问题归偏好问题
公平问题归公平问题
执行条件归执行条件
第一步:过滤候选
过滤不是选最好的,而是先排除“不应该参与竞争”的对象。
怪物集群里会排除:
成员不存在
已经有攻击令牌
已经有攻击许可
目标无效
技能列表为空
角色不受集群控制
没有 AI 接口
技能 ID 非法
当前 AI 认为技能不可释放
这一步的思维是:
先做资格审查,再做优劣比较。
很多逻辑 bug 就来自于:非法对象还没排除,就开始参与排序、评分或执行。
第二步:候选打分
核心评分模型可以写成:
最终分 =
距离优势
+ 等待补偿
+ 类型偏好
+ 少量随机
- 最近攻击过的惩罚
- 最近同角色攻击过的惩罚
评分公式可以写成:
candidate.Score =
distanceScore * Config.ScoreDistanceWeight +
waitScore * Config.ScoreWaitTimeWeight +
typeScore * Config.ScoreTypeWeight +
randomScore * Config.ScoreRandomWeight -
recentGrantPenalty -
recentRolePenalty;
距离分
距离越近,分越高:
distanceScore = 1 / distance
例如:
距离 1 米:1 / 1 = 1
距离 2 米:1 / 2 = 0.5
距离 5 米:1 / 5 = 0.2
这种设计不是线性扣分,而是让近距离优势更明显,远距离差异逐渐变小。
等待分
等待越久,分越高,但有上限。
它解决公平性:
一个怪等太久了,应该逐渐获得更高机会。
但不能无限叠高,避免它压倒所有策略。
类型分
Boss、近战、远程、群怪可以配置不同分数。
它让策略可调:
Boss 更积极
远程更保守
近战更频繁
群怪穿插攻击
随机分
少量随机不是为了让系统失控,而是为了打破僵局。
如果完全没有随机,游戏行为会很机械:
每次都是同一只最近的怪攻击
玩家很容易看出规律
少量随机能让行为更自然。
最近攻击惩罚
刚攻击过的怪,短时间内扣分。
它避免同一只怪连续拿到攻击机会。
最近角色惩罚
如果刚刚是远程怪攻击过,短时间内远程角色整体扣分。
这控制的是“角色节奏”:
远程怪刚攻击过
场上还有近战候选
下一次就更倾向给近战
这样战斗节奏会更丰富。
第三步:选择最高分
选择阶段遍历所有候选,保留最高分。
其中有一个很好的公平性细节:
如果存在最近没攻击过的候选,就跳过最近攻击过的候选。
如果所有候选都刚攻击过,就不跳过,避免系统卡死。
这是一种温和的公平策略:
能轮换就轮换;
不能轮换也别卡死。
第四步:执行前全局检查
选出来的候选不一定马上攻击,还要检查:
是否有候选
攻击名额是否还有
攻击节奏冷却是否到了
所以逻辑不是:
谁分高谁立刻打
而是:
谁分高,谁获得下一张攻击票。
但票的数量和发票频率受控制。
这就是令牌控制思维。
简化例子
假设有 3 只怪:
A:近战,距离 1 米,刚攻击过
B:近战,距离 2 米,等了 2 秒
C:远程,距离 5 米,等了 4 秒
粗略打分:
A:距离分高,但最近攻击惩罚大
B:距离还行,等待分不错
C:距离低,但等待久,类型可能加分
最后可能不是 A 赢,而是 B 或 C 赢。
这就是多因素平衡:
不是单因素决策,而是综合多个可解释因素。
可迁移场景
这个模型可以迁移到很多系统:
AI 行为选择
技能自动释放
任务调度
推荐系统
UI Tips 显示策略
服务器匹配队列
战斗仇恨目标选择
系统通知选择
当你面对“多个对象都想执行,但只能选一个或少数几个”时,可以问:
1. 哪些对象有资格?
2. 每个对象的优势如何量化?
3. 哪些因素要加分?
4. 哪些情况要扣分?
5. 有没有公平性或冷却约束?
6. 最后执行前还要不要检查全局条件?
边界:候选为空、同分和异常分数
没有合法候选不是错误,也不能退回未过滤列表随机选一个:
if (valid.Count == 0)
return DecisionResult.NoCandidate;
两个候选同分时必须有稳定规则,例如等待更久者优先,再以实体 ID 作为最终顺序。否则容器的偶然遍历顺序会让结果在不同机器上变化。
if (!float.IsFinite(score))
return DecisionResult.InvalidScore(candidate.Name);
权重产生 NaN 或无穷大时要拒绝结果并记录每个分项。距离、等待时间等输入也要限制范围,避免某个量纲完全吞掉其他权重。
最重要的收获
先过滤,再评分,再选择,再执行。
这是一种启发式决策模型:
它不追求数学最优;
它追求在真实工程场景里可控、合理、好调、可解释。