Bevy ARPG 战斗系统 ECS 架构

为 ZoOL 的 Bevy 等距 ARPG 项目设计的战斗系统 ECS 架构指南。综合伤害公式、技能系统、角色系统的设计决策,提供可直接实现的 Component/System/Event 拆分范式。


1. 架构原则

ECS 核心信念

战斗系统应遵循三条原则:

  1. 检测与效果解耦 — 什么时候命中、什么时候受击,与伤害数值计算分离
  2. 事件为主干 — 所有战斗交互通过 Event 传递,避免查询冲突
  3. 状态机驱动 — 角色与敌人的行为由状态 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 禁止重叠的可变查询。如果一个实体同时拥有 HealthDamage(如带接触伤害的敌人),直接查询会导致体系评测失败。

解决方案(来自 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.mdarpg-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 实现票。

相关页面

来源