DYNAMIC EXPLAINER / ARTICLE FLOW

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

01 / 14
STEP 01 / CODE / LOGIC

先说结论

Flags 用一个整数的不同二进制位保存多个互不排斥的真假状态。按位或负责添加,按位与负责检查,按位与反码负责移除;读懂它的关键是观察每一位,而不是把整数当普通数值。

Poisoned = 0001
Stunned  = 0010
Invincible = 0100

初始:0000
添加 Poisoned:0001
再添加 Stunned:0011
检查 Invincible:0011 & 0100 = 0000,结果 false
移除 Poisoned:0011 & 1110 = 0010

分类:游戏逻辑常用模式 / CSharp 基础数据结构

先说结论

Flags 用一个整数的不同二进制位保存多个互不排斥的真假状态。按位或负责添加,按位与负责检查,按位与反码负责移除;读懂它的关键是观察每一位,而不是把整数当普通数值。

四位状态怎样变化

Poisoned = 0001
Stunned  = 0010
Invincible = 0100

初始:0000
添加 Poisoned:0001
再添加 Stunned:0011
检查 Invincible:0011 & 0100 = 0000,结果 false
移除 Poisoned:0011 & 1110 = 0010

最终整数是 2,但业务含义不是“状态等于 2”,而是第二位开启,即角色仍处于 Stunned。

核心思想

位标记的核心是:

用一个整数里的不同二进制位,表示多个可以同时存在的开关。

例如:

CanMove
CanAttack
Invincible
Stunned
Silenced
Selectable

这些状态有些可以同时存在。角色可以同时是:

Stunned + Invincible
Silenced + CanMove
Selectable + CanAttack

如果用很多 bool,字段会越来越散:

bool isPrecision;
bool isCritical;
bool isBlock;
bool isMiss;
bool isHeal;

用 Flags 可以集中成一个字段:

AttributeAttackEffect effects;

跟着二进制位一步步运行

先用四个状态观察每一位怎样变化:

using System;

[Flags]
enum UnitState
{
    None       = 0,      // 0000
    CanMove    = 1 << 0, // 0001
    CanAttack  = 1 << 1, // 0010
    Stunned    = 1 << 2, // 0100
    Invincible = 1 << 3  // 1000
}

从空状态开始添加两个标记:

UnitState state = UnitState.None; // 0000
state |= UnitState.CanMove;       // 0001
state |= UnitState.Invincible;    // 1001

Console.WriteLine(state); // CanMove, Invincible

| 把对应位置设为 1,同时保留其他位:

0001  CanMove
1000  Invincible
----
1001  两个状态同时存在

第二步:判断一个或多个标记

bool canMove = (state & UnitState.CanMove) != 0;

UnitState required = UnitState.CanMove | UnitState.Invincible;
bool hasAll = (state & required) == required;
bool hasAny = (state & required) != 0;

代入 state = 1001

1001 & 0001 = 0001,不为 0        -> 包含 CanMove
1001 & 1001 = 1001,等于 required -> 同时包含两个要求

判断“全部包含”要比较 == required;判断“至少包含一个”才使用 != 0

第三步:只移除 Stunned

state |= UnitState.Stunned;  // 1101
state &= ~UnitState.Stunned; // 1001

~Stunned 会得到除了 Stunned 位以外全是 1 的掩码;再用 &,只把目标位清零,CanMoveInvincible 保持不变。

第四步:把位判断用于技能入口

static bool CanCast(UnitState state)
{
    bool blocked = (state & UnitState.Stunned) != 0;
    bool canAttack = (state & UnitState.CanAttack) != 0;
    return !blocked && canAttack;
}

用具体输入验证:

state 二进制 CanCast
CanAttack 0010 true
CanAttack + Stunned 0110 false
CanMove 0001 false

这时 Flags 不再只是位运算语法,而是一条完整业务链:声明独立位、组合状态、查询要求、清除状态,再把查询结果用于技能规则。

基础声明

以攻击结果为例,可以这样声明标记:

[Flags]
public enum AttributeAttackEffect
{
    None = 0,
    Normal = 1 << 0,
    Precision = 1 << 1,
    Block = 1 << 2,
    Critical = 1 << 3,
    Miss = 1 << 4,
    Heal = 1 << 5,
}

1 << n 表示把数字 1 往左移动 n 位。

可以这样理解:

Normal    = 000001
Precision = 000010
Block     = 000100
Critical  = 001000
Miss      = 010000
Heal      = 100000

每一个标记都占一个独立位置,所以它们可以组合到同一个变量里。

添加标记

添加标记用按位或:

effects |= AttributeAttackEffect.Precision;
effects |= AttributeAttackEffect.Critical;

|= 的含义是:

打开这个标记位,同时保留原本已经打开的其他标记。

例如:

Precision = 000010
Critical  = 001000

组合后    = 001010

这就表示一次攻击同时带有:

精准 + 暴击

判断标记

判断有没有某个标记,用按位与:

if ((effects & AttributeAttackEffect.Critical) == AttributeAttackEffect.Critical)
{
    // 有暴击标记
}

& 的含义是:

只保留两边都为 1 的位。

例如:

effects  = Precision + Critical = 001010
Critical =                      001000

001010
&001000
=001000

结果等于 Critical,说明 effects 里面包含暴击。

也可以把判断封装进结果对象:

public bool HasEffect(AttributeAttackEffect effect)
{
    return (Effects & effect) == effect;
}

移除标记

移除标记常用:

flags &= ~SomeFlag;

其中:

~SomeFlag:把 SomeFlag 这一位取反。
&=:保留其他位,关掉目标位。

射线检测层也常用同样的位清除写法:

int fallbackMask = Physics.DefaultRaycastLayers & ~GetPreferredGroundLayerMask();

意思是:

从默认射线检测层里,排除掉已经优先检测过的地面层。

互斥状态为什么也能放在 Flags 里

注意:Flags 只是存储和传递多个标签的方式,不负责保证业务规则。

例如攻击结果里:

Miss 和 Critical 通常互斥。
Miss 和 Block 通常互斥。
Precision 和 Critical 可以叠加。

这些互斥关系不是由 Flags 自动保证的,而是由战斗计算流程保证。

例如一旦判定成 Miss,计算器可以直接返回:

return new AttributeAttackResult(
    AttributeAttackEffect.Miss,
    Array.Empty<AttributeDamageEntry>(),
    ...);

这样就不会继续添加 CriticalBlock 等标记。

所以这里是一个混合模型:

Flags 负责统一携带标签。
计算流程负责保证哪些组合合法。

更严格的设计也可以拆成:

AttackOutcome outcome;       // Hit / Miss / Block
AttackModifiers modifiers;   // Critical / Precision / Heal

但如果更需要把结果轻量传给表现层,一个 Flags 字段也很方便。

最大好处

最大的好处不是省内存,而是:

把一次结果里可能带的多个信息压成一个字段,传递和判断都很方便。

不用到处传:

bool isPrecision;
bool isCritical;
bool isBlock;
bool isMiss;
bool isHeal;

只传:

AttributeAttackEffect effects;

表现层需要什么就判断什么:

if ((effects & AttributeAttackEffect.Critical) == AttributeAttackEffect.Critical)
{
    mapped |= CombatPresentationEffect.Critical;
}

新增一个表现标签时,通常只需要扩展 enum 和映射逻辑,不需要修改一堆构造参数。

最多支持多少位

Flags 能支持多少个标记,取决于底层类型。

C# 的 enum 默认底层类型是 int,也就是 32 位。

常见建议:

int:常用 0 到 30 位,约 31 个常规标记。
uint:可以更舒服地用满 32 位。
long:常用 63 个常规标记。
ulong:可以用满 64 位。

如果需要更多标记,可以指定底层类型:

[Flags]
public enum UnitState : ulong
{
    None = 0,
    CanMove = 1UL << 0,
    CanAttack = 1UL << 1,
    Stunned = 1UL << 2,
    Silenced = 1UL << 3,
    Invincible = 1UL << 4,
}

这里要写 1UL << n,因为底层类型是 ulong

如果还写 1 << n,左边仍然是 int,超过 31 位后容易出问题。

适合什么

适合:

多个开关互相独立
一个对象可以同时拥有多个状态
需要频繁判断、组合、排除状态
状态数量不太多
需要轻量传递或保存结果标签

例如:

战斗表现标签
角色能力开关
Unity LayerMask
UI 控件状态
编辑器选项
权限开关

不适合什么

不适合:

严格互斥且永远只会有一个值的状态
需要携带复杂数据的状态
需要记录来源、持续时间、层数的 Buff
需要策划动态扩展很多类型的状态

例如:

Idle / Move / Attack / Dead

这种互斥主状态更适合普通 enum 或状态机。

而:

眩晕 Buff 的来源、剩余时间、叠加层数

就不该只靠一个 Stunned 标记解决。

常用模板

// 添加
flags |= SomeFlag;

// 判断
bool hasFlag = (flags & SomeFlag) == SomeFlag;

// 移除
flags &= ~SomeFlag;

// 多个组合
var combined = FlagA | FlagB;

最重要的收获

Flags 是把多个 bool 式开关压进一个字段里,用位运算快速组合、判断、排除。

它适合表达“多标签结果”,但不自动保证业务合法性。

真正的规则仍然要靠业务流程维护:

哪些标记可以叠加。
哪些标记必须互斥。
哪些标记出现后应该提前返回。