Bevy ARPG 战斗系统 ECS 架构
为 ZoOL 的 Bevy 等距 ARPG 项目设计的战斗系统 ECS 架构指南。综合伤害公式、技能系统、角色系统的设计决策,提供可直接实现的 Component/System/Event 拆分范式。
1. 架构原则
ECS 核心信念
战斗系统应遵循三条原则:
- 检测与效果解耦 — 什么时候命中、什么时候受击,与伤害数值计算分离
- 事件为主干 — 所有战斗交互通过
Event传递,避免查询冲突 - 状态机驱动 — 角色与敌人的行为由状态 Component 决定,而非分散的 bool 标志
系统执行顺序
FixedUpdate (固定频率,默认 60Hz)
├── 输入采集 (InputSystem)
├── 技能触发判定 (SkillTriggerSystem)
├── 命中检测 (HitDetectionSystem)
├── 伤害计算 (DamageCalculationSystem) → 生成 DamageEvent
├── 生命值应用 (HealthApplicationSystem) → 处理 DamageEvent
├── 击退/硬直应用 (KnockbackStaggerSystem) → 处理 HitEvent
├── 状态转移 (StateTransitionSystem)
└── 死亡检查 (DeathSystem) → 生成 DeathEvent
Update (每帧)
├── 动画更新 (AnimationSystem)
├── 特效更新 (VfxSystem)
└── UI 更新 (CombatHudSystem)
关键:伤害检测和生命值变化放在
FixedUpdate,确保确定性。视觉和 UI 在Update,允许插值渲染。
2. 核心 Component 设计
2.1 生命值层
/// 基础生命值:当前实时值
#[derive(Component)]
pub struct Health {
pub current: f32,
pub max: f32,
}
/// 受击无敌框:受击后瞬间无敌
#[derive(Component)]
pub struct InvulnerabilityFrames {
pub remaining_ticks: u32,
}
/// 死亡标记:避免重复触发死亡逻辑
#[derive(Component)]
pub struct Dying;2.2 攻击/伤害层
/// 一次攻击的完整描述
#[derive(Component)]
pub struct AttackInstance {
pub attacker: Entity,
pub base_damage: f32,
pub damage_type: DamageType, // Physical / Fire / Ice / Lightning
pub crit_chance: f32,
pub crit_multiplier: f32,
pub life_steal: f32, // 0.0 ~ 1.0
pub tags: Vec<AttackTag>, // Melee / Ranged / AOE / Projectile
}
/// 命中盒子:决定"什么时候触碰到什么"
#[derive(Component)]
pub struct Hitbox {
pub shape: HitboxShape,
pub layer: CollisionLayer, // Player / Enemy / Projectile / AOE
pub targets: CollisionLayerMask, // 可以命中哪些层
}
pub enum HitboxShape {
Circle { radius: f32 },
Rectangle { width: f32, height: f32 },
Sector { radius: f32, angle: f32, facing: Vec2 }, // 扇形 AOE
}
/// 受击盒子:决定"被什么碰到时会受伤"
#[derive(Component)]
pub struct Hurtbox {
pub shape: HitboxShape,
pub layer: CollisionLayer,
}
/// 敌对关系:决定谁能打谁
#[derive(Component, Clone, Copy, PartialEq, Eq)]
pub enum Faction {
Player,
Enemy,
Neutral,
}2.3 战斗状态机
/// 战斗状态:决定当前可执行响应
#[derive(Component, Clone)]
pub enum CombatState {
Idle,
Attacking { skill_id: SkillId, phase: AttackPhase },
Casting { skill_id: SkillId, progress: f32 },
Staggered { duration: f32, remaining: f32 },
Knockback { velocity: Vec2, remaining: f32 },
Dead,
}
/// 攻击阶段:用于判断"取消窗口期"
#[derive(Clone)]
pub enum AttackPhase {
Startup, // 起手前摇,可取消
Active, // 命中框有效
Recovery, // 收招后摇,无法取消
}2.4 技能/触发层
/// 技能实例:动作栏上的一个具体技能
#[derive(Component)]
pub struct SkillInstance {
pub skill_id: SkillId,
pub level: u32,
pub cooldown_remaining: f32,
pub charges: u32, // 充能计数器(用于 Meta-gem)
pub modifier_set: Vec<SkillModifier>,
}
/// 触发条件组件(Meta-gem 启发)
#[derive(Component)]
pub struct TriggerCondition {
pub condition: TriggerType,
pub target_skill: SkillId,
pub cooldown: f32,
}
pub enum TriggerType {
OnCrit,
OnKill,
OnStun,
OnTerrainEnter(TerrainType),
OnHealthBelow(f32), // 生命低于 X%
OnHitTaken, // 受到伤害时
}3. 事件设计(战斗系统的血管)
3.1 核心事件
/// 命中事件:物理/传射命中
#[derive(Event)]
pub struct HitEvent {
pub attacker: Entity,
pub target: Entity,
pub attack: AttackInstance,
pub hit_position: Vec2,
pub is_crit: bool,
}
/// 伤害事件:经过公式计算后的最终伤害
#[derive(Event)]
pub struct DamageEvent {
pub source: Entity, // 攻击者
pub target: Entity,
pub raw_damage: f32, // 经过攻击者端计算后
pub final_damage: f32, // 经过受击者端减伤后
pub damage_type: DamageType,
pub is_crit: bool,
pub life_steal_amount: f32,
}
/// 击退事件
#[derive(Event)]
pub struct KnockbackEvent {
pub target: Entity,
pub direction: Vec2,
pub force: f32,
pub duration: f32,
}
/// 死亡事件
#[derive(Event)]
pub struct DeathEvent {
pub entity: Entity,
pub killer: Option<Entity>,
pub position: Vec2,
}
/// 经验/掉落事件
#[derive(Event)]
pub struct ExperienceEvent {
pub source: Entity, // 被杀的敌人
pub amount: u32,
pub position: Vec2,
}3.2 为什么用事件而非直接查询
Bevy ECS 禁止重叠的可变查询。如果一个实体同时拥有 Health 和 Damage(如带接触伤害的敌人),直接查询会导致体系评测失败。
解决方案(来自 Bevy Discussion #5241):
检测系统 (Query<&Hitbox> + Query<&Hurtbox>)
→ 发现交叠
→ 发送 HitEvent
应用系统 (EventReader<HitEvent>)
→ 计算伤害
→ 发送 DamageEvent
生命系统 (EventReader<DamageEvent> + Query<&mut Health>)
→ 扣除生命值
这样,仅有
HealthApplicationSystem需要&mut Health查询,消除了所有查询冲突。^[Bevy Discussion #5241]
4. 命中检测系统
4.1 检测方式分类
| 方式 | 适用场景 | Bevy 实现 |
|---|---|---|
| 碰撞检测 | 近战攻击、身体碰撞 | Avian CollisionStart 事件 + 图层过滤 |
| 射线检测 | 远程攻击、起手前探测 | SpatialQuery 或自实现 raycast |
| 距离检测 | AOE 技能、充能攻击 | 空间索引(Quadtree/Grid)+ 距离平方检查 |
| 形状重叠 | 矩形或扇形 AOE | 几何检测 |
4.2 等距视角的特殊处理
等距视角下距离检测需要计入投影变形:
/// 屏幕像素距离 → 世界空间距离(等距投影补偿)
fn screen_to_world_distance(screen_dx: f32, screen_dy: f32) -> f32 {
// 等距视角通常为 30° 倾斜
// X 轴无变形,Y 轴需要除以 sin(30°) = 0.5
let world_dx = screen_dx;
let world_dy = screen_dy / 0.5;
(world_dx * world_dx + world_dy * world_dy).sqrt()
}
arpg-camera-perspective.md中记录的”圆形 AOE 在屏幕上显示为椭圆”问题在此处解决。
4.3 空间索引实现(AOE 优化)
对于大量敌人 + AOE 技能的场景,不能每次都遍历所有实体检测距离。
简单方案:均匀网格(Uniform Grid)
#[derive(Resource)]
pub struct SpatialGrid {
pub cell_size: f32,
pub cells: HashMap<IVec2, Vec<Entity>>,
}
/// 每帧更新网格(或者通过事件更新)
fn update_spatial_grid(
mut grid: ResMut<SpatialGrid>,
query: Query<(Entity, &Transform, &Hurtbox)>,
) {
grid.cells.clear();
for (entity, transform, _) in query.iter() {
let cell = world_to_cell(transform.translation.truncate(), grid.cell_size);
grid.cells.entry(cell).or_default().push(entity);
}
}
/// AOE 检测只需检查目标网格及邻居网格
fn aoe_hit_detection(
grid: Res<SpatialGrid>,
aoe_query: Query<(Entity, &Transform, &Hitbox, &AttackInstance)>,
target_query: Query<(Entity, &Transform, &Hurtbox, &Faction), Without<InvulnerabilityFrames>>,
mut hit_events: EventWriter<HitEvent>,
) {
for (attacker, aoe_transform, hitbox, attack) in aoe_query.iter() {
let center = aoe_transform.translation.truncate();
let radius = match hitbox.shape {
HitboxShape::Circle { radius } => radius,
_ => continue,
};
// 只检查可能含有目标的网格
let affected_cells = cells_in_radius(center, radius, grid.cell_size);
for cell in affected_cells {
if let Some(entities) = grid.cells.get(&cell) {
for &target in entities {
// 精确距离检查
if let Ok((_, target_transform, _, target_faction)) = target_query.get(target) {
let dist_sq = center.distance_squared(target_transform.translation.truncate());
if dist_sq <= radius * radius {
// 敌对关系检查
if can_hit(attacker_faction, target_faction) {
hit_events.send(HitEvent { ... });
}
}
}
}
}
}
}
}对于几十个敌人的场景,均匀网格已足够。如果需要处理数百个,切换到 Quadtree 或 KD-Tree。^[Stack Overflow - Efficient AOE Detection]
5. 伤害计算管道
5.1 三段式计算
攻击者端 (Attacker) → 受击者端 (Target)
AttackInstance
↓ 应用攻击端加成
raw_damage = base × (1 + Σincreased) × ∏(1 + more) × crit
↓ 通过 DamageEvent 发送
→ DamageEvent { raw_damage }
↓ 应用受击端减伤
final_damage = raw_damage × (1 - 减伤%) × resistance_multiplier
↓ 扣除 Health
5.2 攻击端 System
fn calculate_attack_damage(
mut hit_events: EventReader<HitEvent>,
attacker_query: Query<(&CharacterStats, &Equipment bonuses, &SkillInstance)>,
mut damage_events: EventWriter<DamageEvent>,
) {
for hit in hit_events.iter() {
if let Ok((stats, equipment, skill)) = attacker_query.get(hit.attacker) {
// 基础伤害
let base = hit.attack.base_damage;
// 攻击端计算(参考 arpg-damage-formulas.md 的公式)
let increased_multiplier = 1.0 + stats.increased_damage + equipment.increased_damage;
let more_multiplier = equipment.more_multipliers.iter().product::<f32>();
// 暴击检查
let is_crit = random() < hit.attack.crit_chance;
let crit_multiplier = if is_crit { hit.attack.crit_multiplier } else { 1.0 };
let raw_damage = base * increased_multiplier * more_multiplier * crit_multiplier;
let life_steal = raw_damage * hit.attack.life_steal;
damage_events.send(DamageEvent {
source: hit.attacker,
target: hit.target,
raw_damage,
final_damage: raw_damage, // 临时,将在受击端被覆盖
damage_type: hit.attack.damage_type,
is_crit,
life_steal_amount: life_steal,
});
}
}
}5.3 受击端 System
fn apply_damage(
mut damage_events: EventReader<DamageEvent>,
mut health_query: Query<&mut Health>,
defense_query: Query<(&Armor, &Resistances, &Evasion)>,
mut knockback_events: EventWriter<KnockbackEvent>,
) {
for mut event in damage_events.iter() {
if let Ok((armor, resistances, evasion)) = defense_query.get(event.target) {
// 闪避检查(先于减伤计算)
if random() < evasion.chance {
continue; // 闪避成功,不受伤害
}
// 元素抗性
let resistance = resistances.get(event.damage_type);
let resistance_multiplier = 1.0 - resistance.clamp(0.0, 0.75); // 上限 75%
// 护甲减伤(参考 arpg-defense-formulas.md 的公式)
let armor_mitigation = armor.value / (armor.value + 5.0 * event.raw_damage);
event.final_damage = event.raw_damage
* (1.0 - armor_mitigation)
* resistance_multiplier;
// 扣除生命值
if let Ok(mut health) = health_query.get_mut(event.target) {
health.current -= event.final_damage;
// 生命偷取
if event.life_steal_amount > 0.0 {
if let Ok(mut attacker_health) = health_query.get_mut(event.source) {
attacker_health.current = (attacker_health.current + event.life_steal_amount)
.min(attacker_health.max);
}
}
}
// 触发击退(若伤害超过阈值)
if event.final_damage > 10.0 { // 阈值可配置
knockback_events.send(KnockbackEvent {
target: event.target,
direction: (event.target_position - event.source_position).normalize(),
force: event.final_damage * 0.1,
duration: 0.2,
});
}
}
}
}5.4 减伤公式的 ECS 表达
从 arpg-damage-formulas.md 和 arpg-defense-formulas.md 转换而来的减伤公式:
/// 护甲:标准除法公式
fn armor_mitigation(armor: f32, raw_damage: f32) -> f32 {
armor / (armor + 5.0 * raw_damage)
}
/// 护甲:EHP 计算
fn effective_hp(hp: f32, armor: f32, expected_damage: f32) -> f32 {
hp * (armor + 5.0 * expected_damage) / (5.0 * expected_damage)
}
/// 元素抗性:简单线性
fn resistance_multiplier(resistance: f32) -> f32 {
1.0 - resistance.clamp(0.0, 0.75)
}
/// 闪避:独立检查(非减伤)
fn evasion_check(evasion: f32) -> bool {
random() < evasion.clamp(0.0, 0.75)
}6. 技能触发系统(Meta-gem 启发)
6.1 触发链路
/// 触发器:监听特定事件并执行目标技能
fn trigger_system(
mut damage_events: EventReader<DamageEvent>,
mut hit_events: EventReader<HitEvent>,
mut death_events: EventReader<DeathEvent>,
mut trigger_query: Query<(Entity, &TriggerCondition, &mut SkillInstance)>,
mut skill_events: EventWriter<SkillActivateEvent>,
) {
// OnCrit 触发
for hit in hit_events.iter() {
if hit.is_crit {
for (entity, trigger, mut skill) in trigger_query.iter_mut() {
if matches!(trigger.condition, TriggerType::OnCrit)
&& skill.cooldown_remaining <= 0.0 {
skill_events.send(SkillActivateEvent {
caster: entity,
skill_id: trigger.target_skill,
target: Some(hit.target),
});
skill.cooldown_remaining = trigger.cooldown;
}
}
}
}
// OnKill 触发
for death in death_events.iter() {
if let Some(killer) = death.killer {
for (entity, trigger, mut skill) in trigger_query.iter_mut() {
if matches!(trigger.condition, TriggerType::OnKill)
&& entity == killer
&& skill.cooldown_remaining <= 0.0 {
skill_events.send(SkillActivateEvent {
caster: entity,
skill_id: trigger.target_skill,
target: None,
});
skill.cooldown_remaining = trigger.cooldown;
}
}
}
}
}6.2 充能计数器(Energy 模式)
PoE2 的 Energy 机制在 ECS 中的表达:
#[derive(Component)]
pub struct EnergyStorage {
pub current: f32,
pub max: f32,
}
fn energy_gain_system(
mut hit_events: EventReader<HitEvent>,
mut energy_query: Query<&mut EnergyStorage>,
) {
for hit in hit_events.iter() {
if let Ok(mut energy) = energy_query.get_mut(hit.attacker) {
// 根据敌人稀有度加权(Normal×1, Magic×2, Rare×5, Unique×20)
let gain = calculate_energy_gain(hit.target_rarity);
energy.current = (energy.current + gain).min(energy.max);
}
}
}
fn energy_trigger_system(
mut energy_query: Query<(&mut EnergyStorage, &TriggerCondition, &mut SkillInstance)>,
mut skill_events: EventWriter<SkillActivateEvent>,
) {
for (mut energy, trigger, mut skill) in energy_query.iter_mut() {
if energy.current >= energy.max && skill.cooldown_remaining <= 0.0 {
skill_events.send(SkillActivateEvent {
caster: entity,
skill_id: trigger.target_skill,
target: None,
});
energy.current = 0.0;
skill.cooldown_remaining = trigger.cooldown;
}
}
}7. 状态机与动画系统
7.1 战斗状态转移
fn combat_state_transitions(
time: Res<Time>,
mut query: Query<(Entity, &mut CombatState, &mut Transform)>,
mut commands: Commands,
) {
for (entity, mut state, mut transform) in query.iter_mut() {
match *state {
CombatState::Attacking { skill_id, ref mut phase } => {
// 根据动画进度更新阶段
// Startup → Active → Recovery
}
CombatState::Staggered { duration, ref mut remaining } => {
*remaining -= time.delta_seconds();
if *remaining <= 0.0 {
*state = CombatState::Idle;
}
}
CombatState::Knockback { velocity, ref mut remaining } => {
let dt = time.delta_seconds();
transform.translation += velocity.extend(0.0) * dt;
*remaining -= dt;
if *remaining <= 0.0 {
*state = CombatState::Idle;
}
}
_ => {}
}
}
}7.2 无敌框管理
fn invulnerability_countdown(
mut commands: Commands,
mut query: Query<(Entity, &mut InvulnerabilityFrames)>,
) {
for (entity, mut iframe) in query.iter_mut() {
if iframe.remaining_ticks > 0 {
iframe.remaining_ticks -= 1;
} else {
commands.entity(entity).remove::<InvulnerabilityFrames>();
}
}
}8. 死亡与掉落系统
8.1 死亡检查
fn death_check(
mut commands: Commands,
query: Query<(Entity, &Health, &Transform, Option<&Faction>), Without<Dying>>,
mut death_events: EventWriter<DeathEvent>,
mut xp_events: EventWriter<ExperienceEvent>,
) {
for (entity, health, transform, faction) in query.iter() {
if health.current <= 0.0 {
commands.entity(entity).insert(Dying);
death_events.send(DeathEvent {
entity,
killer: None, // 需要从 DamageEvent 历史查找
position: transform.translation.truncate(),
});
// 经验值(仅敌人)
if matches!(faction, Some(Faction::Enemy)) {
xp_events.send(ExperienceEvent {
source: entity,
amount: calculate_xp(entity),
position: transform.translation.truncate(),
});
}
}
}
}8.2 计算击杀者(用于经验分配)
#[derive(Component)]
pub struct LastHitBy {
pub attacker: Entity,
pub timestamp: f32,
}
fn track_last_hit(
mut damage_events: EventReader<DamageEvent>,
mut query: Query<&mut LastHitBy>,
time: Res<Time>,
) {
for event in damage_events.iter() {
if let Ok(mut last_hit) = query.get_mut(event.target) {
last_hit.attacker = event.source;
last_hit.timestamp = time.elapsed_seconds();
} else {
commands.entity(event.target).insert(LastHitBy {
attacker: event.source,
timestamp: time.elapsed_seconds(),
});
}
}
}9. 与现有系统的集成
9.1 和角色/技能系统的交接
CharacterStats (arpg-character-systems.md)
↓ 提供 increased_damage / crit_chance / life_steal
↓ 装备增强 AttackInstance
SkillInstance (arpg-skill-systems-comparison.md)
↓ 技能等级影响 base_damage
↓ SkillModifier 改变 tags / damage_type
TriggerCondition (Meta-gem 系统)
↓ 监听 HitEvent / DamageEvent / DeathEvent
↓ 触发 SkillActivateEvent
9.2 装备影响伤害
装备的 ExplicitAffix 组件(arpg-equipment-design.md)直接影响战斗计算:
fn equipment_to_attack_bonuses(
equipment_query: Query<&EquippedItem>,
affix_query: Query<&ExplicitAffix>,
) -> AttackBonuses {
let mut increased = 0.0;
let mut more_multipliers = Vec::new();
let mut added_damage = 0.0;
for equipped in equipment_query.iter() {
for affix in affix_query.iter_many(equipped.item_entities) {
match affix.stat {
Stat::IncreasedDamage(pct) => increased += pct,
Stat::MoreDamage(pct) => more_multipliers.push(1.0 + pct),
Stat::AddedFireDamage(val) => added_damage += val,
// ...
}
}
}
AttackBonuses { increased, more_multipliers, added_damage }
}10. 性能考量
10.1 查询优化
// ❌ 避免:每帧重算所有装备加成
// ✅ 推荐:使用变化检测
fn update_attack_bonuses(
mut bonuses_query: Query<&mut AttackBonuses>,
changed_equipment: Query<(), (With<EquippedItem>, Changed<ExplicitAffix>)>,
) {
if !changed_equipment.is_empty() {
// 只有装备变化时才重算
recalculate_all_bonuses();
}
}10.2 事件清理
// FixedUpdate 结束时清理事件
app.add_systems(FixedUpdate, (
hit_detection,
calculate_attack_damage,
apply_damage,
death_check,
).chain().before(clear_combat_events));
fn clear_combat_events(
mut hit_events: ResMut<Events<HitEvent>>,
mut damage_events: ResMut<Events<DamageEvent>>,
) {
hit_events.clear();
damage_events.clear();
}Bevy 的
Events<T>在每帧结束时会清理已读事件,但显式clear()确保不泄漏。
mosaic 研究补注(2026-08-27)
本页 2026-05 写成「ZoOL 等距 ARPG 的可实现架构」。SideDawn 今日没有 skill / combat crate,也没有宝石插座。宝石拼装见历史页 arpg-skill-gem-assembly。facing / 碰撞 / 遭遇综述见 arpg-facing-combat。
mosaic docs/research/2026-07-21-arpg-collision-system-design.md(2026-07-21)补充:hitbox/hurtbox + 按攻击去重;角色↔角色软分离;kinematic collide-and-slide。docs/research/2026-08-05-arpg-walk-vs-cast-facing-practices.md(2026-08-05):单 Facing + 双意图,aim 不写 heading。这些是研究,不是 sidedawn 实现票。
相关页面
- arpg-damage-formulas — 伤害公式体系设计
- arpg-defense-formulas — 防御公式与 EHP 分析
- arpg-character-systems — 角色/Stat/Skill 组件设计
- arpg-skill-systems-comparison — 技能系统设计(Meta-gem / TriggerCondition)
- arpg-equipment-design — 装备物品化系统设计(词缀复杂度、打造系统、Build 赋能)
- bevy-ecs-game-type-fit — ECS 适合度分析
- bevy-game-development — Bevy 引擎跟踪
- bevy-pixel-perfect-rendering — 等距视角渲染
来源
- Bevy Discussion #5241 — Best practices for systems with non-exclusive component lists. https://github.com/bevyengine/bevy/discussions/5241
- artificials.ch — ECS Pattern: Damage System. https://artificials.ch/ecs-pattern-damage-system/
- Stack Overflow — Efficiently determine which units are in AOE spell range. https://stackoverflow.com/questions/19211272/efficiently-determine-which-units-are-in-the-range-of-aoe-spells-in-rpg-games-l
- Avian 2D Documentation — Collision events and broadphase. https://docs.rs/avian2d