diff --git a/Cargo.toml b/Cargo.toml index ff9f8ada..14a5a6ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,7 +133,7 @@ features = ['derive'] version = '4.5.38' [workspace.dependencies.derive_more] -features = ['display', 'from', 'deref', 'deref_mut', 'debug', 'constructor'] +features = ['display', 'from', 'deref', 'deref_mut', 'debug', 'constructor', 'add', 'add_assign'] version = "2.0.1" [workspace.dependencies.divan] diff --git a/crates/hyperion/src/simulation/mod.rs b/crates/hyperion/src/simulation/mod.rs index 47dc0366..06acf1be 100644 --- a/crates/hyperion/src/simulation/mod.rs +++ b/crates/hyperion/src/simulation/mod.rs @@ -1,7 +1,7 @@ use std::{borrow::Borrow, collections::HashMap, hash::Hash, sync::Arc}; use bytemuck::{Pod, Zeroable}; -use derive_more::{Constructor, Deref, DerefMut, Display, From}; +use derive_more::{Add, Constructor, Deref, DerefMut, Display, From, Sub}; use flecs_ecs::prelude::*; use geometry::aabb::Aabb; use glam::{DVec3, I16Vec2, IVec3, Quat, Vec3}; @@ -390,7 +390,9 @@ pub struct AiTargetable; Deref, DerefMut, From, - PartialEq + PartialEq, + Add, + Sub )] #[meta] pub struct Position { diff --git a/events/tag/src/module/attack.rs b/events/tag/src/module/attack.rs index a9046320..095e46e9 100644 --- a/events/tag/src/module/attack.rs +++ b/events/tag/src/module/attack.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use compact_str::format_compact; +use derive_more::with_trait::Add; use flecs_ecs::{ core::{ Builder, ComponentOrPairId, EntityView, EntityViewGet, QueryAPI, QueryBuilderImpl, @@ -18,11 +19,11 @@ use hyperion::{ }, runtime::AsyncRuntime, simulation::{ - PacketState, PendingTeleportation, Player, Position, Velocity, Xp, Yaw, + PacketState, PendingTeleportation, Player, Position, Velocity, Yaw, blocks::Blocks, event::{self, ClientStatusCommand, ClientStatusEvent}, handlers::PacketSwitchQuery, - metadata::{entity::Pose, living_entity::Health}, + metadata::living_entity::Health, packet::HandlerRegistry, }, storage::EventQueue, @@ -30,11 +31,9 @@ use hyperion::{ valence_protocol::{ ItemKind, ItemStack, Particle, VarInt, ident, math::{DVec3, Vec3}, - nbt, packets::play::{ self, boss_bar_s2c::{BossBarColor, BossBarDivision, BossBarFlags}, - entity_attributes_s2c::AttributeProperty, }, text::IntoText, }, @@ -62,7 +61,7 @@ pub struct Armor { } // Used as a component only for commands, does not include armor or weapons -#[derive(Component, Default, Copy, Clone, Debug)] +#[derive(Component, Default, Copy, Clone, Debug, Add)] #[meta] pub struct CombatStats { pub armor: f32, @@ -77,6 +76,45 @@ pub struct KillCount { pub kill_count: u32, } +/// Checks if the entity is immune to attacks and updates the immunity timer if it is +/// +/// Returns true if the entity is immune, false otherwise +fn check_and_update_immunity(tick: i64, view: &EntityView<'_>) -> bool { + const IMMUNE_TICK_DURATION: i64 = 10; + view.try_get::<&mut ImmuneUntil>(|immune_until| { + if immune_until.tick > tick { + return true; + } + + immune_until.tick = tick + IMMUNE_TICK_DURATION; + + false + }) + .unwrap_or(false) +} + +fn is_critical_hit(view: EntityView<'_>) -> bool { + view.try_get::<(&(Prev, Position), &Position)>(|(prev_position, position)| { + // TODO: Do not allow critical hits if the player is on a ladder, vine, or water. None of + // these special blocks are currently on the map. + let position_delta_y = position.y - prev_position.y; + position_delta_y < 0.0 + }) + .unwrap_or(false) +} + +fn inventory_combat_stats(critical_hit: bool, view: EntityView<'_>) -> CombatStats { + view.get::<&PlayerInventory>(|inventory| calculate_stats(inventory, critical_hit)) +} + +fn total_combat_stats(critical_hit: bool, view: EntityView<'_>) -> CombatStats { + let inventory_combat_stats = inventory_combat_stats(critical_hit, view); + let existing_combat_stats = view + .try_get::<&CombatStats>(|stats| *stats) + .unwrap_or_default(); + existing_combat_stats + inventory_combat_stats +} + #[allow(clippy::cast_possible_truncation)] impl Module for AttackModule { #[allow(clippy::excessive_nesting)] @@ -131,16 +169,13 @@ impl Module for AttackModule { // TODO: This code should be split between melee attacks and bow attacks system!("handle_attacks", world, &mut EventQueue($), &Compose($)) - .each_iter( - move |it: TableIter<'_, false>, - _, - (event_queue, compose): ( - &mut EventQueue, - &Compose, - )| { - const IMMUNE_TICK_DURATION: i64 = 10; - + move |it: TableIter<'_, false>, + _, + (event_queue, compose): ( + &mut EventQueue, + &Compose, + )| { let span = info_span!("handle_attacks"); let _enter = span.enter(); @@ -150,368 +185,129 @@ impl Module for AttackModule { let world = it.world(); - for event in event_queue.drain() { - let target = world.entity_from_id(event.target); - let origin = world.entity_from_id(event.origin); - let critical_hit = can_critical_hit(origin); - origin.get::<(&ConnectionId, &Position, &mut KillCount, &mut PlayerInventory, &mut Armor, &CombatStats, &PlayerInventory, &Team, &mut Xp)>(|(origin_connection, origin_pos, kill_count, inventory, origin_armor, from_stats, from_inventory, origin_team, origin_xp)| { - let damage = from_stats.damage + calculate_stats(from_inventory, critical_hit).damage; - target.try_get::<( - &ConnectionId, - Option<&mut ImmuneUntil>, - &mut Health, - &mut Position, - &Yaw, - &CombatStats, - &PlayerInventory, - &Team, - &mut Pose, - &mut Xp, - &mut Velocity - )>( - |(target_connection, immune_until, health, target_position, target_yaw, stats, target_inventory, target_team, target_pose, target_xp, target_velocity)| { - if let Some(immune_until) = immune_until { - if immune_until.tick > current_tick { - return; - } - immune_until.tick = current_tick + IMMUNE_TICK_DURATION; - } - - if target_team == origin_team { - let msg = "§cCannot attack teammates"; - let pkt_msg = play::GameMessageS2c { - chat: msg.into_cow_text(), - overlay: false, - }; - - compose.unicast(&pkt_msg, *origin_connection, system).unwrap(); - return; - } - - let calculated_stats = calculate_stats(target_inventory, critical_hit); - let armor = stats.armor + calculated_stats.armor; - let toughness = stats.armor_toughness + calculated_stats.armor_toughness; - let protection = stats.protection + calculated_stats.protection; - - let damage_after_armor = get_damage_left(damage, armor, toughness); - let damage_after_protection = get_inflicted_damage(damage_after_armor, protection); - - health.damage(damage_after_protection); - - let delta_x: f64 = f64::from(target_position.x - origin_pos.x); - let delta_z: f64 = f64::from(target_position.z - origin_pos.z); - - // Seems that MC generates a random delta if the damage source is too close to the target - // let's ignore that for now - let pkt_hurt = play::DamageTiltS2c { - entity_id: VarInt(target.minecraft_id()), - yaw: delta_z.atan2(delta_x).mul_add(57.295_776_367_187_5_f64, -f64::from(**target_yaw)) as f32 - }; - // EntityDamageS2c: display red outline when taking damage (play arrow hit sound?) - let pkt_damage_event = play::EntityDamageS2c { - entity_id: VarInt(target.minecraft_id()), - source_cause_id: VarInt(origin.minecraft_id() + 1), // this is an OptVarint - source_direct_id: VarInt(origin.minecraft_id() + 1), // if hit by a projectile, it should be the projectile's entity id - source_type_id: VarInt(31), // 31 = player_attack - source_pos: None - }; - let sound = agnostic::sound( - if critical_hit { ident!("minecraft:entity.player.attack.crit") } else { ident!("minecraft:entity.player.attack.knockback") }, - **target_position, - ).volume(1.) - .pitch(1.) - .seed(fastrand::i64(..)) - .build(); - - compose.unicast(&pkt_hurt, *target_connection, system).unwrap(); - - if critical_hit { - let particle_pkt = play::ParticleS2c { - particle: Cow::Owned(Particle::Crit), - long_distance: true, - position: target_position.as_dvec3() + DVec3::new(0.0, 1.0, 0.0), - max_speed: 0.5, - count: 100, - offset: Vec3::new(0.5, 0.5, 0.5), - }; - - // origin is excluded because the crit particles are - // already generated on the client side of the attacker - compose.broadcast(&particle_pkt, system).exclude(*origin_connection).send().unwrap(); - } - - if health.is_dead() { - let attacker_name = origin.name(); - // Even if enable_respawn_screen is false, the client needs this to send ClientCommandC2s and initiate its respawn - let pkt_death_screen = play::DeathMessageS2c { - player_id: VarInt(target.minecraft_id()), - message: format!("You were killed by {attacker_name}").into_cow_text() - }; - compose.unicast(&pkt_death_screen, *target_connection, system).unwrap(); - } - compose.broadcast(&sound, system).send().unwrap(); - compose.broadcast(&pkt_damage_event, system).send().unwrap(); - - if health.is_dead() { - // Create particle effect at the attacker's position - let particle_pkt = play::ParticleS2c { - particle: Cow::Owned(Particle::Explosion), - long_distance: true, - position: target_position.as_dvec3() + DVec3::new(0.0, 1.0, 0.0), - max_speed: 0.5, - count: 100, - offset: Vec3::new(0.5, 0.5, 0.5), - }; - - // Add a second particle effect for more visual impact - let particle_pkt2 = play::ParticleS2c { - particle: Cow::Owned(Particle::DragonBreath), - long_distance: true, - position: target_position.as_dvec3() + DVec3::new(0.0, 1.5, 0.0), - max_speed: 0.2, - count: 75, - offset: Vec3::new(0.3, 0.3, 0.3), - }; - let pkt_entity_status = play::EntityStatusS2c { - entity_id: target.minecraft_id(), - entity_status: 3 - }; - - let origin_entity_id = origin.minecraft_id(); - - origin_armor.armor += 1.0; - let pkt = play::EntityAttributesS2c { - entity_id: VarInt(origin_entity_id), - properties: vec![ - AttributeProperty { - key: ident!("minecraft:generic.armor").into(), - value: origin_armor.armor.into(), - modifiers: vec![], - } - ], - }; - - let entities_to_remove = [VarInt(target.minecraft_id())]; - let pkt_remove_entities = play::EntitiesDestroyS2c { - entity_ids: Cow::Borrowed(&entities_to_remove) - }; - - *target_pose = Pose::Dying; - target.modified(id::()); - compose.broadcast(&pkt, system).send().unwrap(); - compose.broadcast(&particle_pkt, system).send().unwrap(); - compose.broadcast(&particle_pkt2, system).send().unwrap(); - compose.broadcast(&pkt_entity_status, system).send().unwrap(); - compose.broadcast(&pkt_remove_entities, system).send().unwrap(); - - // Create NBT for enchantment protection level 1 - let mut protection_nbt = nbt::Compound::new(); - let mut enchantments = vec![]; - - let mut protection_enchantment = nbt::Compound::new(); - protection_enchantment.insert("id", nbt::Value::String("minecraft:protection".into())); - protection_enchantment.insert("lvl", nbt::Value::Short(1)); - enchantments.push(protection_enchantment); - protection_nbt.insert( - "Enchantments", - nbt::Value::List(nbt::list::List::Compound(enchantments)), - ); - // Apply upgrades based on the level - match kill_count.kill_count { - 0 => {} - 1 => inventory - .set_hotbar(0, ItemStack::new(ItemKind::WoodenSword, 1, None)).unwrap(), - 2 => inventory - .set_boots(ItemStack::new(ItemKind::LeatherBoots, 1, None)), - 3 => inventory - .set_leggings(ItemStack::new(ItemKind::LeatherLeggings, 1, None)), - 4 => inventory - .set_chestplate(ItemStack::new(ItemKind::LeatherChestplate, 1, None)), - 5 => inventory - .set_helmet(ItemStack::new(ItemKind::LeatherHelmet, 1, None)), - 6 => inventory - .set_hotbar(0, ItemStack::new(ItemKind::StoneSword, 1, None)).unwrap(), - 7 => inventory - .set_boots(ItemStack::new(ItemKind::ChainmailBoots, 1, None)), - 8 => inventory - .set_leggings(ItemStack::new(ItemKind::ChainmailLeggings, 1, None)), - 9 => inventory - .set_chestplate(ItemStack::new(ItemKind::ChainmailChestplate, 1, None)), - 10 => inventory - .set_helmet(ItemStack::new(ItemKind::ChainmailHelmet, 1, None)), - 11 => inventory - .set_hotbar(0, ItemStack::new(ItemKind::IronSword, 1, None)).unwrap(), - 12 => inventory - .set_boots(ItemStack::new(ItemKind::IronBoots, 1, None)), - 13 => inventory - .set_leggings(ItemStack::new(ItemKind::IronLeggings, 1, None)), - 14 => inventory - .set_chestplate(ItemStack::new(ItemKind::IronChestplate, 1, None)), - 15 => inventory - .set_helmet(ItemStack::new(ItemKind::IronHelmet, 1, None)), - 16 => inventory - .set_hotbar(0, ItemStack::new(ItemKind::DiamondSword, 1, None)).unwrap(), - 17 => inventory - .set_boots(ItemStack::new(ItemKind::DiamondBoots, 1, None)), - 18 => inventory - .set_leggings(ItemStack::new(ItemKind::DiamondLeggings, 1, None)), - 19 => inventory - .set_chestplate(ItemStack::new(ItemKind::DiamondChestplate, 1, None)), - 20 => inventory - .set_helmet(ItemStack::new(ItemKind::DiamondHelmet, 1, None)), - 21 => inventory - .set_hotbar(0, ItemStack::new(ItemKind::NetheriteSword, 1, None)).unwrap(), - 22 => inventory - .set_boots(ItemStack::new(ItemKind::NetheriteBoots, 1, None)), - 23 => inventory - .set_leggings(ItemStack::new(ItemKind::NetheriteLeggings, 1, None)), - 24 => inventory - .set_chestplate(ItemStack::new(ItemKind::NetheriteChestplate, 1, None)), - 25 => inventory - .set_helmet(ItemStack::new(ItemKind::NetheriteHelmet, 1, None)), - 26 => { - // Reset armor and start again with Protection I - inventory.set_boots(ItemStack::new( - ItemKind::LeatherBoots, - 1, - Some(protection_nbt.clone()), - )); - inventory.set_leggings(ItemStack::new( - ItemKind::LeatherLeggings, - 1, - Some(protection_nbt.clone()), - )); - inventory.set_chestplate(ItemStack::new( - ItemKind::LeatherChestplate, - 1, - Some(protection_nbt.clone()), - )); - inventory.set_helmet(ItemStack::new( - ItemKind::LeatherHelmet, - 1, - Some(protection_nbt.clone()), - )); - } - _ => { - // Continue upgrading with Protection I after reset - let level = (kill_count.kill_count - 26) % 24; - match level { - 1 => inventory.set_boots(ItemStack::new( - ItemKind::ChainmailBoots, - 1, - Some(protection_nbt.clone()), - )), - 2 => inventory.set_leggings(ItemStack::new( - ItemKind::ChainmailLeggings, - 1, - Some(protection_nbt.clone()), - )), - 3 => inventory.set_chestplate(ItemStack::new( - ItemKind::ChainmailChestplate, - 1, - Some(protection_nbt.clone()), - )), - 4 => inventory.set_helmet(ItemStack::new( - ItemKind::ChainmailHelmet, - 1, - Some(protection_nbt.clone()), - )), - 5 => inventory.set_boots(ItemStack::new( - ItemKind::IronBoots, - 1, - Some(protection_nbt.clone()), - )), - 6 => inventory.set_leggings(ItemStack::new( - ItemKind::IronLeggings, - 1, - Some(protection_nbt.clone()), - )), - 7 => inventory.set_chestplate(ItemStack::new( - ItemKind::IronChestplate, - 1, - Some(protection_nbt.clone()), - )), - 8 => inventory.set_helmet(ItemStack::new( - ItemKind::IronHelmet, - 1, - Some(protection_nbt.clone()), - )), - 9 => inventory.set_boots(ItemStack::new( - ItemKind::DiamondBoots, - 1, - Some(protection_nbt.clone()), - )), - 10 => inventory.set_leggings(ItemStack::new( - ItemKind::DiamondLeggings, - 1, - Some(protection_nbt.clone()), - )), - 11 => inventory.set_chestplate(ItemStack::new( - ItemKind::DiamondChestplate, - 1, - Some(protection_nbt.clone()), - )), - 12 => inventory.set_helmet(ItemStack::new( - ItemKind::DiamondHelmet, - 1, - Some(protection_nbt.clone()), - )), - 13 => inventory.set_boots(ItemStack::new( - ItemKind::NetheriteBoots, - 1, - Some(protection_nbt.clone()), - )), - 14 => inventory.set_leggings(ItemStack::new( - ItemKind::NetheriteLeggings, - 1, - Some(protection_nbt.clone()), - )), - 15 => inventory.set_chestplate(ItemStack::new( - ItemKind::NetheriteChestplate, - 1, - Some(protection_nbt.clone()), - )), - 16 => inventory.set_helmet(ItemStack::new( - ItemKind::NetheriteHelmet, - 1, - Some(protection_nbt.clone()), - )), - _ => {} // No upgrade for other levels - } - } - } - // player died, increment kill count - kill_count.kill_count += 1; - - target.set::(*origin_team); - - origin_xp.amount = (f32::from(target_xp.amount)*0.5) as u16; - target_xp.amount = (f32::from(target_xp.amount)/3.) as u16; - - return; - } - - // Calculate velocity change based on attack direction - let this = **target_position; - let other = **origin_pos; - - let dir = (this - other).normalize(); - - let knockback_xz = 8.0; - let knockback_y = 6.432; - - let new_vel = Vec3::new( - dir.x * knockback_xz / 20.0, - knockback_y / 20.0, - dir.z * knockback_xz / 20.0 - ); - - target_velocity.0 += new_vel; - - // https://github.com/valence-rs/valence/blob/8f3f84d557dacddd7faddb2ad724185ecee2e482/examples/ctf.rs#L987-L989 - }, - ); + for event::AttackEntity { origin, target, damage } in event_queue.drain() { + let origin = world.entity_from_id(origin); + let target = world.entity_from_id(target); + + if check_and_update_immunity(current_tick, &target) { + // no damage; the target is immune + continue; + } + + let origin_team = origin.get::<&Team>(|team| *team); + let target_team = target.get::<&Team>(|team| *team); + + let origin_pos = origin.get::<&Position>(|position| *position); + let target_pos = target.get::<&Position>(|position| *position); + + let origin_connection = origin.get::<&ConnectionId>(|connection_id| *connection_id); + let target_connection = target.get::<&ConnectionId>(|connection_id| *connection_id); + + + if origin_team == target_team { + let msg = "§cCannot attack teammates"; + let pkt_msg = play::GameMessageS2c { + chat: msg.into_cow_text(), + overlay: false, + }; + + compose.unicast(&pkt_msg, origin_connection, system).unwrap(); + continue; + } + + let is_critical_hit = is_critical_hit(origin); + let combat_stats = total_combat_stats(is_critical_hit, origin); + + let damage_after_armor = get_damage_left(damage, combat_stats.armor, combat_stats.armor_toughness); + let damage_after_protection = get_inflicted_damage(damage_after_armor, combat_stats.protection); + + if damage_after_protection <= 0.0 { + continue; + } + + let is_dead = target.get::<&mut Health>(|health| { + health.damage(damage_after_protection); + health.is_dead() }); + + let target_yaw = target.get::<&Yaw>(|yaw| *yaw); + + let delta_x: f64 = f64::from(target_pos.x - origin_pos.x); + let delta_z: f64 = f64::from(target_pos.z - origin_pos.z); + + // Seems that MC generates a random delta if the damage source is too close to the target + // let's ignore that for now + let pkt_hurt = play::DamageTiltS2c { + entity_id: VarInt(target.minecraft_id()), + yaw: delta_z.atan2(delta_x).mul_add(57.295_776_367_187_5_f64, -f64::from(*target_yaw)) as f32, + }; + + // EntityDamageS2c: display red outline when taking damage (play arrow hit sound?) + let pkt_damage_event = play::EntityDamageS2c { + entity_id: VarInt(target.minecraft_id()), + source_cause_id: VarInt(origin.minecraft_id() + 1), // this is an OptVarint + source_direct_id: VarInt(origin.minecraft_id() + 1), // if hit by a projectile, it should be the projectile's entity id + source_type_id: VarInt(31), // 31 = player_attack + source_pos: None, + }; + let sound = agnostic::sound( + if is_critical_hit { ident!("minecraft:entity.player.attack.crit") } else { ident!("minecraft:entity.player.attack.knockback") }, + *target_pos, + ).volume(1.) + .pitch(1.) + .seed(fastrand::i64(..)) + .build(); + + if is_critical_hit { + let particle_pkt = play::ParticleS2c { + particle: Cow::Owned(Particle::Crit), + long_distance: true, + position: target_pos.as_dvec3() + DVec3::new(0.0, 1.0, 0.0), + max_speed: 0.5, + count: 100, + offset: Vec3::new(0.5, 0.5, 0.5), + }; + + // origin is excluded because the crit particles are + // already generated on the client side of the attacker + compose.broadcast(&particle_pkt, system).exclude(origin_connection).send().unwrap(); + } + + compose.unicast(&pkt_hurt, target_connection, system).unwrap(); + + if is_dead { + let attacker_name = origin.name(); + // Even if enable_respawn_screen is false, the client needs this to send ClientCommandC2s and initiate its respawn + let pkt_death_screen = play::DeathMessageS2c { + player_id: VarInt(target.minecraft_id()), + message: format!("You were killed by {attacker_name}").into_cow_text(), + }; + compose.unicast(&pkt_death_screen, target_connection, system).unwrap(); + + origin.get::< + &mut KillCount, + >(|origin_kill_count| { + origin_kill_count.kill_count += 1; + }); + } else { + // Calculate velocity change based on attack direction + let dir = (target_pos - origin_pos).normalize(); + + let knockback_xz = 8.0; + let knockback_y = 6.432; + + let new_vel = Vec3::new( + dir.x * knockback_xz / 20.0, + knockback_y / 20.0, + dir.z * knockback_xz / 20.0, + ); + + target.get::<&mut Velocity>(|target_velocity| { + target_velocity.0 += new_vel; + }); + } + compose.broadcast(&sound, system).send().unwrap(); + compose.broadcast(&pkt_damage_event, system).send().unwrap(); } }, ); @@ -680,12 +476,3 @@ fn calculate_stats(inventory: &PlayerInventory, critical_hit: bool) -> CombatSta protection: 0.0, } } - -fn can_critical_hit(player: EntityView<'_>) -> bool { - player.get::<(&(Prev, Position), &Position)>(|(prev_position, position)| { - // TODO: Do not allow critical hits if the player is on a ladder, vine, or water. None of - // these special blocks are currently on the map. - let position_delta_y = position.y - prev_position.y; - position_delta_y < 0.0 - }) -}