DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 12
STEP 01 / CONCEPT

先说结论

启发式打分把多个“不完全确定但有偏好”的条件变成同一尺度的分数,然后选择当前最高分候选。过滤负责排除绝对不能选的对象,打分只比较剩余对象,执行前还要再次验证世界状态。

分类:算法

先说结论

启发式打分把多个“不完全确定但有偏好”的条件变成同一尺度的分数,然后选择当前最高分候选。过滤负责排除绝对不能选的对象,打分只比较剩余对象,执行前还要再次验证世界状态。

三个候选怎样得出结果

怪物 距离分 朝向分 等待奖励 总分
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 或无穷大时要拒绝结果并记录每个分项。距离、等待时间等输入也要限制范围,避免某个量纲完全吞掉其他权重。

最重要的收获

先过滤,再评分,再选择,再执行。

这是一种启发式决策模型:

它不追求数学最优;
它追求在真实工程场景里可控、合理、好调、可解释。