Merge remote-tracking branch 'upstream/main' into HEAD

This commit is contained in:
levzzz
2026-10-27 19:21:10 +05:00
38 changed files with 685 additions and 189 deletions
@@ -567,7 +567,9 @@ fn print_heart(
/// The damage of a mob's direct hit on the player (others count as none).
fn player_damage(e: &MobEvent) -> f32 {
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount,
_ => 0.0,
}
}
@@ -578,7 +580,9 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
return false;
};
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius, _) => {
let d = Vec3::new(at[0], at[1], at[2])
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
)),
MotionEvent::Emission {
state,
in_water: true,
muffled: true,
..
} => Some(format!("muffled-step {}", state.0)),
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
+36 -1
View File
@@ -4,7 +4,7 @@
//! conditions advancements use).
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
};
@@ -753,6 +753,41 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
/// victim condition matched by a distinct victim, and the count of entity types.
pub fn killed_by_arrow(
c: &Value,
me: &EntityView,
victims: &[EntityView],
weapon: Option<&ItemView>,
w: &dyn World,
) -> bool {
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
Entities {
this: Some(e),
origin: me.position,
tool: None,
block_pos: None,
}
}
if !conditions(&c["player"], &ctx(me, me), w) {
return false;
}
if !c["fired_from_weapon"].is_null()
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
{
return false;
}
let mut left: Vec<&EntityView> = victims.iter().collect();
for want in c["victims"].as_array().into_iter().flatten() {
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
return false;
};
left.remove(i);
}
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
range_i(&c["unique_entity_types"], kinds.len() as i64)
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
+3
View File
@@ -159,6 +159,8 @@ pub struct ArrowState {
pub pickup: Pickup,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
@@ -208,6 +210,7 @@ impl ArrowState {
last_state: None,
pickup: Pickup::Disallowed,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
pierce: 0,
pierced: Vec::new(),
+11
View File
@@ -211,6 +211,17 @@ impl Contents {
self.weight.n >= self.weight.d
}
/// `BundleItem.isBarVisible`, `getBarWidth` and `getBarColor`: the fullness bar
/// (width, RGB), blue until the bundle is full, then red.
pub fn bar(&self) -> Option<(i32, u32)> {
if self.weight.n <= 0 {
return None;
}
let width = (1 + self.weight.n * 12 / self.weight.d).min(13) as i32;
let color = if self.is_full() { 0xFF5454 } else { 0x7087FF };
Some((width, color))
}
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
let data = bundle.data_mut();
+34 -20
View File
@@ -36,6 +36,7 @@ mod consume;
mod control;
mod copper_golems;
mod creaking;
mod creative_hotbar;
mod creatures;
mod crossbow;
pub(crate) mod cushions;
@@ -1493,6 +1494,9 @@ struct Game<'a> {
armor: [Option<ItemStack>; 4],
/// The off hand (Inventory slot 40, saved as slot 150; menu slot 504 here).
offhand: Option<ItemStack>,
/// `ItemStack.popTime` of the nine hotbar stacks, then the off hand: set to 5
/// when a slot gains items, counted down each tick.
hotbar_pop: [u8; 10],
/// The hand an item use acts with (`InteractionHand`): the off hand only
/// for its turn in `use_item` and while its item is being used.
hand: Hand,
@@ -4927,15 +4931,13 @@ impl Game<'_> {
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
MotionEvent::Emission {
state, in_water, ..
} => {
MotionEvent::Emission { state, muffled, .. } => {
let sounds = self.res.facts().get(state).sounds.clone();
// Entity.playStepSound: the block's step sound at 0.15 volume,
// muffled under the swim sound in water. Whether it plays is
// Player.getMovementEmission's call (shift on the ground),
// not the pose: a forced crouch still steps.
let (volume, pitch) = if in_water {
let (volume, pitch) = if muffled {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
(sounds.volume * 0.15, sounds.pitch)
@@ -5562,7 +5564,10 @@ impl Game<'_> {
let decorations = hud_overlays::ItemDecorations {
count: stack.count,
count_text,
bar: hud_overlays::damage_bar(stack.damage as i32, max_damage),
// BundleItem overrides the bar with its fullness.
bar: horizon_client::bundle::Contents::of(self.res.items(), stack)
.map(|c| c.bar())
.unwrap_or_else(|| hud_overlays::damage_bar(stack.damage as i32, max_damage)),
cooldown: self.cooldowns.percent(&cooldown, partial),
};
let mut draws = vec![hud_layout::GuiDraw::Push(vec![
@@ -5667,12 +5672,17 @@ impl Game<'_> {
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
}
if !spectator {
let partial = self.clock.fraction() as f32;
let inputs = hud_layout::HotbarInputs {
selected: self.selected as i32,
right_handed: !self.settings.main_hand_left,
offhand: self.offhand.is_some(),
slots: std::array::from_fn(|i| self.inventory[i].as_ref().map(|_| 0.0)),
offhand_pop: 0.0,
slots: std::array::from_fn(|i| {
self.inventory[i]
.as_ref()
.map(|_| (f32::from(self.hotbar_pop[i]) - partial).max(0.0))
}),
offhand_pop: (f32::from(self.hotbar_pop[9]) - partial).max(0.0),
attack: (indicator == 2).then_some(strength),
};
let draws = hud_layout::hotbar(wi, hi, &inputs);
@@ -5759,17 +5769,9 @@ impl Game<'_> {
} else {
hud_layout::HeartKind::Normal
};
let eye = p.position.y + f64::from(p.dimensions.eye_height);
let under_water = self
.res
.facts()
.get(self.world.block(
p.position.x.floor() as i32,
eye.floor() as i32,
p.position.z.floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water;
// Hud.extractAirBubbles: `isEyeInFluidMatching`, so the eyes are below
// the surface, not merely in the water block.
let under_water = p.is_eye_in_fluid(horizon_client::motion::WATER);
let inputs = hud_layout::HealthInputs {
tick: self.ticks as i32,
millis: self.started.elapsed().as_millis() as i64,
@@ -10655,7 +10657,9 @@ impl Game<'_> {
fn tick_sleep(&mut self) {
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
if let Some(head) = self.sleeping {
if self.keys.contains(&KeyCode::ShiftLeft)
// Vanilla has no Shift wake-up: the chat screen's capitals must
// not get the sleeper up.
if (self.screen != Screen::Chat && self.keys.contains(&KeyCode::ShiftLeft))
|| self
.player
.as_ref()
@@ -11831,7 +11835,7 @@ impl Game<'_> {
| KeyCode::Digit9
if self.screen == Screen::Playing =>
{
self.selected = match key {
let slot = match key {
KeyCode::Digit1 => 0,
KeyCode::Digit2 => 1,
KeyCode::Digit3 => 2,
@@ -11842,6 +11846,15 @@ impl Game<'_> {
KeyCode::Digit8 => 7,
_ => 8,
};
// Minecraft.handleKeybinds: a creative player holding the load
// or save toolbar key loads or saves a toolbar instead (load
// first when both are held).
let load = self.mapped_key_down("key.loadToolbarActivator");
if self.creative && (load || self.mapped_key_down("key.saveToolbarActivator")) {
self.hotbar_load_or_save(slot, load);
} else {
self.selected = slot;
}
}
_ => {}
}
@@ -13378,6 +13391,7 @@ impl<'a> Game<'a> {
title: None,
armor: [None, None, None, None],
offhand: None,
hotbar_pop: [0; 10],
hand: Hand::Main,
shield_cooldown: 0,
cooldowns: Default::default(),
@@ -719,6 +719,19 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
victims: &[EntityView],
weapon: Option<&ItemStack>,
) {
let me = self.player_entity();
let victims = victims.to_vec();
let weapon = weapon.map(item_view);
self.trigger("minecraft:killed_by_arrow", move |c, w| {
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), w)
});
}
/// `LevitationTrigger`: how far the player rose from where it started
/// levitating, and for how long.
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
+6 -1
View File
@@ -1144,7 +1144,12 @@ impl Game<'_> {
BLACK,
);
}
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
// BookSignScreen: `owner.getName()`, the account's name on a server.
let owner = self
.remote
.as_ref()
.map_or(super::chat::PLAYER_NAME, |r| r.profile_name.as_str());
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", owner);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
atlas,
+78 -2
View File
@@ -81,6 +81,8 @@ pub struct UiState {
pub chat_screen: Option<ChatScreen>,
/// When the chat screen opened (its input took the focus: the cursor blink).
pub chat_opened: Option<std::time::Instant>,
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
pub in_bed_chat: bool,
pub registries: Registries,
pub rules: GameRules,
pub clock: WorldClock,
@@ -452,6 +454,12 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_chat(&mut self) {
// InBedChatScreen.onClose: leaving the screen means getting up; the
// screen goes once the player has woken (`tick_in_bed_chat`).
if self.ui.in_bed_chat && self.sleeping.is_some() {
self.wake_up();
return;
}
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
if self.screen == Screen::Chat {
@@ -459,6 +467,35 @@ impl Game<'_> {
self.capture(true);
}
}
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
/// screen if something was typed.
pub(super) fn tick_in_bed_chat(&mut self) {
if self.screen != Screen::Chat {
self.ui.in_bed_chat = false;
}
if self.ui.in_bed_chat {
if self.sleeping.is_none() {
self.ui.in_bed_chat = false;
let text = self
.ui
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat();
if !text.is_empty() {
self.open_chat(&text);
}
}
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
self.open_chat("");
self.ui.in_bed_chat = self.screen == Screen::Chat;
}
}
/// The Leave Bed button of `InBedChatScreen`: its bounds.
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
@@ -571,7 +608,15 @@ impl Game<'_> {
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => self.close_chat(),
ChatAction::Submit(message) => {
self.close_chat();
// InBedChatScreen sends without closing (`closeOnSubmit` is
// false): the box empties and the screen stays.
if self.ui.in_bed_chat && self.sleeping.is_some() {
screen.suggest_command(&m, &c, "");
self.ui.chat.reset_scroll();
self.ui.chat_screen = Some(screen);
} else {
self.close_chat();
}
if !message.is_empty() {
self.handle_chat_input(&message);
}
@@ -673,7 +718,8 @@ impl Game<'_> {
{
return;
}
let shift = self.keys.contains(&KeyCode::ShiftLeft);
// ChatScreen.mouseScrolled: `hasShiftDown`, either Shift key.
let shift = self.has_shift_down();
let amount = amount.clamp(-1.0, 1.0) * if shift { 1.0 } else { 7.0 };
self.ui.chat.scroll_by(amount as i32, true);
}
@@ -687,6 +733,16 @@ impl Game<'_> {
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if self.ui.in_bed_chat && self.sleeping.is_some() {
let width = self.renderer.as_ref().map_or(1280.0, |r| {
HudBatch::new([r.config.width, r.config.height]).width
});
let (x, y, w, h) = Self::leave_bed_button(width, height);
if horizon_client::screen_draw::inside((cursor.0 as i32, cursor.1 as i32), x, y, w, h) {
self.wake_up();
return;
}
}
if let Some(screen) = self.ui.chat_screen.as_mut() {
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
return;
@@ -815,6 +871,26 @@ impl Game<'_> {
let draws = screen.draw(&m, mouse, blink, background);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
if self.ui.in_bed_chat && self.sleeping.is_some() {
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
&m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
}
// The hovered message part's tooltip, one row per line of its text.
if let Some(hover) = self.ui.chat.hover_at(
&self.gui.atlas.font,
+4
View File
@@ -42,6 +42,10 @@ pub(super) struct CombatTracker {
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
pub(super) player_attacker: Option<([f64; 3], f32)>,
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
+105 -69
View File
@@ -2210,7 +2210,8 @@ impl Game<'_> {
let dmax = [dest[0] + size[0], dest[1] + size[1], dest[2] + size[2]];
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
let moving = ctx.has("move");
if !ctx.has("force") && overlap {
// Mode.canOverlap: only the normal mode refuses overlapping regions.
if !ctx.has("force") && !moving && overlap {
return Err(err("commands.clone.overlap", &[]));
}
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
@@ -2419,26 +2420,54 @@ impl Game<'_> {
let levels = ctx.has("levels");
let unit = if levels { "levels" } else { "points" };
if ctx.has("query") {
let _ = self.entities(ctx, "target", true)?;
let e = &self.experience;
let value = if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32) as i32
};
let target = self.entities(ctx, "target", true)?[0];
// ExperienceCommand.queryExperience: the target's own level or points.
let value = self
.for_player(target, |g| {
let e = &g.experience;
if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32)
as i32
}
})
.unwrap_or(0);
self.system_message(Text::translate(
&format!("commands.experience.query.{unit}"),
&[
Text::literal(self.acting_player_name()),
Text::literal(value.to_string()),
],
&[self.target_name(target), Text::literal(value.to_string())],
));
return Ok(value);
}
let targets = self.entities(ctx, "target", true)?;
let amount = Self::int(ctx, "amount").unwrap_or(0);
let op = if ctx.has("add") { "add" } else { "set" };
if op == "add" {
let add = ctx.has("add");
let op = if add { "add" } else { "set" };
// Every target changes (a set of points can be refused for some).
let changed: Vec<Target> = targets
.iter()
.copied()
.filter(|&t| {
self.for_player(t, |g| g.change_experience(add, levels, amount)) == Some(true)
})
.collect();
if changed.is_empty() {
return Err(err("commands.experience.set.points.invalid", &[]));
}
let (key, who) = match changed[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(changed.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.experience.{op}.{unit}.success.{key}"),
&[Text::literal(amount.to_string()), who],
));
Ok(changed.len() as i32)
}
/// ExperienceCommand's add and set on the player in place: false when
/// the points to set do not fit the level.
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
if add {
if levels {
self.experience.level = (self.experience.level + amount).max(0);
if self.experience.level == 0 && amount < 0 {
@@ -2452,26 +2481,11 @@ impl Game<'_> {
} else {
let needed = horizon_client::entities::Experience::needed(self.experience.level);
if amount > needed - 1 {
return Err(err("commands.experience.set.points.invalid", &[]));
return false;
}
self.experience.progress = amount as f32 / needed as f32;
}
let key = format!(
"commands.experience.{op}.{unit}.success.{}",
if targets.len() == 1 {
"single"
} else {
"multiple"
}
);
self.feedback(Text::translate(
&key,
&[
Text::literal(amount.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(targets.len() as i32)
true
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
@@ -2556,21 +2570,26 @@ impl Game<'_> {
};
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
let pitch = rot[1].clamp(-90.0, 90.0);
// SetSpawnCommand: a forced point in the source's level.
if self.dim == Dim::Overworld {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
} else {
self.spawn = None;
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos,
yaw,
forced: true,
// SetSpawnCommand: a forced point in the source's level, for each target.
let level = self.dim;
for &t in &targets {
self.for_player(t, |g| {
if level == Dim::Overworld {
g.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
g.respawn_config = None;
} else {
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
}
});
}
let dim = Text::literal(self.dim.id());
@@ -2750,6 +2769,44 @@ impl Game<'_> {
},
};
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
let counting = max == 0;
// ClearInventoryCommands.clearInventory: each player on their own.
let mut removed = 0i32;
let mut found = Vec::new();
for &t in &targets {
let n = self
.for_player(t, |g| g.clear_matching(&predicate, max))
.unwrap_or(0);
removed += n;
if n != 0 {
found.push(t);
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[self.target_name(targets[0])],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
let (key, who) = match found[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(found.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.clear.{kind}.{key}"),
&[Text::literal(removed.to_string()), who],
));
Ok(removed)
}
/// Inventory.clearOrCountMatchingItems for the player in place: `max`
/// of the matching items go (all when negative, none and only counted
/// when 0). Returns how many matched.
fn clear_matching(&mut self, predicate: &ItemInput, max: i32) -> i32 {
let counting = max == 0;
// Inventory slots 0..40: main, armor feet to head, off hand;
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
@@ -2765,10 +2822,7 @@ impl Game<'_> {
};
let matching: Vec<bool> = all(self)
.iter()
.map(|s| {
s.as_ref()
.is_some_and(|s| self.stack_matches(&predicate, s))
})
.map(|s| s.as_ref().is_some_and(|s| self.stack_matches(predicate, s)))
.collect();
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
slots.extend(self.inventory.iter_mut());
@@ -2798,25 +2852,7 @@ impl Game<'_> {
}
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[Text::literal(self.acting_player_name())],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
self.feedback(Text::translate(
&format!("commands.clear.{kind}.single"),
&[
Text::literal(removed.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(removed)
removed
}
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
@@ -0,0 +1,39 @@
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
use super::Game;
use horizon_client::{hotbar, keybinds, text::Text};
impl Game<'_> {
/// Whether the key bound to `mapping` is held.
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
keybinds::key_code(self.settings.keys.get(mapping))
.is_some_and(|k| self.physical_keys.contains(&k))
}
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
/// `index`: load puts the saved row in the hotbar (the creative slot
/// packets follow from the inventory change), save stores the hotbar and
/// says so on the action bar.
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
let path = Self::options_path().with_file_name("hotbar.nbt");
let known = |id: &str| self.res.items().get(id).is_some();
let mut rows = hotbar::load(&path, &known);
if load {
for (slot, stack) in rows[index].iter().enumerate() {
self.inventory[slot] = stack.clone();
}
return;
}
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
let _ = hotbar::save(&path, &rows);
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
let message = Text::translate(
"inventory.hotbarSaved",
&[
key("key.loadToolbarActivator"),
key(&format!("key.hotbar.{}", index + 1)),
],
);
self.titles.set_overlay(message, false);
}
}
+26 -2
View File
@@ -1057,6 +1057,18 @@ impl Game<'_> {
MobEvent::AttackPlayer(amount, from) => {
self.mob_hits_player(amount, from, "minecraft:mob_attack");
}
// doHurtTarget: the effect only when hurtServer took damage.
MobEvent::AttackPlayerWithEffect {
amount,
from,
effect,
ticks,
amplifier,
} => {
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
self.give_player_effect(effect, ticks, amplifier);
}
}
MobEvent::DamagePlayer { amount, from, kind } => {
self.mob_hits_player(amount, from, kind);
}
@@ -2674,6 +2686,17 @@ impl Game<'_> {
};
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
}
// AbstractArrow.onHitEntity: KILLED_BY_ARROW for a kill
// (piercing arrows are not modelled).
if hurt
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
&& !m.alive()
{
let victim = self.mob_entity(m);
let weapon =
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref());
self.trigger_killed_by_arrow(&[victim], weapon);
}
}
hurt
}
@@ -3269,8 +3292,9 @@ impl Game<'_> {
.items()
.get(&item)
.and_then(|i| i.food.as_ref())
.map_or(2.0, |f| f.0 as f32);
// TamableAnimal.feed: playEatingSound, which wolves leave empty.
.map_or(2.0, |f| f.0 as f32 * 2.0);
// TamableAnimal.feed(.., 2.0F, 2.0F): heals twice the nutrition,
// then playEatingSound, which wolves leave empty.
m.health = (m.health + heal).min(m.attrs.max_health);
consume = true;
}
@@ -219,6 +219,7 @@ impl Game<'_> {
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(ammo);
a.set_owner(Some(Who::Player));
a.fired_from_weapon = Some(weapon.clone());
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
@@ -235,6 +236,11 @@ impl Game<'_> {
.play("minecraft:item.crossbow.shoot", Some(eye), 1.0, pitch);
self.damage_held(if rocket { 3 } else { 1 });
}
// CrossbowItem.performShooting: SHOT_CROSSBOW with the crossbow, which
// is an empty stack (no match) when the shots broke it.
if let Some(stack) = self.held().cloned() {
self.trigger_item("minecraft:shot_crossbow", &stack);
}
}
}
+3 -2
View File
@@ -244,7 +244,8 @@ impl Game<'_> {
if on { "visible" } else { "hidden" }
)
};
let remote = self.remote.is_some();
// `DebugScreenOverlay`: the single-player variants for a local server.
let remote = !self.has_singleplayer_server();
let c = &self.debug_charts;
[
format!(
@@ -463,7 +464,7 @@ impl Game<'_> {
height,
self.settings.max_fps,
1000.0 / self.clock.rate(),
self.remote.is_some(),
!self.has_singleplayer_server(),
);
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
scale, scale,
+10 -1
View File
@@ -1465,7 +1465,16 @@ impl Game<'_> {
}
}
let d2 = (pp.x - pos.x).powi(2) + (pp.y - pos.y).powi(2) + (pp.z - pos.z).powi(2);
if d2 < 4096.0 {
// Entity.hasLineOfSight: eye to eye (the dragon's eye is 0.85 of
// its 8 block height), blocks only.
let sees = d2 < 4096.0 && {
let eye = Vec3::new(pos.x, pos.y + 6.8, pos.z);
let target_eye =
tracked.map_or(pp.y + 1.62, |p| p.bounds.max_position().y - 0.18);
self.mob_world_base(&[])
.line_of_sight(eye, Vec3::new(pp.x, target_eye, pp.z))
};
if sees {
*charge += 1;
let d = self.dragon.as_ref().expect("dragon");
let r = d.yaw.to_radians();
+8
View File
@@ -1932,6 +1932,14 @@ impl Game<'_> {
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
.collect();
m.extra.effect_ambient = visible().all(|e| e.ambient);
// Entity.isInvisible: the invisibility effect hides the body (the
// model pass, the name tag rules and `trader::is_invisible` read it).
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
if let Some(e) = g.effects.get("invisibility") {
m.extra
.effects
.push(("minecraft:invisibility", e.duration, e.amplifier));
}
// Lying in its bed (LivingEntity.getBedOrientation).
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
@@ -81,6 +81,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
matches!(
e,
MobEvent::AttackPlayer(..)
| MobEvent::AttackPlayerWithEffect { .. }
| MobEvent::DamagePlayer { .. }
| MobEvent::Push {
target: Who::Player,
+13
View File
@@ -40,6 +40,8 @@ pub(super) struct Strike {
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
magic: bool,
from: [f64; 3],
/// `getSecondsToDisableBlocking` of the attacker's weapon.
disable_blocking: f32,
sweep: Option<[f64; 3]>,
/// `causeExtraKnockback`: its strength and direction (the attacker's
/// yaw), and whether the hit is a sprinting knockback attack.
@@ -129,6 +131,14 @@ impl Game<'_> {
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
self.swing_arm();
// LivingEntity.getSecondsToDisableBlocking: the held weapon's value,
// none while another item is in use.
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
Some(Strike {
attacker: self.lan_acting,
amount,
@@ -136,6 +146,7 @@ impl Game<'_> {
crit,
magic,
from,
disable_blocking,
sweep,
knockback: (strength, xd, zd),
knockback_attack,
@@ -250,7 +261,9 @@ impl Game<'_> {
// Player.attack: the hurt (with its knockback away from the
// attacker), then causeExtraKnockback along the attacker's yaw.
let land = |g: &mut Self| {
g.combat.player_attacker = Some((s.from, s.disable_blocking));
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
g.combat.player_attacker = None;
let (strength, xd, zd) = s.knockback;
if hurt && strength > 0.0 {
g.knock_back_player(f64::from(strength), xd, zd);
+2 -3
View File
@@ -868,9 +868,8 @@ impl Game<'_> {
(d * 2.0 + 0.5) as i8
}
};
let y = f64::from(yaw);
let adjusted = if y < 0.0 { y - 8.0 } else { y + 8.0 };
let rot = ((adjusted * 16.0 / 360.0) as i32 & 15) as u8;
// calculateRotation: the arrow spins in the Nether.
let rot = decoration_rotation(nether, game_time, f64::from(yaw));
let sprite = if inside {
Some("map/decorations/player")
} else if dx.abs() < 320.0 && dz.abs() < 320.0 {
+21 -2
View File
@@ -1215,9 +1215,28 @@ impl Game<'_> {
}
}
}
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row.
/// `Screen.showsActiveEffects`: the survival and creative inventories
/// draw their own effect list when `EffectsInInventory.canSeeEffects`
/// (32 or more pixels right of the screen).
fn screen_shows_active_effects(&self, batch: &HudBatch) -> bool {
let (ox, image_width) = match self.screen {
Screen::Inventory
if self.container.is_none() && self.craft_size == 2 && !self.mount_active() =>
{
(self.inventory_origin(batch).0, 176)
}
Screen::Creative => (
Self::creative_origin(batch).0,
horizon_client::creative::Creative::WIDTH as i32,
),
_ => return false,
};
batch.width as i32 - (ox as i32 + image_width + 2) >= 32
}
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row;
/// every screen but the inventories keeps them.
pub(super) fn draw_effect_icons(&self, batch: &mut HudBatch) {
if self.screen != Screen::Playing || self.potions.effects.list.is_empty() {
if self.potions.effects.list.is_empty() || self.screen_shows_active_effects(batch) {
return;
}
let icons: Vec<hud_overlays::EffectIcon> = self
+5 -8
View File
@@ -821,14 +821,11 @@ impl Game<'_> {
if !alive {
continue;
}
// Raider.updateNoActionTime adds two per tick, and a raider
// fighting a player or golem in a raid is never idle.
if s.raid_id.is_some() {
m.ai.no_action_time = if target_player || target_golem {
0
} else {
m.ai.no_action_time + 1
};
// Raider.aiStep: a raider fighting a player or golem in a raid is
// never idle. The per-tick growth (+1 serverAiStep, +2
// Raider.updateNoActionTime) is added by the mob's own step.
if s.raid_id.is_some() && (target_player || target_golem) {
m.ai.no_action_time = 0;
}
// Raider.aiStep: join the raid the raider stands in.
if s.raid_id.is_none()
+20 -4
View File
@@ -2556,6 +2556,20 @@ impl Game<'_> {
r.state_id = p.state;
}
let stack = self.to_stack(&p.item);
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
// gains items squeezes for 5 ticks.
if p.container == 0
&& (36..=45).contains(&p.slot)
&& let Some(new) = &stack
{
let (pop, old) = match p.slot {
45 => (9, self.offhand.as_ref()),
s => (s as usize - 36, self.inventory[s as usize - 36].as_ref()),
};
if old.is_none_or(|old| old.count < new.count) {
self.hotbar_pop[pop] = 5;
}
}
self.set_menu_slot(p.container, i32::from(p.slot), stack);
self.remote_synced();
}
@@ -3448,6 +3462,10 @@ impl Game<'_> {
// a client. Ticking it after the keybinds keeps a held use from
// trying again a tick before the server's entry ends.
self.cooldowns.tick();
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
for pop in &mut self.hotbar_pop {
*pop = pop.saturating_sub(1);
}
// ClientLevel.tick: the border's size change.
self.border.tick();
self.player_border();
@@ -3590,12 +3608,10 @@ impl Game<'_> {
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
horizon_client::motion::MotionEvent::Emission {
state, in_water, ..
} => {
horizon_client::motion::MotionEvent::Emission { state, muffled, .. } => {
let sounds = self.res.facts().get(state).sounds.clone();
// Player.playStepSound: muffled under the swim sound in water.
let (volume, pitch) = if in_water {
let (volume, pitch) = if muffled {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
(sounds.volume * 0.15, sounds.pitch)
+1 -1
View File
@@ -683,7 +683,7 @@ impl Game<'_> {
let objective = board.display_objective(sb::SLOT_LIST);
let shown = tab_list::visible(
key,
self.remote.is_none(),
self.has_singleplayer_server(),
players.len(),
objective.is_some(),
);
+23 -1
View File
@@ -176,7 +176,14 @@ impl Game<'_> {
if projectile {
return;
}
let Some(id) = attacker else { return };
let Some(id) = attacker else {
// Another player's melee hit has no mob behind it.
if let Some((from, disable)) = self.combat.player_attacker {
let fully = outcome.blocked >= damage;
self.blocked_by_attacker(&component, [from[0], from[2]], fully, disable, at);
}
return;
};
let fully = outcome.blocked >= damage;
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
@@ -219,6 +226,21 @@ impl Game<'_> {
let _ = self.handle_mob_events(events);
return;
}
self.blocked_by_attacker(&component, [mx, mz], fully, disable, at);
}
/// What a blocked melee hit does to the defender once the shield took
/// it: `LivingEntity.blockedByItem` knocks them back unless fully
/// blocked, then `Player.blockUsingItem` disables the shield.
fn blocked_by_attacker(
&mut self,
component: &horizon_client::items::BlocksAttacks,
attacker: [f64; 2],
fully: bool,
disable: f32,
at: Option<[f64; 3]>,
) {
let [mx, mz] = attacker;
// LivingEntity.blockedByItem: defender.knockback(0.5, defender - attacker),
// which moves the defender along -(xd, zd).
if !fully && let Some(p) = self.player.as_mut() {
+16 -7
View File
@@ -1768,6 +1768,11 @@ impl Game<'_> {
};
let gray = rgba(170, 170, 170, 255);
let item_info = self.res.items().get(&stack.item);
// SmithingTemplateItem.appendHoverText: `Item.appendHoverText` comes
// before the components.
for (text, c) in horizon_client::item_tooltip::smithing_template_lines(stack, &translate) {
push(&mut lines, text, c);
}
// InstrumentComponent.addToTooltip.
if let Some(text) =
horizon_client::item_tooltip::instrument_line(stack, &display, &translate)
@@ -2023,15 +2028,19 @@ impl Game<'_> {
&& stack.damage > 0
&& advanced
{
push(
&mut lines,
format!(
"Durability: {} / {}",
info.max_damage - stack.damage,
info.max_damage
// `item.durability`, in the selected language.
lines.push((
horizon_client::text::Text::translate(
"item.durability",
&[
horizon_client::text::Text::literal(
(info.max_damage - stack.damage).to_string(),
),
horizon_client::text::Text::literal(info.max_damage.to_string()),
],
),
u32::MAX,
);
));
}
// ItemStack.addDetailsToTooltipTail: with advanced tooltips the item id
// and how many components the stack has.
+3 -2
View File
@@ -378,9 +378,10 @@ impl Game<'_> {
if pos[1] < 63 || !self.can_see_sky(pos) {
return;
}
// DifficultyInstance.isHarderThan(random * 3) at normal difficulty.
// `getCurrentDifficultyAt(playerPos).isHarderThan(random * 3)`.
let difficulty = self.local_difficulty([position.x, position.y, position.z]);
let r = &mut self.world.random;
if r.next_float() * 3.0 >= 2.0 {
if difficulty.effective <= r.next_float() * 3.0 {
return;
}
if r.next_int_bound(since_rest.max(1)).unwrap_or(0) < 72000 {
+1
View File
@@ -62,6 +62,7 @@ impl Game<'_> {
/// The title, action bar and selected-item parts of `Hud.tick`.
pub(super) fn tick_hud(&mut self) {
self.titles.tick();
self.tick_in_bed_chat();
// `MinecraftServer.isCurrentlySaving` of single player's server.
let saving = self
.integrated
+20
View File
@@ -119,3 +119,23 @@ pub fn load(path: &Path, known: &dyn Fn(&str) -> bool) -> Vec<Hotbar> {
_ => (0..GROUPS).map(|_| empty()).collect(),
}
}
/// `HotbarManager.save`: the nine rows with the current data version, each
/// stack as `ItemStack.OPTIONAL_CODEC` writes it (an empty one is `{}`).
pub fn save(path: &Path, rows: &[Hotbar]) -> crate::Result<()> {
let mut entries = vec![(
JavaString::from("DataVersion"),
Tag::Int(horizon_world::DATA_VERSION),
)];
for (i, row) in rows.iter().enumerate() {
let stacks = row.iter().map(|s| match s {
Some(s) => save::item_to_nbt(s),
None => Tag::compound(Vec::<(&str, Tag)>::new()),
});
entries.push((
JavaString::from(i.to_string().as_str()),
Tag::List(stacks.collect()),
));
}
save::write_dat(path, Tag::compound(entries))
}
+36 -24
View File
@@ -484,6 +484,37 @@ pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
keys.len()
}
/// `SmithingTemplateItem.appendHoverText`: the template's kind, what it applies to
/// and its ingredients (gray titles over blue descriptions); none for other items.
pub fn smithing_template_lines(
stack: &ItemStack,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let path = stack.item.trim_start_matches("minecraft:");
if !path.ends_with("_smithing_template") {
return Vec::new();
}
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let kind = if path == "netherite_upgrade_smithing_template" {
"netherite_upgrade"
} else {
"armor_trim"
};
let (title, description) = (rgba(170, 170, 170, 255), rgba(85, 85, 255, 255));
let described = |what: &str| {
let key = format!("item.minecraft.smithing_template.{kind}.{what}");
(format!(" {}", text(&key)), description)
};
vec![
(text("item.minecraft.smithing_template"), title),
(String::new(), u32::MAX),
(text("item.minecraft.smithing_template.applies_to"), title),
described("applies_to"),
(text("item.minecraft.smithing_template.ingredients"), title),
described("ingredients"),
]
}
/// The lines of `stack` below its name that its components give, in
/// `ItemStack.addDetailsToTooltip` order.
pub fn detail_lines(
@@ -494,30 +525,11 @@ pub fn detail_lines(
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let path = stack.item.trim_start_matches("minecraft:");
let mut lines = Vec::new();
// SmithingTemplateItem.appendHoverText.
if path.ends_with("_smithing_template") {
let kind = if path == "netherite_upgrade_smithing_template" {
"netherite_upgrade"
} else {
"armor_trim"
};
lines.push(text("item.minecraft.smithing_template"));
lines.push(String::new());
lines.push(text("item.minecraft.smithing_template.applies_to"));
lines.push(format!(
" {}",
text(&format!(
"item.minecraft.smithing_template.{kind}.applies_to"
))
));
lines.push(text("item.minecraft.smithing_template.ingredients"));
lines.push(format!(
" {}",
text(&format!(
"item.minecraft.smithing_template.{kind}.ingredients"
))
));
}
lines.extend(
smithing_template_lines(stack, translate)
.into_iter()
.map(|l| l.0),
);
let display = TooltipDisplay::for_stack(stack);
lines.extend(bees_line(stack, &display, translate));
lines.extend(honey_line(stack, &display, translate));
+18 -2
View File
@@ -1011,6 +1011,16 @@ pub enum MobEvent {
state: horizon_world::registry::BlockStateId,
},
AttackPlayer(f32, [f64; 3]),
/// A melee hit whose effect comes only if the hit lands (husk hunger,
/// cave spider poison, wither skeleton wither, bee and pufferfish
/// poison): a shield-blocked hit gives nothing.
AttackPlayerWithEffect {
amount: f32,
from: [f64; 3],
effect: &'static str,
ticks: i32,
amplifier: i32,
},
/// Damage of a given type to the player (guardian lasers, thorns).
DamagePlayer {
amount: f32,
@@ -1955,9 +1965,15 @@ impl Mob {
amount *= 5.0;
}
let (worn, worn_toughness) = self.worn_armor();
if !source.bypasses_armor() && self.kind.armor + worn > 0.0 {
// Shulker.setRawPeekAmount: a closed shell adds the covered armor modifier.
let covered = if self.species() == Species::Shulker && self.extra.counter == 0 {
20.0
} else {
0.0
};
if !source.bypasses_armor() && self.kind.armor + worn + covered > 0.0 {
// CombatRules.getDamageAfterAbsorb
let armor = self.kind.armor + worn;
let armor = self.kind.armor + worn + covered;
let toughness = 2.0 + (self.kind.armor_toughness + worn_toughness) / 4.0;
let effective = (armor - amount / toughness).clamp(armor * 0.2, 20.0);
let mut fraction = effective / 25.0;
+23 -1
View File
@@ -1602,6 +1602,7 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
let mut amount = m.attrs.attack_damage;
let from = [m.position.x, m.position.y, m.position.z];
let source = Damage::mob(m.id, from);
let first_event = cx.events.len();
match m.species() {
// CaveSpider.doHurtTarget: poison for 7 s on normal, 15 s on hard.
Species::CaveSpider if who == Who::Player && cx.w.difficulty.base >= 2 => {
@@ -1672,7 +1673,28 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
});
}
match who {
Who::Player => cx.events.push(MobEvent::AttackPlayer(amount, from)),
// The effect picked above goes with the hit: it applies only when
// the player takes damage (a shield-blocked hit gives none).
Who::Player => {
let effect = cx.events[first_event..]
.iter()
.position(|e| matches!(e, MobEvent::EffectPlayer { .. }))
.map(|k| cx.events.remove(first_event + k));
cx.events.push(match effect {
Some(MobEvent::EffectPlayer {
effect,
ticks,
amplifier,
}) => MobEvent::AttackPlayerWithEffect {
amount,
from,
effect,
ticks,
amplifier,
},
_ => MobEvent::AttackPlayer(amount, from),
});
}
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
target: id,
amount,
+3 -4
View File
@@ -244,11 +244,10 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& m.ai.no_action_time % 10 == 0
{
let amount = 1.0 + m.extra.size as f32;
events.push(MobEvent::AttackPlayer(
// Only when the sting hurt the player.
events.push(MobEvent::AttackPlayerWithEffect {
amount,
[m.position.x, m.position.y, m.position.z],
));
events.push(MobEvent::EffectPlayer {
from: [m.position.x, m.position.y, m.position.z],
effect: "minecraft:poison",
ticks: 60 * m.extra.size,
amplifier: 0,
+7 -3
View File
@@ -333,19 +333,23 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) {
let amount = (m.attrs.attack_damage as i32) as f32;
match who {
Who::Player => {
cx.events.push(MobEvent::AttackPlayer(amount, from));
// Poison only on normal (10 s) and hard (18 s).
// Bee.doHurtTarget: the poison only when the sting hurt, and
// only on normal (10 s) and hard (18 s).
let seconds = match cx.w.difficulty.id {
2 => 10,
3 => 18,
_ => 0,
};
if seconds > 0 {
cx.events.push(MobEvent::EffectPlayer {
cx.events.push(MobEvent::AttackPlayerWithEffect {
amount,
from,
effect: "minecraft:poison",
ticks: seconds * 20,
amplifier: 0,
});
} else {
cx.events.push(MobEvent::AttackPlayer(amount, from));
}
}
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
+3 -14
View File
@@ -13,18 +13,6 @@ use horizon_core::geometry::trig::Mth;
use horizon_core::{geometry::Vec3, random::RandomSource};
use std::f32::consts::PI;
/// The species raiders do not fight (`Raider`'s allies).
fn raider(s: Species) -> bool {
matches!(
s,
Species::Pillager
| Species::Vindicator
| Species::Witch
| Species::Evoker
| Species::Illusioner
| Species::Ravager
)
}
fn targets(m: &mut Mob, villagers_seen: bool) {
let mut hurt = HurtByTarget::new().alert();
hurt.ignore = vec![
@@ -80,10 +68,11 @@ pub fn register_vindicator(m: &mut Mob) {
g.add(9, LookAtPlayer::new(3.0).probability(1.0));
g.add(10, LookAtPlayer::mobs(8.0));
targets(m, true);
// VindicatorJohnnyAttackGoal: a vindicator named Johnny attacks anything.
// VindicatorJohnnyAttackGoal: a vindicator named Johnny attacks any
// living entity, other raiders included.
m.ai.targets.add(
4,
NearestTarget::new(Prey::Filter(|s| !raider(s.species)), true)
NearestTarget::new(Prey::Filter(|_| true), true)
.interval(0)
.when(|m, _| m.custom_name.as_deref() == Some("Johnny")),
);
+54 -11
View File
@@ -55,6 +55,10 @@ pub struct MotionResources {
/// `BlockTags.SOUL_SPEED_BLOCKS`: what Soul Speed's effects look for.
pub soul_speed_blocks: BTreeSet<String>,
pub fall_reset: BTreeSet<String>,
/// `BlockTags.INSIDE_STEP_SOUND_BLOCKS`.
pub inside_step_sound: BTreeSet<String>,
/// `BlockTags.COMBINATION_STEP_SOUND_BLOCKS`.
pub combination_step_sound: BTreeSet<String>,
/// `BlockTags.CRYSTAL_SOUND_BLOCKS`: blocks that chime under footsteps.
pub crystal_sound: BTreeSet<String>,
pub landing: BlockLandingRegistry,
@@ -117,6 +121,8 @@ impl MotionResources {
glide_through: tag("minecraft:can_glide_through")?,
soul_speed_blocks: tag("minecraft:soul_speed_blocks")?,
fall_reset: tag("minecraft:fall_damage_resetting")?,
inside_step_sound: tag("minecraft:inside_step_sound_blocks")?,
combination_step_sound: tag("minecraft:combination_step_sound_blocks")?,
crystal_sound: tag("minecraft:crystal_sound_blocks")?,
})
}
@@ -162,11 +168,12 @@ pub enum MotionEvent {
position: BlockPos,
},
/// `Entity.walkingStepSound` on this block: `Player.playStepSound`
/// muffles it under a swim sound when `in_water`.
/// mutes it (`playMuffledStepSound`) under a swim sound in water or
/// under a combination block's own step.
Emission {
position: BlockPos,
state: BlockStateId,
in_water: bool,
muffled: bool,
},
/// `Entity.playAmethystStepSound`: `block.amethyst_block.chime`.
Chime {
@@ -2181,18 +2188,54 @@ impl MovementEmissionWorld for MotionWorld<'_, '_, '_> {
fn in_water(&mut self) -> Result<bool> {
Ok(self.player.fluids.was_touching_water)
}
/// `Player.playStepSound`: in water a swim sound, then the step muffled.
/// `Player.playStepSound`: in water a swim sound, then the step muffled;
/// on land the step of an inside-sound block above the floor, or of a
/// combination block above it with the floor's step muffled.
fn walking_step_sound(&mut self, position: BlockPos, state: BlockStateId) -> Result<()> {
let in_water = self.player.fluids.was_touching_water;
if in_water {
let muffled = self.player.fluids.was_touching_water;
let mut stepped = false;
if muffled {
self.water_swim_sound()?;
} else {
// Entity.getPrimaryStepSoundBlockPos.
let above = BlockPos {
y: position.y + 1,
..position
};
let above_state = self.block(above);
let name = &self
.assets
.registry
.state(above_state)
.ok_or("missing scene state")?
.name;
let combination = self.resources.combination_step_sound.contains(name);
if combination || self.resources.inside_step_sound.contains(name) {
self.player.events.push(MotionEvent::Emission {
position: above,
state: above_state,
muffled: false,
});
if combination {
// Entity.playCombinationStepSounds.
self.player.events.push(MotionEvent::Emission {
position,
state,
muffled: true,
});
}
stepped = true;
}
}
self.player.events.push(MotionEvent::Emission {
position,
state,
in_water,
});
// Entity.walkingStepSound: the amethyst chime after the step.
if !stepped {
self.player.events.push(MotionEvent::Emission {
position,
state,
muffled,
});
}
// Entity.walkingStepSound: the amethyst chime after the step, by the
// floor's state even when the step sound was the block above's.
let name = &self
.assets
.registry
+23 -1
View File
@@ -81,6 +81,11 @@ impl Opt {
Opt::Narrator
| Opt::NarratorHotkey
| Opt::ChatDelay
// Stored, but there are no blocked players, signed chat or
// chat drafts yet.
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts
| Opt::DistortionEffects
| Opt::MonochromeLogo
| Opt::RotateWithMinecarts
@@ -96,6 +101,9 @@ impl Opt {
Opt::Narrator => "narrator",
Opt::TextBackground => "backgroundForChatOnly",
Opt::ChatDelay => "chatDelay",
Opt::HideMatchedNames => "hideMatchedNames",
Opt::OnlySecureChat => "onlyShowSecureChat",
Opt::SaveChatDrafts => "saveChatDrafts",
Opt::DistortionEffects => "screenEffectScale",
Opt::FovEffects => "fovEffectScale",
Opt::DarknessPulsing => "darknessEffectScale",
@@ -164,6 +172,9 @@ impl Opt {
Opt::ChatHeightUnfocused => "options.chat.height.unfocused",
Opt::ChatLineSpacing => "options.chat.line_spacing",
Opt::AutoSuggestions => "options.autoSuggestCommands",
Opt::HideMatchedNames => "options.hideMatchedNames",
Opt::OnlySecureChat => "options.onlyShowSecureChat",
Opt::SaveChatDrafts => "options.chat.drafts",
Opt::ReducedDebugInfo => "options.reducedDebugInfo",
Opt::HighContrast => "options.accessibility.high_contrast",
Opt::ShowSubtitles => "options.showSubtitles",
@@ -437,6 +448,9 @@ impl Opt {
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
Opt::HideMatchedNames | Opt::OnlySecureChat | Opt::SaveChatDrafts => {
on_off(s.file.bool(self.file_key().unwrap()))
}
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
@@ -635,7 +649,12 @@ impl Opt {
value + 1
};
}
Opt::TextBackground | Opt::HideLightningFlashes | Opt::HideSplashTexts => {
Opt::TextBackground
| Opt::HideLightningFlashes
| Opt::HideSplashTexts
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts => {
let key = self.file_key().unwrap();
s.file
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
@@ -1434,7 +1453,10 @@ impl OptionsUi {
Opt::ChatHeightFocused,
Opt::ChatHeightUnfocused,
Opt::AutoSuggestions,
Opt::HideMatchedNames,
Opt::ReducedDebugInfo,
Opt::OnlySecureChat,
Opt::SaveChatDrafts,
]),
Page::Accessibility => {
let mut rows = vec![Row::Buttons(
+3
View File
@@ -659,7 +659,10 @@ pub enum Opt {
ChatHeightUnfocused,
ChatLineSpacing,
AutoSuggestions,
HideMatchedNames,
ReducedDebugInfo,
OnlySecureChat,
SaveChatDrafts,
HighContrast,
ShowSubtitles,
MusicFrequency,