652 lines
22 KiB
Rust
652 lines
22 KiB
Rust
use std::sync::atomic::Ordering;
|
|
|
|
use crate::entity::EntityBase;
|
|
use pumpkin_data::tag::Taggable;
|
|
use pumpkin_data::{
|
|
attributes::Attributes,
|
|
particle::Particle,
|
|
sound::{Sound, SoundCategory},
|
|
};
|
|
use pumpkin_util::math::vector3::Vector3;
|
|
|
|
use crate::{
|
|
entity::{Entity, player::Player},
|
|
world::World,
|
|
};
|
|
|
|
use crate::net::ClientPlatform;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AttackType {
|
|
Knockback,
|
|
Critical,
|
|
Sweeping,
|
|
Strong,
|
|
Weak,
|
|
MaceSmash,
|
|
}
|
|
|
|
impl AttackType {
|
|
pub fn new(player: &Player, attack_cooldown_progress: f32) -> Self {
|
|
let entity = &player.get_entity();
|
|
|
|
let sprinting = entity.is_sprinting();
|
|
let on_ground = entity.on_ground.load(Ordering::Relaxed);
|
|
let fall_distance = player.living_entity.fall_distance.load();
|
|
let held_item = player.inventory().held_item();
|
|
let is_mace = held_item.item.id == pumpkin_data::item::Item::MACE.id;
|
|
|
|
if is_mace && !on_ground && fall_distance > 1.5 {
|
|
return Self::MaceSmash;
|
|
}
|
|
|
|
let sword = held_item.is_sword();
|
|
let is_bedrock = matches!(player.client.as_ref(), ClientPlatform::Bedrock(_));
|
|
|
|
let is_strong = attack_cooldown_progress > 0.9;
|
|
if sprinting && is_strong {
|
|
return Self::Knockback;
|
|
}
|
|
|
|
if is_strong && !on_ground && fall_distance > 0.0 {
|
|
return Self::Critical;
|
|
}
|
|
|
|
if sword && is_strong && !is_bedrock {
|
|
return Self::Sweeping;
|
|
}
|
|
|
|
if is_strong { Self::Strong } else { Self::Weak }
|
|
}
|
|
}
|
|
|
|
/// Scales a knockback `strength` by a living entity's knockback resistance,
|
|
/// mirroring vanilla `LivingEntity.knockback`: `strength *= 1.0 - resistance`.
|
|
/// A resistance of 1.0 (iron golem, warden, ...) cancels the knockback entirely.
|
|
pub fn knockback_after_resistance(strength: f64, resistance: f64) -> f64 {
|
|
strength * (1.0 - resistance)
|
|
}
|
|
|
|
pub fn handle_knockback(attacker: &Entity, victim: &dyn EntityBase, strength: f64) {
|
|
let resistance = victim.get_living_entity().map_or(0.0, |living| {
|
|
living.get_attribute_value(&Attributes::KNOCKBACK_RESISTANCE)
|
|
});
|
|
let strength = knockback_after_resistance(strength * 0.5, resistance);
|
|
|
|
if strength > 0.0 {
|
|
let yaw = attacker.yaw.load();
|
|
victim.get_entity().knockback(
|
|
strength,
|
|
f64::from((yaw.to_radians()).sin()),
|
|
f64::from(-(yaw.to_radians()).cos()),
|
|
);
|
|
}
|
|
|
|
let velocity = attacker.velocity.load();
|
|
attacker.velocity.store(velocity.multiply(0.6, 1.0, 0.6));
|
|
}
|
|
|
|
pub fn spawn_sweep_particle(attacker_entity: &Entity, world: &World, pos: &Vector3<f64>) {
|
|
let yaw = attacker_entity.yaw.load();
|
|
let d = -f64::from((yaw.to_radians()).sin());
|
|
let e = f64::from((yaw.to_radians()).cos());
|
|
|
|
let scale = 0.5;
|
|
let body_y = f64::from(attacker_entity.height()).mul_add(scale, pos.y);
|
|
|
|
world.spawn_particle(
|
|
Vector3::new(pos.x + d, body_y, pos.z + e),
|
|
Vector3::new(0.0, 0.0, 0.0),
|
|
0.0,
|
|
0,
|
|
Particle::SweepAttack,
|
|
);
|
|
}
|
|
|
|
pub fn player_attack_sound(pos: &Vector3<f64>, world: &World, attack_type: AttackType) {
|
|
match attack_type {
|
|
AttackType::Knockback => {
|
|
world.play_sound(
|
|
Sound::EntityPlayerAttackKnockback,
|
|
SoundCategory::Players,
|
|
pos,
|
|
);
|
|
}
|
|
AttackType::Critical => {
|
|
world.play_sound(Sound::EntityPlayerAttackCrit, SoundCategory::Players, pos);
|
|
}
|
|
AttackType::Sweeping => {
|
|
world.play_sound(Sound::EntityPlayerAttackSweep, SoundCategory::Players, pos);
|
|
}
|
|
AttackType::Strong => {
|
|
world.play_sound(Sound::EntityPlayerAttackStrong, SoundCategory::Players, pos);
|
|
}
|
|
AttackType::Weak => {
|
|
world.play_sound(Sound::EntityPlayerAttackWeak, SoundCategory::Players, pos);
|
|
}
|
|
AttackType::MaceSmash => {
|
|
world.play_sound(Sound::ItemMaceSmashAir, SoundCategory::Players, pos);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Combat calculation rules mirroring vanilla `net.minecraft.world.damagesource.CombatRules`.
|
|
pub struct CombatRules;
|
|
|
|
impl CombatRules {
|
|
pub const MAX_ARMOR: f32 = 20.0;
|
|
pub const ARMOR_PROTECTION_DIVIDER: f32 = 25.0;
|
|
pub const BASE_ARMOR_TOUGHNESS: f32 = 2.0;
|
|
pub const MIN_ARMOR_RATIO: f32 = 0.2;
|
|
|
|
/// Calculates damage after armor reduction, mirroring vanilla `CombatRules.getDamageAfterAbsorb`.
|
|
#[must_use]
|
|
pub fn get_damage_after_absorb(
|
|
damage: f32,
|
|
total_armor: f32,
|
|
armor_toughness: f32,
|
|
breach_level: u32,
|
|
) -> f32 {
|
|
let toughness = Self::BASE_ARMOR_TOUGHNESS + armor_toughness / 4.0;
|
|
let real_armor = (total_armor - damage / toughness)
|
|
.clamp(total_armor * Self::MIN_ARMOR_RATIO, Self::MAX_ARMOR);
|
|
let mut armor_fraction = real_armor / Self::ARMOR_PROTECTION_DIVIDER;
|
|
|
|
if breach_level > 0 {
|
|
let reduction = (breach_level as f32 * 0.15).min(1.0);
|
|
armor_fraction = (armor_fraction * (1.0 - reduction)).clamp(0.0, 1.0);
|
|
}
|
|
|
|
let damage_multiplier = 1.0 - armor_fraction;
|
|
damage * damage_multiplier
|
|
}
|
|
|
|
/// Calculates damage after magic/enchantment armor reduction, mirroring vanilla `CombatRules.getDamageAfterMagicAbsorb`.
|
|
#[must_use]
|
|
pub fn get_damage_after_magic_absorb(damage: f32, total_magic_armor: f32) -> f32 {
|
|
let real_armor = total_magic_armor.clamp(0.0, Self::MAX_ARMOR);
|
|
damage * (1.0 - real_armor / Self::ARMOR_PROTECTION_DIVIDER)
|
|
}
|
|
}
|
|
|
|
pub const RESET_DAMAGE_STATUS_TIME: i64 = 100;
|
|
pub const RESET_COMBAT_STATUS_TIME: i64 = 300;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum FallLocation {
|
|
Generic,
|
|
Ladder,
|
|
Vines,
|
|
WeepingVines,
|
|
TwistingVines,
|
|
Scaffolding,
|
|
OtherClimbable,
|
|
Water,
|
|
}
|
|
|
|
impl FallLocation {
|
|
#[must_use]
|
|
pub const fn language_key(self) -> &'static str {
|
|
match self {
|
|
Self::Generic => "death.fell.accident.generic",
|
|
Self::Ladder => "death.fell.accident.ladder",
|
|
Self::Vines => "death.fell.accident.vines",
|
|
Self::WeepingVines => "death.fell.accident.weeping_vines",
|
|
Self::TwistingVines => "death.fell.accident.twisting_vines",
|
|
Self::Scaffolding => "death.fell.accident.scaffolding",
|
|
Self::OtherClimbable => "death.fell.accident.other_climbable",
|
|
Self::Water => "death.fell.accident.water",
|
|
}
|
|
}
|
|
|
|
pub fn get_current_fall_location(
|
|
living: &crate::entity::living::LivingEntity,
|
|
world: &World,
|
|
) -> Self {
|
|
let pos = living
|
|
.climbing_pos
|
|
.load()
|
|
.unwrap_or_else(|| living.entity.block_pos.load());
|
|
let block = world.get_block(&pos);
|
|
if block == &pumpkin_data::Block::LADDER
|
|
|| block.has_tag(&pumpkin_data::tag::Block::MINECRAFT_TRAPDOORS)
|
|
{
|
|
Self::Ladder
|
|
} else if block == &pumpkin_data::Block::VINE {
|
|
Self::Vines
|
|
} else if block == &pumpkin_data::Block::WEEPING_VINES
|
|
|| block == &pumpkin_data::Block::WEEPING_VINES_PLANT
|
|
{
|
|
Self::WeepingVines
|
|
} else if block == &pumpkin_data::Block::TWISTING_VINES
|
|
|| block == &pumpkin_data::Block::TWISTING_VINES_PLANT
|
|
{
|
|
Self::TwistingVines
|
|
} else if block == &pumpkin_data::Block::SCAFFOLDING {
|
|
Self::Scaffolding
|
|
} else if living.entity.is_in_water() {
|
|
Self::Water
|
|
} else if block.has_tag(&pumpkin_data::tag::Block::MINECRAFT_CLIMBABLE) {
|
|
Self::OtherClimbable
|
|
} else {
|
|
Self::Generic
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct CombatEntry {
|
|
pub damage_type: pumpkin_data::damage::DamageType,
|
|
pub damage: f32,
|
|
pub fall_location: Option<FallLocation>,
|
|
pub fall_distance: f32,
|
|
pub timestamp: i64,
|
|
pub source_id: Option<i32>,
|
|
pub attacker_id: Option<i32>,
|
|
pub attacker_name: Option<pumpkin_util::text::TextComponent>,
|
|
pub attacker_item_name: Option<pumpkin_util::text::TextComponent>,
|
|
pub attacker_is_living: bool,
|
|
pub attacker_is_player: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct CombatTracker {
|
|
entries: Vec<CombatEntry>,
|
|
last_damage_time: i64,
|
|
combat_start_time: i64,
|
|
combat_end_time: i64,
|
|
in_combat: bool,
|
|
taking_damage: bool,
|
|
}
|
|
|
|
impl CombatTracker {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn record_damage(
|
|
&mut self,
|
|
current_tick: i64,
|
|
is_alive: bool,
|
|
fall_distance: f32,
|
|
fall_location: FallLocation,
|
|
damage_type: pumpkin_data::damage::DamageType,
|
|
damage: f32,
|
|
source: Option<&dyn EntityBase>,
|
|
cause: Option<&dyn EntityBase>,
|
|
) {
|
|
self.recheck_status(current_tick, is_alive);
|
|
|
|
let attacker = cause.or(source);
|
|
let attacker_id = attacker.map(|e| e.get_entity().entity_id);
|
|
let attacker_name = attacker.map(super::EntityBase::get_display_name);
|
|
let attacker_is_living = attacker.is_some_and(|e| e.get_living_entity().is_some());
|
|
let attacker_is_player = attacker.is_some_and(|e| {
|
|
e.get_entity().entity_type == &pumpkin_data::entity::EntityType::PLAYER
|
|
});
|
|
|
|
// Check for custom named item in attacker's main hand
|
|
let attacker_item_name =
|
|
attacker.and_then(|e| {
|
|
if let Some(player) = e.get_player() {
|
|
let hand_stack = player
|
|
.inventory()
|
|
.get_stack_in_hand(pumpkin_util::Hand::Right);
|
|
if !hand_stack.is_empty()
|
|
&& let Some(custom_name) = hand_stack
|
|
.get_data_component::<pumpkin_data::data_component_impl::CustomNameImpl>()
|
|
{
|
|
return Some(custom_name.name.clone());
|
|
}
|
|
} else if let Some(living) = e.get_living_entity() {
|
|
let equip_guard = living
|
|
.entity_equipment
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if let Some(stack) = equip_guard
|
|
.equipment
|
|
.get(&pumpkin_data::data_component_impl::EquipmentSlot::MAIN_HAND)
|
|
&& !stack.is_empty()
|
|
&& let Some(custom_name) = stack
|
|
.get_data_component::<pumpkin_data::data_component_impl::CustomNameImpl>()
|
|
{
|
|
return Some(custom_name.name.clone());
|
|
}
|
|
}
|
|
None
|
|
});
|
|
|
|
let entry = CombatEntry {
|
|
damage_type,
|
|
damage,
|
|
fall_location: Some(fall_location),
|
|
fall_distance,
|
|
timestamp: current_tick,
|
|
source_id: source.map(|e| e.get_entity().entity_id),
|
|
attacker_id,
|
|
attacker_name,
|
|
attacker_item_name,
|
|
attacker_is_living,
|
|
attacker_is_player,
|
|
};
|
|
|
|
self.entries.push(entry);
|
|
self.last_damage_time = current_tick;
|
|
self.taking_damage = true;
|
|
|
|
if !self.in_combat && is_alive && attacker_is_living {
|
|
self.in_combat = true;
|
|
self.combat_start_time = current_tick;
|
|
self.combat_end_time = self.combat_start_time;
|
|
}
|
|
}
|
|
|
|
pub fn recheck_status(&mut self, current_tick: i64, is_alive: bool) {
|
|
let reset = if self.in_combat {
|
|
RESET_COMBAT_STATUS_TIME
|
|
} else {
|
|
RESET_DAMAGE_STATUS_TIME
|
|
};
|
|
if self.taking_damage && (!is_alive || current_tick - self.last_damage_time > reset) {
|
|
self.taking_damage = false;
|
|
self.in_combat = false;
|
|
self.combat_end_time = current_tick;
|
|
self.entries.clear();
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn get_combat_duration(&self, current_tick: i64) -> i64 {
|
|
if self.in_combat {
|
|
current_tick - self.combat_start_time
|
|
} else {
|
|
self.combat_end_time - self.combat_start_time
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn is_in_combat(&self) -> bool {
|
|
self.in_combat
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn has_player_attacker(&self) -> bool {
|
|
self.entries.iter().any(|e| e.attacker_is_player)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get_killer_entry(&self) -> Option<&CombatEntry> {
|
|
let mut best_living: Option<&CombatEntry> = None;
|
|
let mut best_player: Option<&CombatEntry> = None;
|
|
let mut max_living_dmg = 0.0f32;
|
|
let mut max_player_dmg = 0.0f32;
|
|
|
|
for entry in &self.entries {
|
|
if entry.attacker_is_player && (best_player.is_none() || entry.damage > max_player_dmg)
|
|
{
|
|
max_player_dmg = entry.damage;
|
|
best_player = Some(entry);
|
|
}
|
|
if entry.attacker_is_living && (best_living.is_none() || entry.damage > max_living_dmg)
|
|
{
|
|
max_living_dmg = entry.damage;
|
|
best_living = Some(entry);
|
|
}
|
|
}
|
|
|
|
if best_player.is_some() && max_player_dmg >= max_living_dmg / 3.0 {
|
|
best_player
|
|
} else {
|
|
best_living
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get_death_message(
|
|
&self,
|
|
victim_name: pumpkin_util::text::TextComponent,
|
|
) -> pumpkin_util::text::TextComponent {
|
|
if self.entries.is_empty() {
|
|
return pumpkin_util::text::TextComponent::translate_cross(
|
|
"death.attack.generic",
|
|
"death.attack.generic",
|
|
[victim_name],
|
|
);
|
|
}
|
|
|
|
let killing_blow = &self.entries[self.entries.len() - 1];
|
|
let killing_damage_type = killing_blow.damage_type;
|
|
let knock_off_entry = self.get_most_significant_fall();
|
|
|
|
match killing_damage_type.death_message_type {
|
|
pumpkin_data::damage::DeathMessageType::FallVariants => {
|
|
if let Some(knock_off) = knock_off_entry {
|
|
Self::get_fall_message(victim_name, knock_off, killing_blow)
|
|
} else {
|
|
let loc = killing_blow.fall_location.unwrap_or(FallLocation::Generic);
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
loc.language_key(),
|
|
loc.language_key(),
|
|
[victim_name],
|
|
)
|
|
}
|
|
}
|
|
pumpkin_data::damage::DeathMessageType::IntentionalGameDesign => {
|
|
let death_msg = format!("death.attack.{}", killing_damage_type.message_id);
|
|
let link = pumpkin_util::text::TextComponent::text("[")
|
|
.add_child(pumpkin_util::text::TextComponent::translate_cross(
|
|
format!("{death_msg}.link"),
|
|
format!("{death_msg}.link"),
|
|
[],
|
|
))
|
|
.add_child(pumpkin_util::text::TextComponent::text("]"))
|
|
.click_event(pumpkin_util::text::click::ClickEvent::OpenUrl {
|
|
url: "https://bugs.mojang.com/browse/MCPE-28723".into(),
|
|
})
|
|
.hover_event(pumpkin_util::text::hover::HoverEvent::show_text(
|
|
pumpkin_util::text::TextComponent::text("MCPE-28723"),
|
|
));
|
|
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
format!("{death_msg}.message"),
|
|
format!("{death_msg}.message"),
|
|
[victim_name, link],
|
|
)
|
|
}
|
|
pumpkin_data::damage::DeathMessageType::Default => {
|
|
self.get_default_death_message(victim_name, killing_blow)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_most_significant_fall(&self) -> Option<&CombatEntry> {
|
|
let mut result = None;
|
|
let mut alternative = None;
|
|
let mut alt_damage = 0.0f32;
|
|
let mut best_fall = 0.0f32;
|
|
|
|
for i in 0..self.entries.len() {
|
|
let entry = &self.entries[i];
|
|
let previous = (i > 0).then(|| &self.entries[i - 1]);
|
|
let damage_type = entry.damage_type;
|
|
let is_fake_fall = damage_type
|
|
.has_tag(&pumpkin_data::tag::DamageType::MINECRAFT_ALWAYS_MOST_SIGNIFICANT_FALL);
|
|
let fall_distance = if is_fake_fall {
|
|
f32::MAX
|
|
} else {
|
|
entry.fall_distance
|
|
};
|
|
|
|
if (damage_type.has_tag(&pumpkin_data::tag::DamageType::MINECRAFT_IS_FALL)
|
|
|| is_fake_fall)
|
|
&& fall_distance > 0.0
|
|
&& (result.is_none() || fall_distance > best_fall)
|
|
{
|
|
if i > 0 {
|
|
result = previous;
|
|
} else {
|
|
result = Some(entry);
|
|
}
|
|
best_fall = fall_distance;
|
|
}
|
|
|
|
if entry.fall_location.is_some() && (alternative.is_none() || entry.damage > alt_damage)
|
|
{
|
|
alternative = Some(entry);
|
|
alt_damage = entry.damage;
|
|
}
|
|
}
|
|
|
|
if best_fall > 5.0 && result.is_some() {
|
|
result
|
|
} else if alt_damage > 5.0 && alternative.is_some() {
|
|
alternative
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn get_fall_message(
|
|
victim_name: pumpkin_util::text::TextComponent,
|
|
knock_off_entry: &CombatEntry,
|
|
killing_blow: &CombatEntry,
|
|
) -> pumpkin_util::text::TextComponent {
|
|
let knock_off_type = knock_off_entry.damage_type;
|
|
if !knock_off_type.has_tag(&pumpkin_data::tag::DamageType::MINECRAFT_IS_FALL)
|
|
&& !knock_off_type
|
|
.has_tag(&pumpkin_data::tag::DamageType::MINECRAFT_ALWAYS_MOST_SIGNIFICANT_FALL)
|
|
{
|
|
let killer_name = killing_blow.attacker_name.as_ref();
|
|
let attacker_name = knock_off_entry.attacker_name.as_ref();
|
|
let attacker_item = knock_off_entry.attacker_item_name.clone();
|
|
|
|
if let Some(attacker_name) = attacker_name
|
|
&& (killer_name.is_none() || killer_name != Some(attacker_name))
|
|
{
|
|
Self::get_message_for_assisted_fall(
|
|
victim_name,
|
|
attacker_name.clone(),
|
|
attacker_item,
|
|
"death.fell.assist.item",
|
|
"death.fell.assist",
|
|
)
|
|
} else if let Some(killer_name) = killer_name {
|
|
Self::get_message_for_assisted_fall(
|
|
victim_name,
|
|
killer_name.clone(),
|
|
killing_blow.attacker_item_name.clone(),
|
|
"death.fell.finish.item",
|
|
"death.fell.finish",
|
|
)
|
|
} else {
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
"death.fell.killer",
|
|
"death.fell.killer",
|
|
[victim_name],
|
|
)
|
|
}
|
|
} else {
|
|
let loc = knock_off_entry
|
|
.fall_location
|
|
.unwrap_or(FallLocation::Generic);
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
loc.language_key(),
|
|
loc.language_key(),
|
|
[victim_name],
|
|
)
|
|
}
|
|
}
|
|
|
|
fn get_message_for_assisted_fall(
|
|
victim_name: pumpkin_util::text::TextComponent,
|
|
attacker_name: pumpkin_util::text::TextComponent,
|
|
attacker_item: Option<pumpkin_util::text::TextComponent>,
|
|
message_with_item: &'static str,
|
|
message_without_item: &'static str,
|
|
) -> pumpkin_util::text::TextComponent {
|
|
if let Some(item_name) = attacker_item {
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
message_with_item,
|
|
message_with_item,
|
|
[victim_name, attacker_name, item_name],
|
|
)
|
|
} else {
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
message_without_item,
|
|
message_without_item,
|
|
[victim_name, attacker_name],
|
|
)
|
|
}
|
|
}
|
|
|
|
fn get_default_death_message(
|
|
&self,
|
|
victim_name: pumpkin_util::text::TextComponent,
|
|
killing_blow: &CombatEntry,
|
|
) -> pumpkin_util::text::TextComponent {
|
|
let damage_type = killing_blow.damage_type;
|
|
let msg_id = damage_type.message_id;
|
|
|
|
if let Some(attacker_name) = &killing_blow.attacker_name {
|
|
if let Some(item_name) = &killing_blow.attacker_item_name {
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
format!("death.attack.{msg_id}.item"),
|
|
format!("death.attack.{msg_id}.item"),
|
|
[victim_name, attacker_name.clone(), item_name.clone()],
|
|
)
|
|
} else {
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
format!("death.attack.{msg_id}.player"),
|
|
format!("death.attack.{msg_id}.player"),
|
|
[victim_name, attacker_name.clone()],
|
|
)
|
|
}
|
|
} else if let Some(killer) = self.get_killer_entry()
|
|
&& let Some(killer_name) = &killer.attacker_name
|
|
{
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
format!("death.attack.{msg_id}.player"),
|
|
format!("death.attack.{msg_id}.player"),
|
|
[victim_name, killer_name.clone()],
|
|
)
|
|
} else {
|
|
pumpkin_util::text::TextComponent::translate_cross(
|
|
format!("death.attack.{msg_id}"),
|
|
format!("death.attack.{msg_id}"),
|
|
[victim_name],
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn zero_resistance_keeps_full_strength() {
|
|
assert_eq!(knockback_after_resistance(0.4, 0.0), 0.4);
|
|
}
|
|
|
|
#[test]
|
|
fn full_resistance_cancels_knockback() {
|
|
// Iron golem / warden have KNOCKBACK_RESISTANCE == 1.0.
|
|
assert_eq!(knockback_after_resistance(0.4, 1.0), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn partial_resistance_scales_strength() {
|
|
// Ravager has KNOCKBACK_RESISTANCE == 0.75.
|
|
assert!((knockback_after_resistance(0.4, 0.75) - 0.1).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn over_full_resistance_is_negative_so_callers_skip_it() {
|
|
// Stacked armour modifiers can push resistance above 1.0; the result is
|
|
// negative and callers guard on `strength > 0.0`.
|
|
assert!(knockback_after_resistance(0.4, 1.2) < 0.0);
|
|
}
|
|
}
|