Buildcraft 是一个编译器问题

Hacker News Top 论文

摘要

本文提出将 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风格构建你的项目

Lobsters Hottest

作者详细介绍了构建一个名为bygge-zig的工具,该工具使用Zig构建系统来编译Rust项目,用更少的代码行复制了Cargo的功能,并突出了其中的差异和挑战。

构建系统重构

Lobsters Hottest

Zig 构建系统已经重构,将配置器和制造器进程分离,支持缓存、发布模式编译,并且'zig build'命令速度提升高达90%。这一变化提高了性能,并允许构建系统在不减速的情况下增加功能。

关于编译器构建的观点

Lobsters Hottest

本文分享了关于现代编译器构建的个人观点,主张使用如OhmJS进行解析和Prolog进行类型检查的工具,重点强调简洁性和迭代开发。

用 Zig 写一个 C 编译器

Hacker News Top

一位开发者记录了用 Zig 语言、按照 Nora Sandler 的教程系列构建名为 paella 的 C 编译器的全过程。