Buildcraft 是一个编译器问题
摘要
本文提出将 ARPG 构建视为编译器管线,其中创作者内容被编译为运行时数据,从而避免为技能-辅助交互编写特殊代码,并使用基于 Zig 的示例进行说明。
暂无内容
查看缓存全文
缓存时间: 2026/05/24 06:37
# Buildcraft 是一个编译器问题 — mitander@xyz
来源:https://mitander.xyz/posts/buildcraft-is-a-compiler-problem/
ARPG 的 buildcraft 看起来像是一个内容问题,直到各种组合开始堆积起来。
这里的例子摘自一个 Zig 编写的 ARPG 游戏引擎,其中技能、辅助宝石、物品和运行时规则都需要组合在一起。
起初,每条规则似乎都无害:
- 这个辅助宝石增加伤害
- 这个辅助宝石让投射物穿透
- 这个物品让法术伤害作用于近战
- 这个状态增加临时属性
- 这个词缀让怪物群获得更多速度
- 这个传奇物品改变一条规则
然后组合就出现了。
更大的范围的劈砍。更小范围但更高伤害的劈砍。附带流血效果的劈砍。双翼侧击的劈砍。一个具有穿透、连锁和分叉效果的投射物技能。一件物品说某个法术属性现在作用于一次近战攻击。一个状态临时改变了那个已经被物品和辅助宝石影响过的属性。
诱人的路径是一堆不断增长的特殊情况:
``
if 技能 == 劈砍 and 辅助 == 广域横扫:
让劈砍范围变大
if 技能 == 劈砍 and 辅助 == 集中锋刃:
让劈砍变小但更强
if 技能 == 劈砍 and 辅助 == 双刃劈砍 and 规则 == 护卫弧线:
悄悄移动到树林里
``
这在演示阶段可行。一旦游戏包含大量技能、辅助宝石、物品、状态和遭遇规则,就变得棘手了。
这里行之有效的框架是:
> 将 Buildcraft 视为一个小型编译器流水线。
创作内容作为源代码输入。辅助宝石、物品、状态、词缀和职业规则会发出事实(facts)。这些事实被折叠到派生缓存中。战斗消耗这些缓存。
在这种设计中,技能解析不应该问:“这是带有 3 号辅助槽的广域横扫的劈砍吗?”当技能解析发生时,那个问题已经变成了更底层的运行时数据:
``
increased_damage_bp = 500
area_radius_bonus_subunits = 1000
area_sweep_profile = 默认
status_payload_count = 0
more_multipliers = [...]
``
---
## 创作数据应保持无趣
一个辅助宝石定义不是可执行的游戏代码。在这种设计中,它是具有有限词汇表的数据。
support.zig
``
const support_modifier_max = constants.support_modifiers_max;
const support_behavior_max = constants.support_behaviors_max;
const ModifierSlots = [support_modifier_max]SupportModifier;
const BehaviorSlots = [support_behavior_max]SupportBehavior;
pub const SupportDef = struct {
scope: SupportScope,
modifiers: ModifierSlots = undefined,
modifier_count: u8 = 0,
behaviors: BehaviorSlots = undefined,
behavior_count: u8 = 0,
};
``
一个辅助宝石可以发出属性修正和行为改变。这就是它的边界。它不会直接访问投射物存储、调用战斗或修补世界中的随机字段。
这里是一个辅助宝石定义的示例:
support.zig
``
const wide_sweep_index = @intFromEnum(SupportId.cleave_wide_sweep);
table[wide_sweep_index] = add_behaviors(
make_def(.skill, &.{
.{
.stat = .damage_increased_bp,
.op = .increased_bp,
.damage_type = .physical,
.value = 500,
},
}),
&.{
.{
.kind = .area_radius_subunits,
.value = constants.subunits_per_unit,
.tag_require = TagMask.init(&.{ .melee, .area }),
},
},
);
``
将其视为内容,而非行为代码:
- 技能范围的物理伤害提高
- 一次行为发射
- 该行为是一个范围半径增量
- 它仅适用于标记为近战和范围的技能
这个辅助宝石可能被镶嵌在劈砍旁边,但发射的行为仍然使用内部可应用性标签。它说的是“近战 + 范围”,而不是“调用劈砍实现并改变其半径”。
未来的技能可以与现有的辅助规则配合工作,而无需手动将每个技能与每个辅助连接起来。它还保持了一个重要的区别:面向玩家的标签和运行时可应用性标签不必是同一回事。
---
## 辅助宝石编译成行
当技能槽发生变化时,旧的编译输出必须在新输出发射前消失。
support_rebuild.zig
``
const mask = skill_data.active_support_mask(skill);
clear_support_effects(modifier_store, behavior_store, entity_index, skill_slot);
generate_support_stat_modifiers(modifier_store, entity_index, skill_slot, skill, mask);
generate_support_behavior_emissions(
behavior_store,
entity_index,
skill_slot,
skill,
mask,
);
``
这一遍将装备的技能及其激活的辅助宝石转换为行:
``
激活的技能宝石 + 激活的辅助掩码
-> 属性修正行
-> 行为发射行
``
`active_support_mask` 很重要,因为插入的辅助宝石并不总是激活的。在这个代码库中,宝石等级控制哪些辅助槽位被解锁。我希望这个细节在单个编译器遍中处理,而不是分散在战斗代码中。
删除步骤和生成步骤同样重要。如果一个辅助被移除,而旧的行仍然存在,那么构建就会保留它不再拥有的力量。
---
## 行需要来源
流水线保留源身份。
support.zig
``
// 两个 ID 都编码了 skill_slot | support_slot。
// 技能范围的行设置高位,以便清理可以定位正确的行。
pub fn encode_entity_support_source_id(skill_slot: u8, support_slot: u8) u32 {
return (@as(u32, skill_slot) << 8) | @as(u32, support_slot);
}
pub fn encode_skill_support_source_id(skill_slot: u8, support_slot: u8) u32 {
return (1 << 15) | (@as(u32, skill_slot) << 8) | @as(u32, support_slot);
}
``
小东西,大重量。
一条修正行如果不仅说“+500 伤害提高”,还说明这个数字来自哪里:技能槽、辅助槽和作用域。后续系统可以利用它来移除正确的行,重建正确的缓存,并最终向测试或检查器解释结果。
没有来源,数学计算仍然可以成立,但系统无法回答更有意义的问题:
> 这个数字为什么在这里?
---
## 实体作用域和技能作用域是不同的通道
一些辅助宝石会改变整个实体。一些只改变被支持的技能。
这种区别被编译到修正行中:
support_rebuild.zig
``
const modifier_scope: StatScope = switch (def.scope) {
.entity => .entity,
.skill => .skill_specific,
};
// 技能范围的行以技能身份为键。
const scope_param: u16 = if (modifier_scope == .skill_specific)
@intFromEnum(skill.gem.skill_id)
else
0;
``
平淡无奇,直到出错。
如果一个劈砍辅助说“更多劈砍伤害”,那么冲刺不应该仅仅因为两个技能都装备了就继承这个效果。如果一个物品说“法术伤害作用于近战”,那是一个更广泛的规则,并通过不同的通道流动。
因此,流水线保持作用域明确:
``
实体作用域 -> 影响角色
技能特定 -> 影响一个技能身份
规则发射 -> 改变可应用性的解释方式
``
这就是编译器比喻持续有价值的地方:作用域、命名空间和重写,以非常小的形式存在。
---
## 行为发射携带形状,而非行为代码
属性行足够简单:增加伤害、提高生命值、应用倍数。
行为改变则更为混乱。穿透、连锁、分叉、额外投射物、范围半径、转换、状态载荷和宝石等级增量都是不同形状的变化。
因此,它们通过单独的发射类型,并折叠到技能缓存中:
modifier.zig
``
const more_multiplier_max = 4;
const status_payload_max = constants.skill_support_status_payloads_max;
const MoreMultipliers = [more_multiplier_max]i32;
const StatusPayloadSlots = [status_payload_max]SupportStatusPayload;
pub const SkillCacheEntry = extern struct {
increased_damage_bp: i32 = 0,
more_multipliers: MoreMultipliers = .{0} ** more_multiplier_max,
more_count: u8 = 0,
pierce_count: u8 = 0,
chain_count: u8 = 0,
fork_count: u8 = 0,
extra_projectile_count: u8 = 0,
convert_override: ConvertOverride = .{},
area_radius_bonus_subunits: i32 = 0,
status_payloads: StatusPayloadSlots = .{.{}} ** status_payload_max,
status_payload_count: u8 = 0,
effective_gem_level: u8 = 0,
gem_level_delta: i8 = 0,
area_sweep_profile: AreaSweepProfile = .default,
};
``
缓存条目是技能槽的运行时摘要。
它不回答“哪些辅助宝石被插入了?”或“是哪个物品导致了这个?”这些信息存在于上游的行中。缓存回答的是解析关心的问题:
``
伤害修正
投射物行为计数
转换覆盖
范围半径增量
状态载荷
有效宝石等级
``
这种分离很重要。战斗消耗摘要。检查和清理仍可使用源行。
---
## 脏域使重建保持边界
模拟不应仅仅因为可以就每个滴答重建所有派生事实。变异标记脏域,然后重建遍处理受影响的实体。
stats_rebuild.zig
``
const domains = dirty_domains_for_entity(world, entity_index);
const rebuild_skill_cache = modifier_store.is_skill_cache_dirty(entity_index) or
behavior_store.is_dirty(entity_index) or
scope_rewire_store.is_dirty(entity_index);
if (!domains.any() and !rebuild_skill_cache) {
modifier_store.clear_dirty(entity_index);
behavior_store.clear_dirty(entity_index);
scope_rewire_store.clear_dirty(entity_index);
runtime_rule_store.clear_dirty(entity_index);
continue;
}
rebuild_entity(
modifier_store,
behavior_store.span(entity_index),
scope_rewire_store.span(entity_index),
runtime_rule_store.span(entity_index),
entities,
entity_index,
domains,
rebuild_skill_cache,
);
``
这是微缩版的增量编译。
一个辅助宝石改变了?将技能缓存标记为脏。一个防御属性改变了?重建防御。一个运行时规则改变了?重建规则。什么都没有改变?清除过时标志并继续。
部分是为了性能,部分是为了所有权。变异指明了它使何种派生状态失效。重建遍在意图和战斗读取之前,在已知阶段完成派生工作。
使用属性的系统不需要问“有人更新了这个吗?”重建阶段使这一点成立。
---
## 标签是可应用性过滤器,而非技能矩阵
一旦行存在,行为重建遍将它们折叠到每个技能缓存中。
behavior_rebuild.zig
``
reset_skill_cache(cached);
for (skill_slots.skills, 0..) |skill, slot| {
if (skill.is_empty()) continue;
const skill_id = skill.gem.skill_id;
const skill_tags = catalog.skill.def(skill_id).tag_mask;
const entry = &cached.skill_cache[slot];
entry.effective_gem_level = skill.gem_level;
// 将行折叠到槽位本地的运行时摘要中。
fold_skill_damage_modifiers(...);
fold_entity_applicable_damage_modifiers(...);
fold_behavior_emissions(behaviors, slot, skill_tags, entry);
apply_runtime_skill_rules(skill_id, cached.rules, entry);
finalize_effective_gem_level(entry, skill.gem_level);
}
``
缓存从行重建。而不是因为一个旧辅助曾经接触过某个字段就打补丁,重建遍清空摘要并重新折叠当前事实。
可应用性是基于标签的:
``
pub fn tags_match(skill_tags: TagMask, require: TagMask, exclude: TagMask) bool {
const skill_bits = skill_tags.bits();
const require_bits = require.bits();
const exclude_bits = exclude.bits();
assert((require_bits & exclude_bits) == 0);
const has_required = (skill_bits & require_bits) == require_bits;
const has_excluded = (skill_bits & exclude_bits) != 0;
return has_required and !has_excluded;
}
``
如果正在修改的运行时事物具有所需标签且没有排除标签,则行为发射适用。在上面的片段中,那是技能的目录标签。对于更复杂的技能,同样的想法可以向下移动到递送或载荷标签级别。
这让内容可以说:
``
需要 近战 + 范围
需要 攻击 + 近战 + 物理
排除 冰冷
``
这避免了巨大的技能/辅助身份矩阵。当辅助应用于错误的事物时,它也给了我一个明显的地方去检查:发出的要求错误、目标标签错误、或者可应用性规则错误。
棘手的部分是标签的粒度。一个宝石可以授予一个技能,该技能包含多个部分:一个近战命中、一个投射物、一个爆炸、一个异常状态载荷。宝石上的单个显示标签可能不够精确,无法决定每个修正交互。关键的标签是附加在被修改事物上的标签。
仍然有足够的空间出现错误,但至少错误有有限的词汇量。
---
## 规则重写与属性计算分离
一些构建效果不是属性。它们改变其他事实应如何被解释。
示例形式:“法术伤害作用于近战”。不是一个平直的伤害数值。它改变了一个仅法术修正是否可以应用于近战技能。
行为重建有一个小地方处理这个:
``
const direct_match = stat_applicability_matches(skill_tags, m.applicability);
const rewire_match = entity_rules.spell_damage_applies_to_melee and
skill_tags.melee and m.applicability == .spell_only;
if (!direct_match and !rewire_match) continue;
``
源事实不会改变每个近战技能。它不会将法术修正克隆到攻击修正中。它发射一条规则。缓存重建在折叠可应用的伤害修正时解释该规则。
在当前代码库中,这种规则可以来自创作的物品效果、职业树效果或印记效果。奇怪之处保持在一个层面,而不是扩散到每个技能实现中。
---
## 解析消耗编译后的事实
当技能解析时,投射物路径从缓存开始。
resolution.zig
``
const skill_cache_entry = cached.skill_cache[use_skill.action_slot];
const caster_ctx = build_caster_context(use_skill.caster, cached);
delivery_projectile.execute(
skill_id,
skill_preview.def,
caster_ctx,
spatial_ctx,
skill_preview.gem_level,
cached,
skill_cache_entry,
&world.projectiles,
);
``
投射物递送随后消耗它关心的字段:
delivery_projectile.zig
``
const projectile_count = resolve_projectile_spawn_count(skill_cache_entry);
const increased_damage_bp = payload.saturating_add_i32(
cached_stats.damage_increased_bp,
skill_cache_entry.increased_damage_bp,
);
``
当它分配投射物时:
delivery_projectile.zig
``
_ = projs.allocate(.{
.source = caster_ctx.id,
.damage = resolved_payload.base_damage,
.more_multipliers = skill_cache_entry.more_multipliers,
.more_count = skill_cache_entry.more_count,
.pierce_remaining = resolve_projectile_pierce(skill_cache_entry),
.chain_remaining = resolve_projectile_chain(skill_cache_entry),
.fork_remaining = resolve_projectile_fork(skill_cache_entry),
.convert_override = resolve_projectile_convert(skill_cache_entry),
}) catch continue;
``
注意这个层面大多缺少的是什么:辅助宝石 ID。
投射物递送不需要关心穿透是来自辅助宝石、物品、状态还是未来的神龛。它得到 `pierce_remaining`。
源归属仍然存在于上游,用于清理、检查、调试和测试。热路径消耗编译后的事实。
---
## 有边界的异常
内容契约中有上限:
constants.zig
``
pub const support_slots_max: u8 = 9;
pub const support_modifiers_max: u8 = 3;
pub const support_behaviors_max: u8 = 1;
``
行为缓存也有固定的载荷槽位、固定的更多倍数存储和固定的投射物行为计数。
确切的上限是游戏特定的。有用的部分是限制是可见的。一个辅助宝石可以很有趣,但在这个版本中,它不能发射无限数量的行为。一个技能可以携带<|im_end|>
相似文章
以Zig风格构建你的项目
作者详细介绍了构建一个名为bygge-zig的工具,该工具使用Zig构建系统来编译Rust项目,用更少的代码行复制了Cargo的功能,并突出了其中的差异和挑战。
编译器、构建系统及其之外的通信机制
探讨编译器与构建系统如何更高效地通信,审视它们之间乃至更广泛范围内的接口与协调机制。
构建系统重构
Zig 构建系统已经重构,将配置器和制造器进程分离,支持缓存、发布模式编译,并且'zig build'命令速度提升高达90%。这一变化提高了性能,并允许构建系统在不减速的情况下增加功能。
关于编译器构建的观点
本文分享了关于现代编译器构建的个人观点,主张使用如OhmJS进行解析和Prolog进行类型检查的工具,重点强调简洁性和迭代开发。
用 Zig 写一个 C 编译器
一位开发者记录了用 Zig 语言、按照 Nora Sandler 的教程系列构建名为 paella 的 C 编译器的全过程。