diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index b9e9d7383..13149da3a 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -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]) diff --git a/crates/horizon-client/examples/probe/scene_motion_probe.rs b/crates/horizon-client/examples/probe/scene_motion_probe.rs index 0a7e4b594..66856858f 100644 --- a/crates/horizon-client/examples/probe/scene_motion_probe.rs +++ b/crates/horizon-client/examples/probe/scene_motion_probe.rs @@ -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!( diff --git a/crates/horizon-client/src/advancements.rs b/crates/horizon-client/src/advancements.rs index 1ed11da4d..229510dd7 100644 --- a/crates/horizon-client/src/advancements.rs +++ b/crates/horizon-client/src/advancements.rs @@ -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 { diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 6d8dc528b..9b46269ad 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -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, 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(), diff --git a/crates/horizon-client/src/bundle.rs b/crates/horizon-client/src/bundle.rs index 948e2f4a9..49d29d5f9 100644 --- a/crates/horizon-client/src/bundle.rs +++ b/crates/horizon-client/src/bundle.rs @@ -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(); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 09ff67b48..29043d5e6 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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; 4], /// The off hand (Inventory slot 40, saved as slot 150; menu slot 504 here). offhand: Option, + /// `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(), diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index ab5d4f5d6..c7de6b292 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -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) { diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index ddbe47481..07d65c5a4 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -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, diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 5e61eb0b1..9fbc5cd88 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -81,6 +81,8 @@ pub struct UiState { pub chat_screen: Option, /// When the chat screen opened (its input took the focus: the cursor blink). pub chat_opened: Option, + /// 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, diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index f125899f3..808f489c7 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -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, diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index b0d1ce316..f5824e5db 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -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::(); @@ -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 = 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 { 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 = 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> = 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> { diff --git a/crates/horizon-client/src/game/creative_hotbar.rs b/crates/horizon-client/src/game/creative_hotbar.rs new file mode 100644 index 000000000..81e5ad6c4 --- /dev/null +++ b/crates/horizon-client/src/game/creative_hotbar.rs @@ -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); + } +} diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index ed3842cc0..70ff25dff 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -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; } diff --git a/crates/horizon-client/src/game/crossbow.rs b/crates/horizon-client/src/game/crossbow.rs index d66610183..0c58b69f9 100644 --- a/crates/horizon-client/src/game/crossbow.rs +++ b/crates/horizon-client/src/game/crossbow.rs @@ -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); + } } } diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index dc79bf8c1..1b822430b 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -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, diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index 250887b30..192b72552 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -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(); diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 2d3770cf9..38cb16fa1 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -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. diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 4b4efaaa8..86b9fd655 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -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, diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index 937fa28f4..27c7ada1f 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -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); diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index bab619c18..e2680ec84 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -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 { diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index c0d7f1e3c..b8971046d 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -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 = self diff --git a/crates/horizon-client/src/game/raids.rs b/crates/horizon-client/src/game/raids.rs index 8fbcfc6dd..f7525c953 100644 --- a/crates/horizon-client/src/game/raids.rs +++ b/crates/horizon-client/src/game/raids.rs @@ -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() diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 73d03bb7e..3e79f1437 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -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) diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index 53323fdbb..443cb2c03 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -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(), ); diff --git a/crates/horizon-client/src/game/shield.rs b/crates/horizon-client/src/game/shield.rs index a26bc5d75..228b7c206 100644 --- a/crates/horizon-client/src/game/shield.rs +++ b/crates/horizon-client/src/game/shield.rs @@ -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() { diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 361879d2e..8e8733850 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -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. diff --git a/crates/horizon-client/src/game/stats.rs b/crates/horizon-client/src/game/stats.rs index 68a574eb1..94814320c 100644 --- a/crates/horizon-client/src/game/stats.rs +++ b/crates/horizon-client/src/game/stats.rs @@ -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 { diff --git a/crates/horizon-client/src/game/titles.rs b/crates/horizon-client/src/game/titles.rs index f4ef2d7d0..217702e51 100644 --- a/crates/horizon-client/src/game/titles.rs +++ b/crates/horizon-client/src/game/titles.rs @@ -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 diff --git a/crates/horizon-client/src/hotbar.rs b/crates/horizon-client/src/hotbar.rs index a60f28287..dccae7560 100644 --- a/crates/horizon-client/src/hotbar.rs +++ b/crates/horizon-client/src/hotbar.rs @@ -119,3 +119,23 @@ pub fn load(path: &Path, known: &dyn Fn(&str) -> bool) -> Vec { _ => (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)) +} diff --git a/crates/horizon-client/src/item_tooltip.rs b/crates/horizon-client/src/item_tooltip.rs index 63a19b114..ce4fc352d 100644 --- a/crates/horizon-client/src/item_tooltip.rs +++ b/crates/horizon-client/src/item_tooltip.rs @@ -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, +) -> 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)); diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 389c6c7e0..d3bf52b8a 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -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; diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 2af044209..e1627e29f 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -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, diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index 59a73c605..fbd09cd9f 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -244,11 +244,10 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { && 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, diff --git a/crates/horizon-client/src/mobs/bee.rs b/crates/horizon-client/src/mobs/bee.rs index c759822f6..e8e3ae0ae 100644 --- a/crates/horizon-client/src/mobs/bee.rs +++ b/crates/horizon-client/src/mobs/bee.rs @@ -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 { diff --git a/crates/horizon-client/src/mobs/illager.rs b/crates/horizon-client/src/mobs/illager.rs index 00581d6d3..f4cb18af8 100644 --- a/crates/horizon-client/src/mobs/illager.rs +++ b/crates/horizon-client/src/mobs/illager.rs @@ -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")), ); diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 06e513c00..d357a196f 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -55,6 +55,10 @@ pub struct MotionResources { /// `BlockTags.SOUL_SPEED_BLOCKS`: what Soul Speed's effects look for. pub soul_speed_blocks: BTreeSet, pub fall_reset: BTreeSet, + /// `BlockTags.INSIDE_STEP_SOUND_BLOCKS`. + pub inside_step_sound: BTreeSet, + /// `BlockTags.COMBINATION_STEP_SOUND_BLOCKS`. + pub combination_step_sound: BTreeSet, /// `BlockTags.CRYSTAL_SOUND_BLOCKS`: blocks that chime under footsteps. pub crystal_sound: BTreeSet, 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 { 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 diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 2b852a4af..fbcf8ba98 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -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( diff --git a/crates/horizon-client/src/settings.rs b/crates/horizon-client/src/settings.rs index 0c59c18f9..aeb366947 100644 --- a/crates/horizon-client/src/settings.rs +++ b/crates/horizon-client/src/settings.rs @@ -659,7 +659,10 @@ pub enum Opt { ChatHeightUnfocused, ChatLineSpacing, AutoSuggestions, + HideMatchedNames, ReducedDebugInfo, + OnlySecureChat, + SaveChatDrafts, HighContrast, ShowSubtitles, MusicFrequency,