Merge main into this branch (creative display slots)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-30 03:30:02 +01:00
commit ada669266f
133 files changed
+5914 -472

No files matched your search

+3
View File
@@ -7,6 +7,7 @@
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -127,6 +128,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
### Java parity: 100%
@@ -55,6 +55,12 @@ particle_types! {
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
NoxiousGas = "noxious_gas",
NoxiousGasCloud = "noxious_gas_cloud",
Geyser = "geyser",
GeyserBase = "geyser_base",
GeyserPoof = "geyser_poof",
GeyserPlume = "geyser_plume",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
@@ -149,6 +155,10 @@ impl ParticleType {
matches!(
self,
Self::BlockMarker
| Self::Geyser
| Self::GeyserBase
| Self::GeyserPoof
| Self::GeyserPlume
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
@@ -191,6 +201,8 @@ impl ParticleType {
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// NoxiousGasParticle.
| Self::NoxiousGas
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
@@ -13,6 +13,12 @@ pub enum Button {
}
pub trait InteractionWorld {
/// Minecraft.pick before handleKeybinds: the tick's `hitResult`, which
/// every click of the tick reads, even after one of them changed the
/// block it names (a creative break, then a use that replaces it).
fn pick_hit_result(&mut self);
/// The tick's clicks are done: later picks follow the crosshair again.
fn release_hit_result(&mut self);
fn using_item(&self) -> bool;
fn release_using(&mut self);
fn start_attack(&mut self) -> bool;
@@ -48,6 +54,7 @@ impl InteractionInput {
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
world.pick_hit_result();
let mut instant = false;
if world.using_item() {
if !use_down {
@@ -70,6 +77,7 @@ impl InteractionInput {
world.start_use();
}
world.continue_attack(!instant && attack_down);
world.release_hit_result();
}
}
@@ -86,6 +94,8 @@ mod tests {
events: Vec<&'static str>,
}
impl InteractionWorld for World {
fn pick_hit_result(&mut self) {}
fn release_hit_result(&mut self) {}
fn using_item(&self) -> bool {
self.using
}
@@ -183,4 +193,67 @@ mod tests {
input.tick(&mut world, false, false);
assert_eq!(world.events, ["release", "continue"]);
}
/// A floating block in front of the crosshair, nothing behind it, and a
/// block item in the hand of a creative player.
#[derive(Default)]
struct Column {
block: bool,
hit: Option<Option<()>>,
placed: bool,
}
impl Column {
/// Minecraft.hitResult: the tick's pick, or a fresh one outside it.
fn target(&self) -> Option<()> {
self.hit.unwrap_or(self.block.then_some(()))
}
}
impl InteractionWorld for Column {
fn pick_hit_result(&mut self) {
self.hit = Some(self.target());
}
fn release_hit_result(&mut self) {
self.hit = None;
}
fn using_item(&self) -> bool {
false
}
fn release_using(&mut self) {}
fn start_attack(&mut self) -> bool {
// MultiPlayerGameMode.startDestroyBlock in creative: the
// predicted break leaves air.
if self.target().is_some() {
self.block = false;
}
!self.block
}
fn start_use(&mut self) {
// BlockItem.place: the clicked block, now air, can be replaced.
if self.target().is_some() && !self.block {
self.block = true;
self.placed = true;
}
}
fn pick(&mut self) {}
fn use_delay(&self) -> u32 {
0
}
fn continue_attack(&mut self, _: bool) {}
}
#[test]
fn attack_and_use_in_one_tick_share_the_hit_result() {
// #3006: both buttons at once replace the block the crosshair is on.
let mut input = InteractionInput::default();
let mut world = Column {
block: true,
..Default::default()
};
input.press(Button::Attack);
input.press(Button::Use);
input.tick(&mut world, false, false);
assert!(world.placed, "the use acts on the block the attack broke");
assert!(world.block);
assert!(world.hit.is_none(), "the next pick follows the crosshair");
}
}
+13 -1
View File
@@ -192,9 +192,17 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the models and
/// the sprites' layers, without the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
}
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
@@ -218,7 +226,11 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
if pixels {
sprites.finish();
} else {
sprites.finish_without_pixels();
}
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
@@ -2363,6 +2363,7 @@ mod tests {
lines: [line.into(), "two".into(), String::new(), "four".into()],
color: 3,
glowing,
..Default::default()
};
let first_text = crate::signs::SignData {
front: text("one", false),
@@ -80,6 +80,24 @@ pub enum BlockEntity {
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
PotentSulfur(PotentSulfur),
}
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
/// last eruption block event, which the client's plume counts from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PotentSulfur {
pub waiting_countdown: i32,
pub eruption_tick: i64,
}
impl Default for PotentSulfur {
fn default() -> Self {
Self {
waiting_countdown: -1,
eruption_tick: -1,
}
}
}
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
#[derive(Clone, Debug, Default)]
@@ -228,6 +246,8 @@ pub struct SignText {
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
/// The `click_event` in each line's component style, as saved.
pub click: [Option<Tag>; 4],
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -236,6 +256,8 @@ pub struct SignData {
pub front: SignText,
pub back: SignText,
pub waxed: bool,
/// `allow_op_features`: the sign's click events may run commands.
pub allow_op_features: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -3,7 +3,7 @@
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -306,6 +306,9 @@ impl BlockEntity {
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
if name == "minecraft:potent_sulfur" {
return Some(Self::PotentSulfur(PotentSulfur::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -349,6 +352,7 @@ impl BlockEntity {
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -367,6 +371,7 @@ impl BlockEntity {
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -391,6 +396,7 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
@@ -419,6 +425,7 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
+1
View File
@@ -59,6 +59,7 @@ pub mod packs;
pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod potent_sulfur;
pub mod providers;
pub mod screen_draw;
pub mod screen_effects;
+68
View File
@@ -161,6 +161,31 @@ impl Column {
self.spans.push(span);
}
}
/// Open water and its floor lit as open sky, as the seed's far columns
/// are: a chunk's own light fades a level per block of water, so the
/// chunks a player passed drew a darker band through the seed's sea.
/// The water is one span again.
fn light_open_water(&mut self, kinds: &[u8]) {
// Water; lava glows.
let water = |span: &Span| {
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
kind & FLUID != 0 && kind >> 4 == 0
};
if !self.spans.last().is_some_and(water) {
return;
}
let floor = self
.spans
.iter()
.rposition(|span| !water(span))
.unwrap_or(0);
for (i, mut span) in std::mem::take(&mut self.spans).into_iter().enumerate() {
if i >= floor {
span.light |= 0xf0;
}
self.push(span);
}
}
/// `kinds`: `state_kinds`, per block state.
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
@@ -212,6 +237,7 @@ impl Column {
});
}
}
column.light_open_water(kinds);
let mut water = None;
for span in column.spans.iter().rev() {
if kind(span.state) & FLUID != 0 {
@@ -1772,6 +1798,48 @@ mod tests {
);
}
#[test]
fn open_water_and_its_floor_are_lit_as_the_seed_lights_them() {
let span = |min, max, state, light| Span {
min,
max,
state: BlockStateId(state),
biome: 0,
light,
};
// 0 air, 1 stone, 2 water, 3 lava.
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | 15 << 4];
// A chunk's own light: a cave, the floor, water fading a level a block.
let mut sea = Column {
surface: None,
spans: vec![
span(30, 40, 1, 0),
span(40, 48, 1, 0x10 | 2 << 12),
span(48, 61, 2, 0x20),
span(61, 62, 2, 0xe0),
span(62, 63, 2, 0xf0),
],
};
sea.light_open_water(&kinds);
assert_eq!(
sea.spans,
[
span(30, 40, 1, 0),
span(40, 48, 1, 0xf0 | 2 << 12),
span(48, 63, 2, 0xf0)
]
);
// Land, and lava, keep their light.
for top in [1, 3] {
let mut column = Column {
surface: None,
spans: vec![span(40, 50, 2, 0x20), span(50, 63, top, 0x80)],
};
let before = column.spans.clone();
column.light_open_water(&kinds);
assert_eq!(column.spans, before);
}
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
let span = |min, max, state| Span {
min,
+99 -20
View File
@@ -2797,26 +2797,7 @@ impl OptionsUi {
}
Act::Done => events.extend(self.back(s)),
Act::Cycle(opt) => {
opt.cycle(s, right, framebuffer);
if opt == Opt::Preset {
apply_preset(s);
}
if opt == Opt::HighContrast {
// Options.highContrast: the built-in pack follows the option.
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
if on != s.high_contrast {
s.resource_packs.retain(|id| id != "high_contrast");
if s.high_contrast {
s.resource_packs.push("high_contrast".into());
}
// PackSelectionScreen reads the repository each time it
// opens: the next one starts from this selection.
self.pack_selection = s.resource_packs.clone();
self.pack_screen = None;
events.push(Event::Packs(s.resource_packs.clone()));
}
}
events.push(Event::Changed);
events.extend(self.cycle_option(s, opt, right, framebuffer));
}
Act::Extra(item) => {
if let Some(name) = item.key_name() {
@@ -2924,6 +2905,77 @@ impl OptionsUi {
self.scroll -= amount * 18.0;
self.clamp_scroll(top, bottom, content);
}
/// `CycleButton.cycleValue` on an option, then what the option changes:
/// the preset's values, High Contrast's built-in pack.
fn cycle_option(
&mut self,
s: &mut Settings,
opt: Opt,
back: bool,
framebuffer: [i32; 2],
) -> Vec<Event> {
let mut events = Vec::new();
opt.cycle(s, back, framebuffer);
if opt == Opt::Preset {
apply_preset(s);
}
if opt == Opt::HighContrast {
// Options.highContrast: the built-in pack follows the option.
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
if on != s.high_contrast {
s.resource_packs.retain(|id| id != "high_contrast");
if s.high_contrast {
s.resource_packs.push("high_contrast".into());
}
// PackSelectionScreen reads the repository each time it
// opens: the next one starts from this selection.
self.pack_selection = s.resource_packs.clone();
self.pack_screen = None;
events.push(Event::Packs(s.resource_packs.clone()));
}
}
events.push(Event::Changed);
events
}
/// The mouse wheel at the cursor. Over a cycle button it takes the step
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put.
pub fn wheel(
&mut self,
s: &mut Settings,
size: (f32, f32),
cursor: (f32, f32),
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Event> {
let plain = self.resource_error.is_none()
&& Self::graphics_level(self.page()).is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
self.scroll_at(s, size, cursor, amount);
return Vec::new();
}
let (widgets, top, bottom, _) = self.layout(s, size.0, size.1);
let hovered = widgets.iter().find_map(|wd| match &wd.kind {
WKind::Button {
active: true,
act: Act::Cycle(opt),
..
} if ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& !(wd.list && (cursor.1 < top || cursor.1 >= bottom)) =>
{
Some(*opt)
}
_ => None,
});
match hovered {
Some(opt) if amount != 0.0 => self.cycle_option(s, opt, amount > 0.0, framebuffer),
Some(_) => Vec::new(),
None => {
self.scroll_at(s, size, cursor, amount);
Vec::new()
}
}
}
/// The mouse wheel at the cursor.
pub fn scroll_at(&mut self, s: &Settings, size: (f32, f32), cursor: (f32, f32), amount: f32) {
if self.resource_error.is_some() {
@@ -2940,6 +2992,33 @@ impl OptionsUi {
_ => self.scroll(s, size, amount),
}
}
/// `VideoSettingsScreen.mouseScrolled`: with Ctrl held the wheel steps the GUI scale
/// (auto counts as one past the maximum) and the list goes back to the top.
pub fn video_wheel_gui_scale(
&mut self,
s: &mut Settings,
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Event> {
let max = i64::from(crate::window::calculate_scale(
framebuffer,
0,
s.force_unicode,
));
let old = if s.gui_scale == 0 {
max + 1
} else {
i64::from(s.gui_scale)
};
let step = i64::from(amount > 0.0) - i64::from(amount < 0.0);
let new = old + step;
if new == 0 || new > max {
return Vec::new();
}
s.gui_scale = new as u32;
self.scroll = 0.0;
vec![Event::Changed]
}
/// The pack screen at this GUI size, made on first use.
fn packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) -> &mut crate::pack_screen::PackScreen {
let m = crate::screen_draw::Measure(&atlas.font);
+284 -3
View File
@@ -44,6 +44,9 @@ pub struct ParticleOptions {
/// `ItemParticleOption`: the particle sprite of each layer of the item's
/// model as resolved for the ground.
pub item: Option<Vec<String>>,
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -55,6 +58,21 @@ impl ParticleOptions {
to_color: [1.0, 0.0, 0.0],
trail: None,
item: None,
geyser: None,
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
pub fn geyser(ty: ParticleType, water_blocks: i32) -> Self {
Self {
geyser: Some((water_blocks, 0.0)),
..Self::simple(ty)
}
}
/// `GeyserBaseParticleOptions(type, waterBlocks, burstImpulseBase)`.
pub fn geyser_base(ty: ParticleType, water_blocks: i32, burst_impulse_base: f32) -> Self {
Self {
geyser: Some((water_blocks, burst_impulse_base)),
..Self::simple(ty)
}
}
/// `ItemParticleOption`: the stack's layer particle sprites (`BreakingItemParticle`).
@@ -189,6 +207,29 @@ enum Behaviour {
size_start: f32,
y_prev: f64,
},
/// `NoxiousGasParticle`: fades out from `fade_start` ticks of age.
NoxiousGas {
fade_start: f32,
},
/// `NoxiousGasCloudParticle`: every second tick, a gas particle where the
/// gas reaches.
NoxiousGasCloud,
/// `GeyserEruptionParticle`: the base puffs, the plume and the poofs of
/// one eruption; `aux` is the speed its provider was given.
GeyserEruption {
water_blocks: i32,
aux: [f64; 3],
},
/// `GeyserPlumeParticle`: shoots up `plumeHeight` blocks, then falls back.
GeyserPlume {
start_y: f64,
max_y: f64,
propulsion: f32,
spray: [f32; 2],
min_size: f32,
max_size: f32,
done: bool,
},
BubblePop,
BubbleColumnUp,
CurrentDown {
@@ -869,6 +910,26 @@ impl Particle {
(age / self.lifetime as f32).clamp(0.0, 1.0)
}
}
/// `NoxiousGasCloudParticle.pickRandomParticleSpawnPoint`: a point up to three
/// blocks from the centre of `center_block` in a random horizontal direction,
/// a quarter block lower, from the level's random.
fn pick_random_particle_spawn_point(random: &mut LegacyRandom, center_block: [i32; 3]) -> [f64; 3] {
let dx = f64::from(random.next_float() - 0.5);
let dz = f64::from(random.next_float() - 0.5);
// Vec3.normalize.
let length = (dx * dx + dz * dz).sqrt();
let (dx, dz) = if length < f64::from(1.0e-5f32) {
(0.0, 0.0)
} else {
(dx / length, dz / length)
};
let distance = f64::from(random.next_float() * 3.0);
[
f64::from(center_block[0]) + 0.5 + dx * distance,
f64::from(center_block[1]) + 0.5 - 0.25,
f64::from(center_block[2]) + 0.5 + dz * distance,
]
}
/// `FireflyParticle.getFadeAmount`.
fn fade_amount(progress: f32, fade_in: f32, fade_out: f32) -> f32 {
if progress >= 1.0 - fade_in {
@@ -928,7 +989,7 @@ impl ParticleLevel<'_> {
Err(_) => d,
}
}
fn block_at(p: [f64; 3]) -> [i32; 3] {
pub(crate) fn block_at(p: [f64; 3]) -> [i32; 3] {
[
p[0].floor() as i32,
p[1].floor() as i32,
@@ -950,7 +1011,7 @@ impl ParticleLevel<'_> {
(s.fluid, height)
}
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
}
@@ -1165,6 +1226,17 @@ impl ParticleEngine {
self.create(options, p, v);
}
}
/// `NoxiousGasCloudParticle.spawnNoxiousGasParticle`: the gas, as an
/// always visible particle (`ClientLevel.addAlwaysVisibleParticle`).
fn spawn_noxious_gas_particle(&mut self, pos: [f64; 3]) {
self.add_with(
&ParticleOptions::simple(ParticleType::NoxiousGas),
pos,
[0.0; 3],
false,
true,
);
}
/// `ClientLevel.calculateParticleLevel`: minimal still shows one in ten
/// always-visible particles as decreased, and decreased drops a third.
fn calculate_particle_level(&mut self, always_show: bool) -> u8 {
@@ -1562,7 +1634,15 @@ impl ParticleEngine {
q.set = set;
q
}
T::Smoke | T::LargeSmoke | T::WhiteSmoke | T::Ash | T::WhiteAsh | T::DustPlume => {
T::Smoke
| T::LargeSmoke
| T::WhiteSmoke
| T::Ash
| T::WhiteAsh
| T::DustPlume
| T::NoxiousGas
| T::GeyserBase
| T::GeyserPoof => {
// BaseAshSmokeParticle(dir, xa, scale, colorRandom, maxLifetime, gravity, hasPhysics).
let (dir, scale, color_random, max_lifetime, gravity, physics, vel) = match ty {
T::Smoke | T::WhiteSmoke => ([0.1, 0.1, 0.1], 1.0, 0.3, 8, -0.1, true, v),
@@ -1578,6 +1658,22 @@ impl ParticleEngine {
false,
[v[0], v[1] + f64::from(0.15f32), v[2]],
),
// NoxiousGasParticle.Provider: scale 3.
T::NoxiousGas => ([0.1, 0.1, 0.1], 3.0, 0.3, 5, -0.02, true, v),
// GeyserBaseParticle: the burst impulse scales every axis.
T::GeyserBase | T::GeyserPoof => {
let (water_blocks, burst_impulse_base) = o.geyser?;
let burst_impulse = burst_impulse_base + 0.25 * water_blocks as f32;
(
[f64::from(burst_impulse); 3],
3.0 + 0.125 * water_blocks as f32,
0.0,
0,
0.0,
true,
v,
)
}
_ => {
// WhiteAshParticle.Provider draws the motion.
let pr = &mut self.random;
@@ -1593,6 +1689,18 @@ impl ParticleEngine {
([0.1, -0.1, 0.1], 1.0, 0.0, 20, 0.0125, false, [xa, ya, za])
}
};
// GeyserBaseParticle.Provider: a random spot half a block
// around the point, a fifth of a block up (the provider's
// random, as float arithmetic added to the double position).
let p = if matches!(ty, T::GeyserBase | T::GeyserPoof) {
let pr = &mut self.random;
let x = p[0] + f64::from((pr.next_float() - 0.5) * 0.5);
let y = p[1] + f64::from((pr.next_float() - 0.5) * 0.5) + f64::from(0.2f32);
let z = p[2] + f64::from((pr.next_float() - 0.5) * 0.5);
[x, y, z]
} else {
p
};
let mut q = Particle::moving(r, ty, p, [0.0; 3]);
q.friction = 0.96;
q.gravity = gravity;
@@ -1621,6 +1729,87 @@ impl ParticleEngine {
);
q.behaviour = Behaviour::DustPlume;
}
match ty {
// NoxiousGasParticle: white, living `6 / (0.5 to 1) * 3`
// ticks, fading out over its second half.
T::NoxiousGas => {
q.set_color(1.0, 1.0, 1.0);
q.lifetime = (6.0 / (f64::from(r.next_float()) * 0.5 + 0.5)
* f64::from(3.0f32)) as i32;
q.behaviour = Behaviour::NoxiousGas {
fade_start: q.lifetime as f32 / 2.0,
};
}
// GeyserBaseParticle: white, only ever rising, 20 to 25 ticks.
T::GeyserBase | T::GeyserPoof => {
q.friction = 0.725;
q.set_color(1.0, 1.0, 1.0);
q.velocity[1] = q.velocity[1].abs();
let lifetime_factor = 0.8 + 0.2 * self.level_random.next_float();
q.lifetime = (25.0 * lifetime_factor) as i32;
}
_ => {}
}
q.set = set;
q
}
// NoxiousGasCloudParticle: lives 20 ticks, draws nothing itself.
T::NoxiousGasCloud => {
let mut q = Particle::base_at(r, ty, p);
q.lifetime = 20;
q.no_render = true;
q.behaviour = Behaviour::NoxiousGasCloud;
q
}
// GeyserEruptionParticle: lives 20 ticks, draws nothing itself.
T::Geyser => {
let (water_blocks, _) = o.geyser?;
let mut q = Particle::base_at(r, ty, p);
q.lifetime = 20;
q.no_render = true;
q.behaviour = Behaviour::GeyserEruption {
water_blocks,
aux: v,
};
q
}
// GeyserPlumeParticle, after its provider's random placement.
T::GeyserPlume => {
let (water_blocks, _) = o.geyser?;
let pr = &mut self.random;
let at = [
p[0] + f64::from((pr.next_float() - 0.5) * 0.2),
p[1] + f64::from(pr.next_float()),
p[2] + f64::from((pr.next_float() - 0.5) * 0.2),
];
let plume_height = 5 * water_blocks.max(1);
let mut q = Particle::moving(r, ty, at, v);
q.has_physics = true;
q.speed_up_when_y_blocked = true;
q.lifetime = plume_height * 5;
q.velocity[1] = 0.0;
let level = &mut self.level_random;
let spray = [
(level.next_float() - 0.5) * 0.2,
(level.next_float() - 0.5) * 0.2,
];
q.friction = 1.0;
let propulsion =
(if water_blocks == 1 { 1.5 } else { 1.0 }) * plume_height as f32 * 1.45;
q.gravity = -propulsion;
let initial_size = q.size * 0.75;
let min_size = initial_size * (2.0 + plume_height as f32 / 8.0);
let max_size = initial_size * (3.0 + plume_height as f32 / 8.0);
q.size = min_size;
q.behaviour = Behaviour::GeyserPlume {
start_y: at[1],
max_y: at[1] + f64::from(plume_height) - 1.0,
propulsion,
spray,
min_size,
max_size,
done: false,
};
q.set = set;
q
}
@@ -3198,6 +3387,98 @@ impl ParticleEngine {
y_prev: p.position[1],
};
}
Behaviour::NoxiousGas { fade_start } => {
p.base_tick(level);
self.sprite_from_age(p);
if p.age as f32 > fade_start {
let frames_since_fade_out_start = p.age as f32 - fade_start;
p.rgba[3] =
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
}
}
Behaviour::NoxiousGasCloud => {
p.base_tick(level);
if p.age % 2 == 0 {
let source = ParticleLevel::block_at(p.position);
let spawn = pick_random_particle_spawn_point(&mut self.level_random, source);
if crate::potent_sulfur::can_be_reached_by_noxious_gas(level, source, spawn) {
self.spawn_noxious_gas_particle(spawn);
}
}
}
Behaviour::GeyserEruption { water_blocks, aux } => {
p.base_tick(level);
if p.age % 2 == 0 {
for _ in 0..2 {
self.add(
&ParticleOptions::geyser_base(
ParticleType::GeyserBase,
water_blocks,
1.5,
),
p.position,
aux,
);
}
}
for _ in 0..water_blocks + 2 {
self.add(
&ParticleOptions::geyser(ParticleType::GeyserPlume, water_blocks),
p.position,
aux,
);
}
if p.age % 10 == 0 {
for _ in 0..20 {
self.add(
&ParticleOptions::geyser_base(
ParticleType::GeyserPoof,
water_blocks,
2.0,
),
p.position,
aux,
);
}
}
}
Behaviour::GeyserPlume {
start_y,
max_y,
propulsion,
spray,
min_size,
max_size,
mut done,
} => {
p.base_tick(level);
if !done
&& (p.velocity[1] < 0.0
|| p.position[1] > max_y
|| p.position[1] == p.previous[1])
{
p.lifetime = p.lifetime.min(p.age + 5);
p.friction = 0.0;
done = true;
}
let y_progress_linear =
((p.position[1] - start_y) / (max_y - start_y)).clamp(0.0, 1.0);
let y_progress_exponential = horizon_core::jmath::pow(y_progress_linear, 3.0);
p.gravity = propulsion * y_progress_exponential as f32 * 0.12;
p.velocity[0] = y_progress_linear * f64::from(spray[0]);
p.velocity[2] = y_progress_linear * f64::from(spray[1]);
self.sprite_from_age(p);
p.size = min_size + (y_progress_linear * f64::from(max_size - min_size)) as f32;
p.behaviour = Behaviour::GeyserPlume {
start_y,
max_y,
propulsion,
spray,
min_size,
max_size,
done,
};
}
Behaviour::BubbleColumnUp => {
p.base_tick(level);
if !p.removed && level.fluid(ParticleLevel::block_at(p.position)).0 != Fluid::Water
@@ -0,0 +1,119 @@
//! `PotentSulfurBlockEntity`'s reads of the level: where the noxious gas of a
//! potent sulfur block comes out, how far a geyser throws, and whether the gas
//! reaches a point. The client's particles and the server's nausea and
//! launches read the level the same way, through a [`ParticleLevel`].
use crate::{blocks::Fluid, collisions::SceneCollisions, particles::ParticleLevel};
use horizon_core::{
geometry::{Vec3, traverse_blocks},
position::BlockPos,
};
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
/// with no collision shape.
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
let state = level.world.block(pos[0], pos[1], pos[2]);
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
return true;
}
let mut scene = SceneCollisions {
world: level.world,
binding: level.collision,
subject: None,
};
match level.collision.collision_shape(&mut scene, state, pos) {
Ok(shape) => shape.is_empty(),
Err(_) => !level.facts.get(state).full_collision,
}
}
/// `PotentSulfurBlockEntity.isWater`: the block at `pos` holds a water source
/// (`FluidState.isSourceOfType`).
fn is_water_source(level: &ParticleLevel, pos: [i32; 3]) -> bool {
let (fluid, source) = level.fluid_source(pos);
fluid == Fluid::Water && source
}
/// `PotentSulfurBlockEntity.findNoxiousGasSourceBlock`: the first block above
/// the sulfur, within the water column's reach, that is not water-filled and
/// lets gas through; None when something solid or a deeper column stops it.
pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) -> Option<[i32; 3]> {
let max_y = origin[1] + ALLOWED_WATER_BLOCKS_ABOVE + 1;
let mut pos = [origin[0], origin[1] + 1, origin[2]];
while pos[1] <= max_y {
let state = level.world.block(pos[0], pos[1], pos[2]);
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
if !is_water_source(level, pos)
|| (!is_water_block && !is_geyser_passable_block(level, pos))
{
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
return Some(pos);
}
break;
}
pos[1] += 1;
}
None
}
/// `PotentSulfurBlockEntity.getUnobstructedBlockCount`: how many blocks from
/// `pos` up, at most six per water block, a geyser's push goes through.
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
let geyser_force_height = 6 * water_blocks;
for i in 0..geyser_force_height {
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
return i;
}
}
geyser_force_height
}
/// `PotentSulfurBlockEntity.canBeReachedByNoxiousGas`: `pos` is in a block gas
/// can enter, within three blocks of the source block's centre, and above
/// water the source's water can be seen from.
pub fn can_be_reached_by_noxious_gas(
level: &ParticleLevel,
source_block: [i32; 3],
pos: [f64; 3],
) -> bool {
let block = ParticleLevel::block_at(pos);
if !is_geyser_passable_block(level, block) {
return false;
}
let center = source_block.map(|c| f64::from(c) + 0.5);
let distance_sqr =
(center[0] - pos[0]).powi(2) + (center[1] - pos[1]).powi(2) + (center[2] - pos[2]).powi(2);
if distance_sqr > 9.0 {
return false;
}
let below_source = [center[0], f64::from(source_block[1] - 1) + 0.5, center[2]];
let below_pos = [pos[0], pos[1] - 1.0, pos[2]];
is_water_source(level, ParticleLevel::block_at(below_pos))
&& have_line_of_sight(level, below_source, below_pos)
}
/// `PotentSulfurBlockEntity.haveLineOfSight`: a clip over collider shapes and
/// no fluids (as the mobs' line of sight does) finds no block between the
/// points.
fn have_line_of_sight(level: &ParticleLevel, a: [f64; 3], b: [f64; 3]) -> bool {
let (from, to) = (Vec3::new(a[0], a[1], a[2]), Vec3::new(b[0], b[1], b[2]));
let mut scene = SceneCollisions {
world: level.world,
binding: level.collision,
subject: None,
};
let hit = traverse_blocks(from, to, 512, |p: BlockPos| {
let state = level.world.block(p.x, p.y, p.z);
if level.facts.is_air(state) {
return None;
}
let shape = level
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(from, to, p).map(|_| ())
});
!matches!(hit, Ok(Some(())))
}
@@ -150,6 +150,8 @@ pub struct Settings {
pub chat_links: bool,
pub chat_links_prompt: bool,
pub chat_opacity: f32,
/// Chat Delay in seconds (`chatDelay`): player chat shows this far apart.
pub chat_delay: f64,
pub text_background_opacity: f32,
pub chat_scale: f32,
pub chat_width: f32,
@@ -246,6 +248,7 @@ impl Default for Settings {
chat_links: true,
chat_links_prompt: true,
chat_opacity: 1.0,
chat_delay: 0.0,
text_background_opacity: 0.5,
chat_scale: 1.0,
chat_width: 1.0,
@@ -448,6 +451,7 @@ impl Settings {
chat_links: f.bool("chatLinks"),
chat_links_prompt: f.bool("chatLinksPrompt"),
chat_opacity: f.double("chatOpacity") as f32,
chat_delay: f.double("chatDelay"),
text_background_opacity: f.double("textBackgroundOpacity") as f32,
chat_scale: f.double("chatScale") as f32,
chat_width: f.double("chatWidth") as f32,
@@ -551,6 +555,9 @@ impl Settings {
put_b(&mut f, "chatLinks", self.chat_links);
put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt);
put_f(&mut f, "chatOpacity", self.chat_opacity);
if self.chat_delay != f.double("chatDelay") {
f.set("chatDelay", V::Double(self.chat_delay));
}
put_f(
&mut f,
"textBackgroundOpacity",
+87 -4
View File
@@ -31,9 +31,45 @@ impl Default for SignText {
lines: Default::default(),
color: BLACK,
glowing: false,
click: Default::default(),
}
}
}
/// The click events `SignBlockEntity.executeClickCommandsIfPresent` acts on.
#[derive(Debug, PartialEq)]
pub enum ClickAction<'a> {
/// `ClickEvent.RunCommand`.
RunCommand(String),
/// `ClickEvent.ShowDialog`: a registry id or the dialog itself.
ShowDialog(&'a Tag),
/// `ClickEvent.Custom`.
Custom {
id: String,
payload: Option<&'a Tag>,
},
}
/// A saved `click_event` as its action: the three a sign acts on, read the
/// way `ClickEvent.CODEC` does; any other action or a malformed one is None.
pub fn click_action(event: &Tag) -> Option<ClickAction<'_>> {
let Some(Tag::String(action)) = event.get("action") else {
return None;
};
match action.to_string_lossy().as_str() {
"run_command" => match event.get("command") {
Some(Tag::String(command)) => Some(ClickAction::RunCommand(command.to_string_lossy())),
_ => None,
},
"show_dialog" => event.get("dialog").map(ClickAction::ShowDialog),
"custom" => match event.get("id") {
Some(Tag::String(id)) => Some(ClickAction::Custom {
id: id.to_string_lossy(),
payload: event.get("payload"),
}),
_ => None,
},
_ => None,
}
}
impl SignText {
pub fn is_empty(&self) -> bool {
self.lines.iter().all(String::is_empty)
@@ -42,6 +78,14 @@ impl SignText {
pub fn text_color(&self) -> u32 {
DYE_TEXT[usize::from(self.color.min(15))].1
}
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
/// click event.
pub fn has_any_click_commands(&self) -> bool {
self.click
.iter()
.flatten()
.any(|event| matches!(click_action(event), Some(ClickAction::RunCommand(_))))
}
/// `AbstractSignRenderer.getDarkColor`.
pub fn dark_color(&self) -> u32 {
let c = self.text_color();
@@ -70,6 +114,13 @@ impl SignData {
pub fn line_height(&self) -> f32 {
if self.hanging { 9.0 } else { 10.0 }
}
/// `SignBlockEntity.canExecuteClickCommands`: a waxed sign whose side
/// has a `run_command` click event. (Horizon keeps no filtered text, so
/// `Player.isTextFilteringEnabled` never selects it.)
pub fn can_execute_click_commands(&self, front: bool) -> bool {
let side = if front { &self.front } else { &self.back };
self.waxed && side.has_any_click_commands()
}
/// NBT entries after the common block-entity header.
pub fn encode(&self) -> Vec<(&'static str, Tag)> {
let side = |t: &SignText| {
@@ -79,7 +130,18 @@ impl SignData {
Tag::List(
t.lines
.iter()
.map(|l| Tag::String(l.as_str().into()))
.zip(&t.click)
.map(|(line, click)| {
let text = Tag::String(line.as_str().into());
match click {
// The line's style keeps its click event.
Some(event) => Tag::compound([
("text", text),
("click_event", event.clone()),
]),
None => text,
}
})
.collect(),
),
),
@@ -90,19 +152,25 @@ impl SignData {
("has_glowing_text", Tag::Byte(i8::from(t.glowing))),
])
};
vec![
let mut out = Vec::new();
if self.allow_op_features {
out.push(("allow_op_features", Tag::Byte(1)));
}
out.extend([
("front_text", side(&self.front)),
("back_text", side(&self.back)),
("is_waxed", Tag::Byte(i8::from(self.waxed))),
]
]);
out
}
pub fn decode(&mut self, tag: &Tag) {
let side = |t: Option<&Tag>| -> SignText {
let mut out = SignText::default();
let Some(t) = t else { return out };
if let Some(Tag::List(messages)) = t.get("messages") {
for (line, m) in out.lines.iter_mut().zip(messages) {
for ((line, click), m) in out.lines.iter_mut().zip(&mut out.click).zip(messages) {
*line = component_text(m);
*click = click_event(m);
}
}
if let Some(Tag::String(c)) = t.get("color") {
@@ -117,6 +185,21 @@ impl SignData {
self.front = side(tag.get("front_text"));
self.back = side(tag.get("back_text"));
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
self.allow_op_features =
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
}
}
/// The `click_event` in the style of a stored line: the one of a component
/// object, or of the first component of a list (`createFromList` copies it
/// and appends the rest as siblings).
fn click_event(tag: &Tag) -> Option<Tag> {
let root = match tag {
Tag::List(list) => list.first()?,
other => other,
};
match root.get("click_event") {
Some(event @ Tag::Compound(_)) => Some(event.clone()),
_ => None,
}
}
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
@@ -159,6 +159,14 @@ impl Sprites {
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
/// textures are scaled up by whole pixels, so UVs stay per layer.
pub fn finish(&mut self) {
self.lay_out(true);
}
/// [`Sprites::finish`] for a server, which draws nothing: the layers'
/// size, but neither their pixels nor the animations' frames.
pub fn finish_without_pixels(&mut self) {
self.lay_out(false);
}
fn lay_out(&mut self, pixels: bool) {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
@@ -166,6 +174,12 @@ impl Sprites {
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
}
if !pixels {
self.pixels = Vec::new();
self.animations = Vec::new();
self.size = size;
return;
}
let mut resized = Vec::with_capacity(self.native.len());
self.pixels.clear();
for (from, frames, initial) in &self.native {
+15
View File
@@ -1078,6 +1078,21 @@ impl EditBox {
self.set_cursor(pos, shift);
}
}
/// `MultilineTextField.selectWordAtCursor` on a book page: the word before
/// the cursor is selected, its end found past non-whitespace only
/// (`getWordEndPosition`).
pub fn select_word_at_cursor(&mut self) {
let begin = multiline_word_position(&self.value, -1, self.cursor);
let end = begin
+ self
.value
.chars()
.skip(begin)
.take_while(|c| !java_whitespace(*c))
.count();
self.set_cursor(begin, false);
self.set_cursor(end, true);
}
/// EditBox.onDrag: the selection follows the pointer.
pub fn drag_to(&mut self, atlas: &HudAtlas, x: f32) {
let pos = self.position_at(atlas, x);
@@ -15,6 +15,9 @@ struct World {
passing: bool,
}
impl InteractionWorld for World {
// The fixture's targets don't change within a tick.
fn pick_hit_result(&mut self) {}
fn release_hit_result(&mut self) {}
fn using_item(&self) -> bool {
self.using
}
@@ -198,6 +198,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
let vanilla = match name {
"Float" => "FloatGoal",
"Stroll" => "WaterAvoidingRandomStrollGoal",
"ParrotWander" => "ParrotWanderGoal",
"RandomStroll" => "RandomStrollGoal",
// AbstractFish's private subclass of RandomSwimmingGoal.
"RandomSwimming"
@@ -76,6 +76,14 @@ fn options(case: &Value) -> Option<ParticleOptions> {
..ParticleOptions::simple(ty)
},
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
ParticleType::Geyser | ParticleType::GeyserPlume => {
ParticleOptions::geyser(ty, o["water_blocks"].as_i64()? as i32)
}
ParticleType::GeyserBase | ParticleType::GeyserPoof => ParticleOptions::geyser_base(
ty,
o["water_blocks"].as_i64()? as i32,
o["burst_impulse_base"].as_f64()? as f32,
),
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
ParticleType::Trail => {
let t = &o["target"];
@@ -36,7 +36,7 @@ pub fn main() -> Result<()> {
aquatic::squid_cycle_event(&mut mob);
}
let velocity = mob.velocity;
aquatic::squid_client_step(&mut mob);
aquatic::squid_client_step(&mut mob, &mut Vec::new());
assert_eq!(
velocity, mob.velocity,
"a remote animation must preserve packet velocity"
+85 -1
View File
@@ -20,6 +20,9 @@ pub struct Tab {
pub column: u32,
pub search: bool,
pub inventory: bool,
/// `CreativeModeTab.Type.HOTBAR`: the saved toolbars, filled when the
/// tab is selected (`set_saved_hotbars`).
pub hotbar: bool,
}
/// What the client's creative search trees (SessionSearchTrees) index for one
/// stack of the search tab: its creative tooltip lines and its item tags.
@@ -229,7 +232,7 @@ impl Creative {
}
search_items = items.clone();
}
if kind == "HOTBAR" || t["visible"] == false {
if t["visible"] == false {
continue;
}
let icon = t["icon"]
@@ -244,6 +247,7 @@ impl Creative {
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
});
}
Ok(Self::new(tabs, search_items, search_index))
@@ -277,6 +281,7 @@ impl Creative {
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
});
}
let search_index = search_items
@@ -333,6 +338,7 @@ impl Creative {
column: t.column,
search: t.search,
inventory: t.inventory,
hotbar: t.hotbar,
})
.collect()
}
@@ -464,6 +470,23 @@ impl Creative {
});
items
}
/// The stack in grid cell `index` (row-major from the tab's top), None
/// for an empty cell: a saved toolbar's empty slot, or past the end.
pub fn shown(&self, names: &Names, index: usize) -> Option<ItemStack> {
self.items(names)
.get(index)
.filter(|s| s.count > 0)
.cloned()
}
/// `CreativeModeInventoryScreen.selectTab` for the HOTBAR tab: the nine
/// saved rows of `HotbarManager` as the tab's items, an empty stack (count
/// 0) for an empty slot.
pub fn set_saved_hotbars(&mut self, items: Vec<ItemStack>) {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.hotbar) {
tab.items = items;
}
self.forget_items();
}
/// Drop the shown items, so the next `items` works them out again: a
/// reload can rename items without changing the Search label.
pub fn forget_items(&self) {
@@ -664,6 +687,13 @@ pub enum DisplayClick {
Drop(ItemStack),
}
/// Whether a stack has `minecraft:creative_slot_lock`.
pub fn slot_locked(stack: &ItemStack) -> bool {
stack
.data()
.is_some_and(|d| d.other("minecraft:creative_slot_lock").is_some())
}
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
/// the tab's end) and `max` gives an item's max stack size. The tab's items
@@ -676,6 +706,11 @@ pub fn click_display(
input: crate::menu::Input,
) -> DisplayClick {
use crate::menu::Input;
// CustomCreativeSlot.mayPickup: a `creative_slot_lock` stack (the saved
// toolbars' hint) can't be clicked at all.
if shown.is_some_and(slot_locked) {
return DisplayClick::Nothing;
}
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
let quick_key = input == Input::QuickMove;
match input {
@@ -738,6 +773,7 @@ pub fn click_display(
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::Tag;
fn stack(item: &str, count: u32) -> ItemStack {
ItemStack {
@@ -1047,6 +1083,53 @@ mod tests {
assert_eq!(c.search.value, "");
}
#[test]
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
{"id": "minecraft:hotbar", "type": "HOTBAR", "row": "TOP", "column": 5, "title": "Saved Hotbars",
"icon": "{count:1,id:\"minecraft:bookshelf\"}", "visible": true, "items": []},
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
"icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []}
]}))
.unwrap();
assert!(c.tab().hotbar);
assert!(!c.tab_headers()[1].hotbar);
let mut hint = stack("paper", 1);
hint.data_mut().set_other(
"minecraft:creative_slot_lock",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
// A saved sword in slot 0, an empty slot, then the hint.
c.set_saved_hotbars(vec![stack("diamond_sword", 1), stack("air", 0), hint]);
let names = Names {
map: HashMap::new(),
};
assert_eq!(c.shown(&names, 0), Some(stack("diamond_sword", 1)));
assert_eq!(c.shown(&names, 1), None);
assert_eq!(c.shown(&names, 3), None);
let hint = c.shown(&names, 2).unwrap();
assert!(slot_locked(&hint));
assert!(!slot_locked(&stack("diamond_sword", 1)));
// CustomCreativeSlot.mayPickup: the hint is never taken, and a carried
// stack stays as it is.
use crate::menu::Input::*;
for (carried, button, input) in [
(None, 0, Pickup),
(None, 0, QuickMove),
(None, 2, Clone),
(None, 3, Swap),
(None, 0, Throw),
(Some(stack("dirt", 2)), 0, Pickup),
(Some(stack("dirt", 2)), 1, Pickup),
] {
let mut carried = carried;
let before = carried.clone();
let effect = click_display(&mut carried, Some(&hint), |_| 64, button, input);
assert_eq!(effect, DisplayClick::Nothing);
assert_eq!(carried, before);
}
}
#[test]
fn search_matches_vanilla_trees() {
let c = creative(0);
@@ -1191,6 +1274,7 @@ mod tests {
column,
search: false,
inventory: false,
hotbar: false,
};
// Top row, second column: the button spans x 27..53, y -28..4.
let t = tab(true, 1);
+7 -2
View File
@@ -179,12 +179,17 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
};
}
// CommonLayouts.labeledElement: the label, 4 below it the box,
// both on the left.
// both on the left. The label is a plain StringWidget: it has no
// click handler, so a click event in it does nothing.
let mut shown = label.clone();
for span in &mut shown.spans {
span.style.click = None;
}
Element {
w: w.max(font.width_text(label)),
h: 9.0 + 4.0 + 20.0,
parts: vec![
Part::Line(label.clone(), 0.0, 0.0),
Part::Line(shown, 0.0, 0.0),
Part::Field {
input: i,
rect: [0.0, 13.0, w, 20.0],
+164 -1
View File
@@ -150,6 +150,123 @@ fn player_uuid() -> [i32; 4] {
u as u32 as i32,
]
}
/// `ResolvableProfile.CODEC`: the name, id and properties of a profile, then the
/// skin's texture ids and model (`PlayerSkin.Patch`: "texture", "cape", "elytra", "model").
fn profile_nbt(p: &horizon_protocol::entity_data::Profile) -> Tag {
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
if let Some(name) = &p.name {
entries.push(("name", string(name)));
}
if let Some(id) = p.uuid {
entries.push((
"id",
Tag::IntArray(vec![
(id >> 96) as i32,
(id >> 64) as i32,
(id >> 32) as i32,
id as i32,
]),
));
}
if !p.properties.is_empty() {
let properties = p
.properties
.iter()
.map(|property| {
let mut entries: Vec<(&'static str, Tag)> = vec![
("name", string(&property.name)),
("value", string(&property.value)),
];
if let Some(signature) = &property.signature {
entries.push(("signature", string(signature)));
}
Tag::compound(entries)
})
.collect();
entries.push(("properties", Tag::List(properties)));
}
for (key, texture) in [
("texture", &p.body),
("cape", &p.cape),
("elytra", &p.elytra),
] {
if let Some(texture) = texture {
entries.push((key, string(texture)));
}
}
if let Some(slim) = p.slim {
entries.push(("model", string(if slim { "slim" } else { "wide" })));
}
Tag::compound(entries)
}
/// `ResolvableProfile.CODEC` read back: the compound `profile_nbt` writes, or a
/// bare player name (`Codec.withAlternative(FULL_CODEC, PLAYER_NAME, ...)`).
fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile> {
if let Tag::String(name) = tag {
return Some(horizon_protocol::entity_data::Profile {
name: Some(name.to_string_lossy()),
uuid: None,
properties: Vec::new(),
body: None,
cape: None,
elytra: None,
slim: None,
});
}
let property = |name: String, value: &Tag, signature: Option<String>| match value {
Tag::String(v) => Some(horizon_protocol::packets::login::Property {
name,
value: v.to_string_lossy(),
signature,
}),
_ => None,
};
let properties: Vec<horizon_protocol::packets::login::Property> = match tag.get("properties") {
// `ExtraCodecs.PROPERTY_MAP`: a list of properties ...
Some(Tag::List(list)) => list
.iter()
.filter_map(|p| {
let name = match p.get("name")? {
Tag::String(s) => s.to_string_lossy(),
_ => return None,
};
let signature = match p.get("signature") {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
property(name, p.get("value")?, signature)
})
.collect(),
// ... or a map from a name to its values.
Some(Tag::Compound(map)) => map
.iter()
.flat_map(|(name, values)| match values {
Tag::List(values) => values
.iter()
.filter_map(|v| property(name.to_string_lossy(), v, None))
.collect::<Vec<_>>(),
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
};
let text = |key: &str| match tag.get(key) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
Some(horizon_protocol::entity_data::Profile {
name: text("name"),
uuid: uuid_from_tag(tag.get("id")).map(|a| {
a.iter()
.fold(0u128, |u, &x| (u << 32) | u128::from(x as u32))
}),
properties,
body: text("texture"),
cape: text("cape"),
elytra: text("elytra"),
slim: text("model").map(|model| model == "slim"),
})
}
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: &m.kind.id,
@@ -196,6 +313,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
}
}
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears.
if m.species() == crate::mobs::Species::Mannequin
&& let Some(profile) = &m.profile
{
put(&mut tag, "profile", profile_nbt(profile));
}
// `Entity.saveWithoutId` writes the flag only when set.
if m.name_visible {
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
@@ -337,6 +460,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
}
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
// effects are not modelled).
"minecraft:mooshroom" => {
let kind = if m.extra.variant == 1 { "brown" } else { "red" };
map.insert("Type".into(), string(kind));
}
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
@@ -668,6 +797,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
map.insert("Color".into(), Tag::Byte(m.extra.color as i8));
}
// GlowSquid.addAdditionalSaveData.
"minecraft:glow_squid" => {
map.insert(
"DarkTicksRemaining".into(),
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
);
}
"minecraft:slime" | "minecraft:magma_cube" => {
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
map.insert("wasOnGround".into(), Tag::Byte(i8::from(m.extra.flag2)));
@@ -1047,6 +1183,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.angry_at = Some(crate::mobs::Who::Player);
}
}
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin.
if m.species() == crate::mobs::Species::Mannequin {
m.profile = tag.get("profile").and_then(profile_from_nbt);
}
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
@@ -1183,6 +1323,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
}
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
// Variant.DEFAULT, red.
crate::mobs::Species::Mooshroom => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown"));
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
@@ -1362,6 +1507,13 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
m.extra.sheared = flag(tag, "Sheared");
}
// GlowSquid.readAdditionalSaveData: not dimmed when absent.
if m.species() == crate::mobs::Species::GlowSquid {
crate::mobs::aquatic::set_dark_ticks(
&mut m,
number(tag.get("DarkTicksRemaining")).unwrap_or(0) as i32,
);
}
// IronGolem.readAdditionalSaveData: not player created when absent.
if m.species() == crate::mobs::Species::IronGolem {
m.extra.player_created = flag(tag, "PlayerCreated");
@@ -3019,7 +3171,7 @@ mod sheep_nbt_tests {
}
#[test]
fn foxes_rabbits_and_goats_keep_their_kind() {
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
@@ -3043,6 +3195,17 @@ mod sheep_nbt_tests {
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
assert_eq!(number(tag.get("Sleeping")), Some(0));
assert_eq!(back.extra.variant, 1);
// MushroomCow: a brown mooshroom stays brown, and a save without (or
// with an unknown) Type reads as red.
let mut mooshroom = new("minecraft:mooshroom");
mooshroom.extra.variant = 1;
let (back, tag) = round_trip(&mooshroom);
assert_eq!(text(tag.get("Type")).as_deref(), Some("brown"));
assert_eq!(back.extra.variant, 1);
mooshroom.extra.variant = 0;
let (back, tag) = round_trip(&mooshroom);
assert_eq!(text(tag.get("Type")).as_deref(), Some("red"));
assert_eq!(back.extra.variant, 0);
let mut rabbit = new("minecraft:rabbit");
rabbit.extra.variant = 4;
let (back, tag) = round_trip(&rabbit);
+116 -9
View File
@@ -1,5 +1,6 @@
//! FallingBlockEntity's impact damage, anvil wear, saved impact settings and
//! the cobwebs that hold it back.
//! the blocks it moves through (cobwebs, bubble columns).
use crate::motion::BubbleColumn;
use crate::{
mobs::{Damage as MobDamage, DamageKind, Mob},
save::{float, number, put},
@@ -118,30 +119,76 @@ pub fn bounds(position: Vec3) -> Aabb {
pub fn web_multiplier() -> Vec3 {
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
}
/// What a block a falling block moves through does to it in `entityInside`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Inside {
/// `WebBlock`: `Entity.makeStuckInBlock`.
Cobweb,
/// `BubbleColumnBlock`: lifts or drags it.
BubbleColumn(BubbleColumn),
Nothing,
}
/// `Entity.applyEffectsFromBlocks` after a falling block's move this tick
/// (`from` to `to`; `original` the move it asked for, when the move was
/// recorded): whether `checkInsideBlocks` reaches a cobweb (`is_web`), whose
/// `entityInside` makes it stuck. Each movement sweeps its axes in
/// `Direction.axisStepOrder` within 16 iterations, then the end alone.
pub fn passes_cobweb(
/// recorded; `velocity` already zeroed on the axes `Entity.move` collided on):
/// the blocks it went through (`block` says what each is) push it and reset
/// its fall. Returns whether a cobweb made it stuck.
pub fn apply_effects_from_blocks(
from: Vec3,
to: Vec3,
original: Option<Vec3>,
mut is_web: impl FnMut(BlockPos) -> bool,
velocity: &mut Vec3,
fall_distance: &mut f64,
mut block: impl FnMut(BlockPos) -> Inside,
) -> bool {
let mut stuck = false;
inside_blocks(from, to, original, |p, intersects| match block(p) {
Inside::Cobweb => {
// makeStuckInBlock forgets the fall.
*fall_distance = 0.0;
stuck = true;
}
// BubbleColumnBlock.entityInside acts only when intersecting.
Inside::BubbleColumn(column) if intersects => {
if column.push(velocity) {
*fall_distance = 0.0;
}
}
Inside::BubbleColumn(_) | Inside::Nothing => {}
});
stuck
}
/// Each block `checkInsideBlocks` reaches, once, in order, with
/// whether its `entityInside` counts the block as intersected (every block of a
/// movement a block or longer, else only those the box ends in). Each movement
/// sweeps its axes in `Direction.axisStepOrder` within 16 iterations, then the
/// end alone. The blocks the caller acts on (cobwebs, bubble columns) have a
/// whole-block `getEntityInsideCollisionShape`, so every one visited counts.
fn inside_blocks(
from: Vec3,
to: Vec3,
original: Option<Vec3>,
mut visit: impl FnMut(BlockPos, bool),
) {
let mut seen = std::collections::HashSet::new();
// checkInsideBlocks(from, to, ..): the iterations used plus one.
let mut check = |from: Vec3, to: Vec3, max: i32| {
let d = f64::from(1.0e-5_f32);
let target = bounds(to).deflate(Vec3::new(d, d, d));
// The move spans a block or more: Mth.square(1.0 - 1.0E-5F).
let long = from.distance_squared(to) > (1.0 - d) * (1.0 - d);
let mut iterations = 0;
let _ = sweep_intersected_blocks(from, to, target, 1_000_000, |p, iteration| {
if iteration >= max {
return false;
}
iterations = iteration;
// getEntityInsideCollisionShape is the whole block for cobwebs.
stuck |= is_web(p);
if seen.insert(p) {
let corner = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
let inside =
target.intersects_corners(corner, corner.add_vector(Vec3::new(1.0, 1.0, 1.0)));
visit(p, long || inside);
}
true
});
iterations + 1
@@ -170,7 +217,6 @@ pub fn passes_cobweb(
if budget <= 0 {
check(to, to, 1);
}
stuck
}
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
@@ -249,6 +295,23 @@ mod tests {
assert!(!wear_anvil(&mut state));
}
}
fn passes_cobweb(
from: Vec3,
to: Vec3,
original: Option<Vec3>,
is_web: impl Fn(BlockPos) -> bool,
) -> bool {
let (mut velocity, mut fall) = (Vec3::ZERO, 3.0);
let stuck = apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, |p| {
if is_web(p) {
Inside::Cobweb
} else {
Inside::Nothing
}
});
assert_eq!(fall == 0.0, stuck);
stuck
}
#[test]
fn a_cobweb_on_the_path_or_at_the_end_holds_a_falling_block() {
let web = BlockPos::new(0, 5, 0);
@@ -285,6 +348,50 @@ mod tests {
assert!(!passes_cobweb(beside, beside, None, is_web));
}
#[test]
fn bubble_columns_lift_or_drag_a_falling_block() {
// Soul sand under x 0 lifts, magma under x 3 drags, both up to y 9.
let block = |p: BlockPos| match (p.x, p.y, p.z) {
(0 | 3, 0..=9, 0) => Inside::BubbleColumn(BubbleColumn {
drag: p.x == 3,
surface: p.y == 9,
}),
_ => Inside::Nothing,
};
let step = |x: f64, from: f64, to: f64, vy: f64| {
let (from, to) = (Vec3::new(x, from, 0.5), Vec3::new(x, to, 0.5));
let mut velocity = Vec3::new(0.0, vy, 0.0);
let mut fall = 2.0;
let original = Some(to.subtract(from));
let stuck =
apply_effects_from_blocks(from, to, original, &mut velocity, &mut fall, block);
assert!(!stuck);
(velocity.y, fall)
};
let near = |a: f64, b: f64| (a - b).abs() < 1.0e-9;
// Released in the column: its box ends in blocks 5 and 6, each lifts
// it by 0.06 (handleOnInsideBubbleColumn), and its fall is forgotten.
let (vy, fall) = step(0.5, 5.5, 5.46, -0.04);
assert!(near(vy, 0.08), "{vy}");
assert_eq!(fall, 0.0);
// The lift is capped at 0.7.
assert!(near(step(0.5, 5.0, 5.3, 0.68).0, 0.7));
// Falling a block or more into the surface block: every column
// block on the way counts, the surface throws it up by 0.1
// (handleOnAboveBubbleColumn), which keeps its fall.
let (vy, fall) = step(0.5, 11.0, 9.5, -1.5);
assert!(near(vy, -1.5 + 0.1), "{vy}");
assert_eq!(fall, 2.0);
// Magma drags down by 0.03, at most to -0.3 inside and -0.9 at the surface.
assert!(near(step(3.5, 5.5, 5.4, -0.1).0, -0.16));
assert!(near(step(3.5, 5.5, 5.4, -0.29).0, -0.3));
assert!(near(step(3.5, 9.8, 9.6, -0.89).0, -0.9));
// A short move that leaves the column behind: the block it went
// through no longer intersects its box, so nothing pushes it.
assert_eq!(step(0.5, 9.95, 10.5, 0.55), (0.55, 2.0));
// Beside the column.
assert_eq!(step(1.5, 5.5, 5.46, -0.04), (-0.04, 2.0));
}
#[test]
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
use crate::mobs::{Mob, MobTypes};
+215 -90
View File
@@ -83,6 +83,7 @@ mod creatures;
mod crossbow;
pub(crate) mod cushions;
mod debug_keys;
mod debug_stick;
mod decorated_pot;
mod dedicated;
mod deflect;
@@ -180,6 +181,7 @@ mod piglins;
mod piston_push;
mod player_equip;
mod postprocess;
mod potent_sulfur;
mod potions;
mod powder_snow;
mod pumpkin;
@@ -216,6 +218,7 @@ mod spawner;
mod spear;
mod spectator;
mod spill;
mod splash_water;
mod startup_menu;
mod station_control;
mod stations;
@@ -594,6 +597,7 @@ struct Mining {
progress: f32,
ticks: u32,
}
#[derive(Clone, Copy)]
struct Hit {
pos: [i32; 3],
face: [i32; 3],
@@ -1193,6 +1197,14 @@ impl LevelAccess for GameLevel<'_> {
fn set_command_block_powered(&mut self, pos: BlockPos, powered: bool) -> Option<(bool, bool)> {
command_blocks::set_powered(self.world, self.registry, (pos.x, pos.y, pos.z), powered)
}
/// `PotentSulfurBlockEntity.resetCountdown`.
fn reset_potent_sulfur_countdown(&mut self, pos: BlockPos) {
if let Some(BlockEntity::PotentSulfur(sulfur)) =
self.world.block_entities.get_mut(&(pos.x, pos.y, pos.z))
{
sulfur.waiting_countdown = -1;
}
}
}
/// FallingBlockEntity carrying a block state.
#[derive(Clone, Debug)]
@@ -1377,6 +1389,17 @@ impl UiResources {
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
})
}
/// What a server's game holds, since it draws nothing: the font, which books
/// and chat measure with, and a one-pixel panorama.
pub fn server(root: &std::path::Path) -> Result<Self> {
Ok(Self {
atlas: HudAtlas::fonts(root)?,
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
}),
})
}
}
/// What a resource pack reload replaces (`Minecraft.reloadResourcePacks`):
/// block models and sprites with their meshing tables, item models,
@@ -1682,6 +1705,8 @@ struct Game<'a> {
sign_edit: Option<sign_edit::SignEditor>,
/// An open book, book and quill or lectern screen.
book: Option<books::BookScreen>,
/// The last click on the book screen, for double clicks.
book_last_click: Option<std::time::Instant>,
/// Lecterns whose page-turn pulse is running (ticks left).
lectern_pulses: Vec<((i32, i32, i32), i32)>,
/// LevelLoadingScreen.smoothedProgress while the player is not placed yet.
@@ -2006,6 +2031,10 @@ struct Game<'a> {
world_icon: Option<WorldIcon>,
/// The block a guest is acting on (game/lan_actions.rs).
forced_hit: Option<Hit>,
/// `Minecraft.hitResult` while the clicks of a tick act (game/remote.rs
/// `remote_handle_input`): picked once, so a creative break does not
/// move the use that follows it past the broken block.
tick_hit: Option<Option<Hit>>,
/// The part of a right click a guest's packet asks for (game/lan_actions.rs).
guest_use: Option<GuestUse>,
/// Level events of this tick for the guests, and the guest acting now.
@@ -2038,6 +2067,10 @@ struct Game<'a> {
/// have each chest open.
lid_openers: HashMap<(i32, i32, i32), i32>,
lan_acting: Option<u32>,
/// The block the acting player is breaking (`break_block`): its destroy
/// event, and the ones before it, are the player's own (`Level.levelEvent`
/// with the player excluded); the events after it are not.
lan_break_pos: Option<[i32; 3]>,
/// The name of the guest `lan_acting` acts for (`Player.getName`).
lan_acting_name: Option<String>,
/// Their UUID (`Player.getUUID`).
@@ -2273,12 +2306,11 @@ impl Game<'_> {
/// each block's outline shape (`BlockGetter.clip` with `ClipContext.Block.OUTLINE`).
fn pick(&self) -> Option<Hit> {
// A guest of a hosted world acts on the block their client targeted.
if let Some(hit) = &self.forced_hit {
return Some(Hit {
pos: hit.pos,
face: hit.face,
point: hit.point,
});
if let Some(hit) = self.forced_hit {
return Some(hit);
}
if let Some(hit) = self.tick_hit {
return hit;
}
let (origin, dir) = self.aim()?;
self.clip_ray(origin, dir, self.reach())
@@ -2958,9 +2990,13 @@ impl Game<'_> {
if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) {
let before = item.stack.count;
let kind = item.stack.item.clone();
let thrower = item.thrower;
match self.give(item.stack.clone()) {
None => {
self.stats.add("picked_up", &kind, before as i32);
if let Some(thrower) = thrower {
self.trigger_thrown_picked_up_by_player(&item.stack, thrower);
}
self.add_pickup(
pickups::Taken::Item(Box::new(item)),
pickups::Collector::Player,
@@ -2978,6 +3014,9 @@ impl Game<'_> {
if rest.count < before {
self.stats
.add("picked_up", &kind, (before - rest.count) as i32);
if let Some(thrower) = thrower {
self.trigger_thrown_picked_up_by_player(&rest, thrower);
}
self.add_pickup(
pickups::Taken::Item(Box::new(item.clone())),
pickups::Collector::Player,
@@ -4112,22 +4151,7 @@ impl Game<'_> {
];
return Ok(FallStep::Landed(pos));
}
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
// (Entity.makeStuckInBlock, which forgets the fall). Entity.move
// records the move unless it was blocked short of a tiny one.
let moved = movement.length_squared();
let recorded = moved > 1.0e-7 || wanted.length_squared() - moved < 1.0e-7;
let world = &self.world;
let facts = self.res.facts();
if horizon_client::falling_block::passes_cobweb(
from,
f.position,
recorded.then_some(wanted),
|p| &*facts.get(world.block(p.x, p.y, p.z)).name == "minecraft:cobweb",
) {
f.fall_distance = 0.0;
f.stuck = horizon_client::falling_block::web_multiplier();
}
// Entity.move stops it on the axes it collided on.
if movement.x != wanted.x {
f.velocity.x = 0.0;
}
@@ -4137,6 +4161,37 @@ impl Game<'_> {
if movement.z != wanted.z {
f.velocity.z = 0.0;
}
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
// (Entity.makeStuckInBlock), BubbleColumnBlock.entityInside lifts or
// drags it. Entity.move records the move unless it was blocked short
// of a tiny one.
let moved = movement.length_squared();
let recorded = moved > 1.0e-7 || wanted.length_squared() - moved < 1.0e-7;
let world = &self.world;
let facts = self.res.facts();
let (registry, collision) = (self.pack.assets.registry, self.collision);
let stuck = horizon_client::falling_block::apply_effects_from_blocks(
from,
f.position,
recorded.then_some(wanted),
&mut f.velocity,
&mut f.fall_distance,
|p| {
use horizon_client::falling_block::Inside;
if &*facts.get(world.block(p.x, p.y, p.z)).name == "minecraft:cobweb" {
Inside::Cobweb
} else if let Some(column) =
horizon_client::motion::bubble_column_at(world, registry, facts, collision, p)
{
Inside::BubbleColumn(column)
} else {
Inside::Nothing
}
},
);
if stuck {
f.stuck = horizon_client::falling_block::web_multiplier();
}
f.velocity = Vec3::new(
f.velocity.x * 0.98,
f.velocity.y * 0.98,
@@ -4228,9 +4283,17 @@ impl Game<'_> {
self.stats.add("used", &stack.item, 1);
}
fn break_block(&mut self, pos: [i32; 3]) -> Result<()> {
self.with_player_actor(|game| game.break_block_as_player(pos))
self.lan_break_pos = Some(pos);
let broken = self.with_player_actor(|game| game.break_block_as_player(pos));
self.lan_break_pos = None;
broken
}
fn break_block_as_player(&mut self, pos: [i32; 3]) -> Result<()> {
// DebugStickItem.canDestroyBlock: a left click selects a property and
// never destroys the block.
if !self.debug_stick_can_destroy(pos)? {
return Ok(());
}
if !self
.res
.items()
@@ -4551,7 +4614,8 @@ impl Game<'_> {
}
// CeilingHangingSignBlock.useItemOn: a hanging sign aimed at the
// underside passes, so the next sign chains below instead of
// opening the editor (signs here carry no click commands).
// opening the editor, unless the side has click commands to run
// (`shouldTryToChainAnotherHangingSign`).
let chains_sign = hit.face == [0, -1, 0]
&& held
.as_ref()
@@ -4569,7 +4633,8 @@ impl Game<'_> {
.block_entities
.get(&(hit.pos[0], hit.pos[1], hit.pos[2])),
Some(BlockEntity::Sign(_))
);
)
&& !self.sign_can_execute_click_commands((hit.pos[0], hit.pos[1], hit.pos[2]));
// TRY_WITH_EMPTY_HAND: only the main hand's turn uses the block
// itself (useWithoutItem).
if !anchor_passed && !chains_sign && self.hand == Hand::Main {
@@ -4633,6 +4698,24 @@ impl Game<'_> {
{
return Ok(false);
}
// ItemStack.useOn: after the block's own useItemOn/useWithoutItem
// passed. DebugStickItem.useOn cycles the selected property.
if let Some(hit) = block_hit
&& let Some(success) = self.debug_stick_use_on(hit.pos)?
{
// `Minecraft.startUseItem`: the stick's client-side `useOn` is
// SUCCESS, so the actor's hand swings; a remote guest swings in
// its own client, not on the server.
if self.lan_acting.is_none() {
self.swing_arm();
}
if success {
// `ServerPlayerGameMode.useItemOn`: a consuming `useOn`
// triggers ITEM_USED_ON_BLOCK with the stack before the use.
self.trigger_block("minecraft:item_used_on_block", hit.pos, &stack);
}
return Ok(true);
}
if block_hit.is_some() && (self.use_bone_meal(&stack)? || self.ignite_tnt(&stack)?) {
return Ok(true);
}
@@ -5591,14 +5674,8 @@ impl Game<'_> {
pitch,
..
}) => {
let name = match sound {
horizon_client::landing::SceneSound::BigFall => {
"minecraft:entity.player.big_fall"
}
horizon_client::landing::SceneSound::SmallFall => {
"minecraft:entity.player.small_fall"
}
_ => continue,
let Some(name) = sound.player_fall_sound() else {
continue;
};
self.audio.play(name, Some(feet), volume, pitch);
}
@@ -5707,6 +5784,12 @@ impl Game<'_> {
self.level.last_death =
Some((self.dim.id().to_owned(), feet.map(|v| v.floor() as i32)));
self.death_message();
// The mobs bound to a guest see that guest as their player.
let bindings = match self.lan.as_ref() {
Some(host) if !host.hub.guests.is_empty() => host.mob_players.clone(),
_ => Default::default(),
};
self.tell_neutral_mobs_player_died(feet, None, &bindings);
// ServerPlayer.die: deaths, and the time-since counters restart.
self.stats.custom("deaths", 1);
self.score_player_died();
@@ -6648,7 +6731,8 @@ impl Game<'_> {
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_),
| BlockEntity::Shelf(_)
| BlockEntity::PotentSulfur(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
@@ -6934,7 +7018,8 @@ impl Game<'_> {
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_),
| BlockEntity::Shelf(_)
| BlockEntity::PotentSulfur(_),
) => {}
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
None => {
@@ -7066,12 +7151,13 @@ impl Game<'_> {
search.move_cursor_to_end(false);
search.highlight = 0;
}
let items = self.creative_screen.items(&self.pack.names);
let first = self.creative_screen.first_row(&self.pack.names);
let shown = items.get(first * 9 + slot - Creative::DISPLAY);
let shown = self
.creative_screen
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY);
let catalog = world_items!(self);
let max = |s: &ItemStack| catalog.get(&s.item).map_or(64, |i| i.max_stack);
match click_display(&mut self.carried, shown, max, button, input) {
match click_display(&mut self.carried, shown.as_ref(), max, button, input) {
DisplayClick::Hotbar(i, stack) if i < self.inventory.len() => {
self.inventory[i] = Some(stack);
}
@@ -7670,9 +7756,11 @@ impl Game<'_> {
None => Hover::Nothing,
Some(slot) if slot >= Creative::DISPLAY => {
let first = self.creative_screen.first_row(&self.pack.names);
let shown = self.creative_screen.items(&self.pack.names).len();
Hover::Display {
filled: first * 9 + slot - Creative::DISPLAY < shown,
filled: self
.creative_screen
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY)
.is_some(),
}
}
Some(_) => Hover::Slot,
@@ -9321,6 +9409,39 @@ impl Game<'_> {
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
(out, stars)
}
/// What the title screen asked for, from a click or Enter.
fn title_action(&mut self, event_loop: &ActiveEventLoop, action: Action) -> Result<()> {
match action {
Action::Quit => event_loop.exit(),
Action::Options => self.open_options(true),
Action::Accessibility => {
self.open_options_at(true, horizon_client::options_ui::Page::Accessibility)
}
Action::Language => {
self.open_options_at(true, horizon_client::options_ui::Page::Language)
}
Action::Play { folder } => self.open_world(&folder, None, None, false, None)?,
Action::Create {
folder,
name,
seed,
creative,
options,
} => self.open_world(&folder, Some(&name), seed, creative, Some(&options))?,
Action::Connect { address } => {
self.connect_server(&address, &self.options.player_name())?
}
Action::DataPacks { dir, selected } => {
self.keys.clear();
self.screen = Screen::Options;
self.options_from_title = true;
let resources = self.vanilla_root();
self.ui.options.open_data_packs(dir, selected, resources);
}
Action::None => {}
}
Ok(())
}
/// Enter a local world from the client, selecting its simulation mode.
fn open_world(
&mut self,
@@ -11718,7 +11839,7 @@ impl Game<'_> {
let first = self.creative_screen.first_row(&self.pack.names);
for r in 0..5 {
for c in 0..9 {
let Some(stack) = items.get((first + r) * 9 + c) else {
let Some(stack) = items.get((first + r) * 9 + c).filter(|s| s.count > 0) else {
continue;
};
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
@@ -11828,6 +11949,7 @@ impl Game<'_> {
let (tx, ty) = Creative::tab_position(t);
if cx >= ox + tx && cx < ox + tx + 26.0 && cy >= oy + ty && cy < oy + ty + 32.0 {
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, i);
self.refresh_saved_hotbars();
self.inv_drag = None;
self.last_inv_click = None;
return;
@@ -12739,11 +12861,11 @@ impl Game<'_> {
&& self.creative_screen.tab().search
&& self.search_tab_hotbar_key(key) => {}
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat(""),
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat_method(""),
// key.advancements.
KeyCode::KeyL if self.screen == Screen::Playing => self.open_advancements(false),
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat("/"),
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat_method("/"),
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
KeyCode::F11 => self.toggle_fullscreen(),
@@ -12761,6 +12883,7 @@ impl Game<'_> {
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
self.refresh_saved_hotbars();
} else {
self.screen = Screen::Inventory;
}
@@ -13467,7 +13590,7 @@ impl ApplicationHandler for Game<'_> {
self.command_block_scroll(d);
}
if self.screen == Screen::Options && d != 0.0 {
self.options_scroll(d);
self.options_scroll(d)?;
}
// AbstractContainerScreen.mouseScrolled: a bundle under the
// cursor takes the wheel before the screen's own scrolling.
@@ -13573,38 +13696,7 @@ impl ApplicationHandler for Game<'_> {
if sound {
self.audio.play_button_click();
}
match action {
Action::Quit => event_loop.exit(),
Action::Options => self.open_options(true),
Action::Accessibility => self
.open_options_at(true, horizon_client::options_ui::Page::Accessibility),
Action::Language => {
self.open_options_at(true, horizon_client::options_ui::Page::Language)
}
Action::Play { folder } => {
self.open_world(&folder, None, None, false, None)?
}
Action::Create {
folder,
name,
seed,
creative,
options,
} => {
self.open_world(&folder, Some(&name), seed, creative, Some(&options))?
}
Action::Connect { address } => {
self.connect_server(&address, &self.options.player_name())?
}
Action::DataPacks { dir, selected } => {
self.keys.clear();
self.screen = Screen::Options;
self.options_from_title = true;
let resources = self.vanilla_root();
self.ui.options.open_data_packs(dir, selected, resources);
}
Action::None => {}
}
self.title_action(event_loop, action)?;
}
WindowEvent::MouseInput { .. } if self.title.is_some() => {}
WindowEvent::MouseInput { state, button, .. } if self.book.is_some() => {
@@ -13905,10 +13997,21 @@ impl ApplicationHandler for Game<'_> {
return Ok(());
}
match event.physical_key {
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => {
if let Some(t) = self.title.as_mut() {
t.confirm_edit();
// `WorldListEntry.keyPressed`: Space joins too, with the button click sound.
PhysicalKey::Code(
code @ (KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space),
) if code != KeyCode::Space
|| self.title.as_ref().is_some_and(|t| t.list_focused()) =>
{
let action = self
.title
.as_mut()
.map_or(Action::None, |t| t.confirm_key());
self.save_title_options();
if matches!(action, Action::Play { .. }) {
self.audio.play_button_click();
}
self.title_action(event_loop, action)?;
}
PhysicalKey::Code(
key @ (KeyCode::Backspace
@@ -14044,6 +14147,8 @@ impl ApplicationHandler for Game<'_> {
self.tick_fov();
}
self.tick_win_screen()?;
// Gui.tick runs ChatListener.tick paused or not.
self.tick_chat_queue();
self.tick_music();
self.tick_sound_engine();
self.clock.record(started.elapsed());
@@ -14108,6 +14213,7 @@ impl ApplicationHandler for Game<'_> {
server.set_distances(
self.settings.render_distance,
self.settings.simulation_distance,
self.settings.entity_distance,
);
}
self.clock.end_frame(self.pause);
@@ -14472,6 +14578,7 @@ impl<'a> Game<'a> {
pickups: Default::default(),
sign_edit: None,
book: None,
book_last_click: None,
lectern_pulses: Vec::new(),
loading_progress: 0.0,
spawn_origin: None,
@@ -14665,6 +14772,7 @@ impl<'a> Game<'a> {
pending_save: None,
world_icon: None,
forced_hit: None,
tick_hit: None,
guest_use: None,
lan_events: Vec::new(),
lan_songs: Vec::new(),
@@ -14679,6 +14787,7 @@ impl<'a> Game<'a> {
lan_global_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
lan_break_pos: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
guest_entity_events: Vec::new(),
@@ -15096,6 +15205,7 @@ fn load_resources(
settings: &Settings,
started: Instant,
menu: bool,
headless: bool,
progress: &Progress,
publish: &mut dyn FnMut(UiResources, &std::path::Path),
) -> Result<Loaded> {
@@ -15107,7 +15217,16 @@ fn load_resources(
let mut loaded_gui = None;
let mut ui_ready = false;
let mut pack_error = None;
let ui = match UiResources::load(&root, vanilla_root) {
// A dedicated server builds only the font (UiResources::server), as the
// integrated server's game does: the full atlas and panorama are 144 MB.
let load_ui = |root: &std::path::Path| {
if headless {
UiResources::server(root)
} else {
UiResources::load(root, vanilla_root)
}
};
let ui = match load_ui(&root) {
Ok(ui) => Some(ui),
// Minecraft.rollbackResourcePacks: the packs' textures don't fit, start
// with the game's own resources.
@@ -15133,8 +15252,16 @@ fn load_resources(
ui_ready = true;
}
progress.set(0);
// Nor the block sprites' pixels, which only the renderer reads (32 MB).
let reload = |assets: &Assets, root: &std::path::Path| {
if headless {
assets.reload_for_server(root)
} else {
assets.reload(root)
}
};
let (assets, render) = match Assets::load(&root).and_then(|assets| {
let render = assets.reload(&root)?;
let render = reload(&assets, &root)?;
Ok((assets, render))
}) {
Ok(bundle) => bundle,
@@ -15145,7 +15272,7 @@ fn load_resources(
pack_error = Some(e.to_string());
root = vanilla_root.to_path_buf();
let assets = Assets::load(vanilla_root)?;
let render = assets.reload(vanilla_root)?;
let render = reload(&assets, vanilla_root)?;
(assets, render)
}
Err(e) => {
@@ -15159,7 +15286,7 @@ fn load_resources(
let render = Arc::new(render);
// The menu bundle must come from the same root as the world bundle.
if !ui_ready || root != requested_root {
let ui = UiResources::load(&root, vanilla_root)?;
let ui = load_ui(&root)?;
if menu {
horizon_client::telemetry::mark_ui_ready();
log::info!(
@@ -16186,13 +16313,10 @@ pub fn run() -> Result<()> {
horizon_threads::favour_current();
}
// Someone is playing: the anonymous ping behind the launcher's "N playing now", and the
// official server in their list. Not for a server, a benchmark or a run that quits by itself.
if options.execution_role.is_client()
&& options.benchmark.is_none()
&& !options.offscreen
&& options.frames.is_none()
&& options.world_frames.is_none()
{
// official server in their list (servers.dat in the game folder, the working directory). Not
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
horizon_client::heartbeat::start();
if let Err(e) = horizon_client::multiplayer::add_official_server(&std::env::current_dir()?)
{
@@ -16241,6 +16365,7 @@ pub fn run() -> Result<()> {
&early_settings,
started,
false,
options.dedicated.is_some(),
&Progress::new(),
&mut |_, _| {},
)?;
@@ -1108,6 +1108,31 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `ServerPlayer.onItemPickup`: the player in place (the host, or the
/// guest `as_guest` runs) picked up an item entity whose owner
/// (`ItemEntity.getOwner`, the thrower) is `thrower`: the whole stack, or
/// what is left after a partial take. An owner that is that player also
/// fires `LivingEntity.onItemPickup` (the by-entity trigger); one that is
/// gone, or another player, fires nothing here.
pub(super) fn trigger_thrown_picked_up_by_player(
&mut self,
stack: &ItemStack,
thrower: [i32; 4],
) {
let target = if thrower == self.acting_uuid() {
let me = self.player_entity();
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
me
} else if let Some(id) = self.mob_by_uuid(thrower) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
self.mob_entity(m)
} else {
return;
};
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_player", stack, &target);
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
+1 -1
View File
@@ -598,7 +598,7 @@ impl Game<'_> {
Mode::Delete { world } => {
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
}
Mode::BackupBeforeUpgrade { world } => {
Mode::BackupBeforeUpgrade { world, .. } => {
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
}
Mode::Error { message, .. } => {
+5
View File
@@ -181,6 +181,11 @@ impl Game<'_> {
m.extra.ints[3] = 60;
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
let allay = self.mob_uuid(self.mobs[i].id);
if let Some(thrown) = self.dropped.last_mut() {
thrown.thrower = Some(allay);
}
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
const PITCHES: [f32; 16] = [
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
+10
View File
@@ -67,6 +67,16 @@ impl Game<'_> {
for head in left {
self.player_beds.retain(|b| *b != (dim, head));
self.set_bed_occupied(head, false);
// AbstractBedBlock.onStopSleeping: the Overworld's straw_bed_rule
// has destroy_on_leave, so a straw bed breaks when its sleeper gets up.
let bed = self.world.block(head[0], head[1], head[2]);
let straw = &*self.res.facts().get(bed).name == "minecraft:straw_bed";
if dim == Dim::Overworld
&& straw
&& let Err(e) = self.destroy_straw_bed(head)
{
log::warn!("could not break the straw bed at {head:?}: {e}");
}
}
}
@@ -33,6 +33,10 @@ impl Game<'_> {
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
let time = self.world.game_time;
// PotentSulfurBlock.triggerEvent.
if self.potent_sulfur_eruption_event((pos[0], pos[1], pos[2])) {
return;
}
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
self.be_anims
@@ -1,6 +1,6 @@
//! `Level.tickBlockEntities` for the block entities ticked together here
//! (hoppers, crafters, jukeboxes, beacons, creaking hearts, spawners, trial
//! spawners, vaults, beehives and furnaces): only those whose state has a
//! spawners, vaults, beehives, furnaces and potent sulfur): only those whose state has a
//! ticker, in vanilla's list order (`horizon_client::block_entities`), in
//! chunks that tick blocks. Sculk, brewing stands and pistons tick in their
//! own passes.
@@ -64,6 +64,14 @@ impl Game<'_> {
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
Some(BlockEntity::PotentSulfur(_)) => {
self.tick_potent_sulfur(pos)?;
// A window that simulates the world itself is its own
// client; a server's clients tick it on their own.
if self.options.execution_role.is_client() {
self.client_tick_potent_sulfur(pos);
}
}
_ => {}
}
}
@@ -105,6 +113,14 @@ impl Game<'_> {
// Lit, cookTick; unlit, cooldownTick.
| "minecraft:campfire"
| "minecraft:soul_campfire" => Ticking::Yes,
// Every state but dry has a ticker.
"minecraft:potent_sulfur" => {
let dry = registry
.state(state)
.and_then(|s| s.properties.get("potent_sulfur_state"))
.is_some_and(|v| v == "dry");
if dry { Ticking::Idle } else { Ticking::Yes }
}
"minecraft:jukebox" => {
let record = registry
.state(state)
+3 -2
View File
@@ -601,9 +601,10 @@ impl Game<'_> {
self.dismount(i);
return Ok(());
}
let mut b = self.boats[i].clone();
// The boat steers with the input `LocalPlayer.rideTick` latched on the
// previous tick; this tick's keys are latched after it ticked.
let mut b = self.tick_boat(self.boats[i].clone(), true);
b.input = [keys.left, keys.right, keys.forward, keys.backward];
let b = self.tick_boat(b, true);
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
+16 -3
View File
@@ -622,8 +622,15 @@ impl Game<'_> {
let pos = self.gui.atlas.font.fit(title, rel);
title_edit.set_cursor(pos, shift);
}
/// A click on the open page.
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
/// A click on the open page (`MultiLineEditBox.onClick`): a double click
/// selects the word before the cursor and leaves the cursor where it is.
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32, double: bool) {
if double {
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.select_word_at_cursor();
}
return;
}
let shift = self.has_shift_down();
let text = match self.book.as_ref() {
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
@@ -989,6 +996,12 @@ impl Game<'_> {
let b = HudBatch::new(size);
let (w, left) = (b.width, ((b.width - 192.0) / 2.0).floor());
let (mx, my) = self.cursor;
// MouseHandler: a click within 250 ms of the last one on this screen is a double click.
let now = std::time::Instant::now();
let double = self
.book_last_click
.is_some_and(|at| now.duration_since(at).as_millis() < 250);
self.book_last_click = Some(now);
let inside =
|x: f32, y: f32, bw: f32, bh: f32| mx >= x && my >= y && mx < x + bw && my < y + bh;
let click = |game: &mut Self| {
@@ -1029,7 +1042,7 @@ impl Game<'_> {
click(self);
self.save_book(false);
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
self.book_page_click(left + 36.0, 32.0, mx, my);
self.book_page_click(left + 36.0, 32.0, mx, my, double);
}
}
Some(BookScreen::View {
+53 -1
View File
@@ -77,11 +77,20 @@ impl TimeMarker {
}
}
}
/// `ChatComponent.Draft`: the text the chat box kept, and whether it was a command.
pub struct ChatDraft {
pub text: String,
pub command: bool,
}
/// UI and command state that lives beside the game.
#[derive(Default)]
pub struct UiState {
pub chat: Chat,
pub chat_screen: Option<ChatScreen>,
/// `ChatComponent.latestDraft`.
pub chat_draft: Option<ChatDraft>,
/// `ChatScreen.isDraft`: the box opened with the draft and is unchanged since.
pub chat_draft_shown: bool,
/// 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.
@@ -466,10 +475,25 @@ impl Game<'_> {
self.ui.registries.players = names;
}
}
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
/// last time, when this method can restore it (a command draft only for
/// /), else with its prefix.
pub(super) fn open_chat_method(&mut self, prefix: &str) {
let command = prefix == "/";
let draft = self
.ui
.chat_draft
.as_ref()
.filter(|d| !command || d.command)
.map(|d| d.text.clone());
self.open_chat(draft.as_deref().unwrap_or(prefix));
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
}
pub(super) fn open_chat(&mut self, initial: &str) {
if self.player.is_none() {
return;
}
self.ui.chat_draft_shown = false;
self.refresh_dynamic_registries();
let size = self
.renderer
@@ -506,6 +530,13 @@ impl Game<'_> {
self.wake_up();
return;
}
let text = self
.ui
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.keep_chat_draft(&text);
self.ui.chat_draft_shown = false;
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
if self.screen == Screen::Chat {
@@ -513,6 +544,21 @@ impl Game<'_> {
self.capture(true);
}
}
/// `ChatScreen.removed`: the text left in the box becomes the draft when
/// Save Chat Drafts is on, else (and when blank) the draft is discarded. A
/// draft still shown unchanged stays as it is.
fn keep_chat_draft(&mut self, text: &str) {
let unchanged =
self.ui.chat_draft_shown && self.ui.chat_draft.as_ref().is_some_and(|d| d.text == text);
if !self.settings.file.bool("saveChatDrafts") || text.trim().is_empty() {
self.ui.chat_draft = None;
} else if !unchanged {
self.ui.chat_draft = Some(ChatDraft {
text: text.to_owned(),
command: text.starts_with('/'),
});
}
}
/// `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.
@@ -648,7 +694,11 @@ impl Game<'_> {
registries: &self.ui.registries,
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => self.close_chat(),
ChatAction::Close => {
// Escape: the box still holds its text, which close_chat keeps as the draft.
self.ui.chat_screen = Some(screen);
self.close_chat();
}
ChatAction::Submit(message) => {
// InBedChatScreen sends without closing (`closeOnSubmit` is
// false): the box empties and the screen stays.
@@ -657,6 +707,8 @@ impl Game<'_> {
self.ui.chat.reset_scroll();
self.ui.chat_screen = Some(screen);
} else {
// The screen is taken here, so close_chat sees no text: a sent
// message discards the draft (DONE).
self.close_chat();
}
if !message.is_empty() {
@@ -1053,6 +1053,11 @@ impl Game<'_> {
&horizon_client::brewing::potion_name_key(item, s.potion()),
&[],
)
} else if item == "minecraft:shield"
&& let Some(color) = s.data().and_then(|d| d.base_color.as_deref())
{
// `ShieldItem.getName`: the banner's base colour names the shield.
Text::translate(&format!("item.minecraft.shield.{color}"), &[])
} else {
// Item.getDescriptionId: a block item's is the block's.
let (namespace, path) = item.split_once(':').unwrap_or(("minecraft", item));
@@ -1048,6 +1048,23 @@ impl Game<'_> {
let Some(holder) = self.dialog_holder(dialog) else {
return;
};
self.send_dialog(targets, &holder);
}
/// `ServerPlayer.openDialog` for the dialog of a `show_dialog` click
/// event (a sign's): a registry id or the dialog itself, opened for the
/// player the game acts for.
pub(in crate::game) fn show_click_dialog(&mut self, dialog: &horizon_world::nbt::Tag) {
let holder = match dialog {
horizon_world::nbt::Tag::String(id) => self.dialog_holder(&id.to_string_lossy()),
direct => Some(DialogHolder::Direct(direct.clone())),
};
if let Some(holder) = holder {
self.send_dialog(&[Target::Player], &holder);
}
}
/// `ServerPlayer.openDialog` for each target: the packet to a guest, the
/// screen to the window's own player.
fn send_dialog(&mut self, targets: &[Target], holder: &DialogHolder) {
let packet = C::ShowDialog(play::ShowDialog {
dialog: holder.clone(),
});
@@ -1056,7 +1073,7 @@ impl Game<'_> {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => self.show_play_dialog(&holder),
Target::Player => self.show_play_dialog(holder),
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
@@ -170,23 +170,25 @@ impl Game<'_> {
let hurt = match target {
// The causing mob is the one the hit is credited to and thorns
// answer; the source position gives the knockback its direction.
Target::Player => {
Target::Player | Target::Guest(_) => {
// `DamageSource(type, cause, by)`: the death message names the
// causing entity, else the direct one. A mob is noted by
// `hurt_player_from`, a player here.
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
let name = self.target_name(t);
self.note_direct_entity(&ty, name);
}
if self.spectator() {
false
} else {
let attacker = match cause.or(by) {
Some(Target::Mob(i)) => Some(Some(i)),
_ => Some(None),
};
self.hurt_player_from(amount, from, &ty, attacker)
}
let named = match cause.or(by) {
Some(t @ (Target::Player | Target::Guest(_))) => Some(self.target_name(t)),
_ => None,
};
let attacker = match cause.or(by) {
Some(Target::Mob(i)) => Some(Some(i)),
_ => Some(None),
};
self.for_player(target, |g| {
if let Some(name) = named {
g.note_direct_entity(&ty, name);
}
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
})
.unwrap_or(false)
}
Target::Mob(id) => {
let attacker = cause.or(by).and_then(|t| match t {
@@ -66,6 +66,17 @@ impl Game<'_> {
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// `ComposterBlock.useItemOn` on a client's level: whether `held` is
/// compostable and the composter at `pos` below level 8 (SUCCESS).
pub(super) fn composter_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
self.composter_level(pos).is_some_and(|level| level < 8)
&& held.is_some_and(|stack| {
self.res
.items()
.get(&stack.item)
.is_some_and(|i| i.compostable.is_some())
})
}
/// `HopperBlockEntity.addItem` into the composter's `InputContainer`
/// (levels 0 to 6; one item, compostable, from above): the remainder.
pub(super) fn composter_container_insert(
@@ -1,7 +1,8 @@
//! 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};
use horizon_client::{hotbar, items::ItemStack, keybinds, text::Text};
use horizon_world::nbt::Tag;
impl Game<'_> {
/// Whether the key bound to `mapping` is held.
@@ -10,6 +11,58 @@ impl Game<'_> {
.is_some_and(|k| self.physical_keys.contains(&k))
}
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
/// row's nine stacks, or for an empty row a locked paper in that row's own
/// slot, named after the keys that save it (`inventory.hotbarInfo`).
pub(super) fn refresh_saved_hotbars(&mut self) {
if !self.creative_screen.tab().hotbar {
return;
}
let path = Self::options_path().with_file_name("hotbar.nbt");
let known = |id: &str| self.res.items().get(id).is_some();
let rows = hotbar::load(&path, &known);
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping)).plain();
let empty = || ItemStack::new("minecraft:air", 0);
let mut items = Vec::with_capacity(hotbar::GROUPS * hotbar::SIZE);
for (i, row) in rows.iter().enumerate().take(hotbar::GROUPS) {
if row.iter().any(Option::is_some) {
items.extend(row.iter().map(|s| s.clone().unwrap_or_else(empty)));
continue;
}
for j in 0..hotbar::SIZE {
if j != i {
items.push(empty());
continue;
}
let with = [
key("key.saveToolbarActivator"),
key(&format!("key.hotbar.{}", i + 1)),
];
let mut paper = ItemStack::new("minecraft:paper", 1);
let data = paper.data_mut();
data.set_other(
"minecraft:creative_slot_lock",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
data.set_other(
"minecraft:item_name",
Some(Tag::compound([
("translate", Tag::String("inventory.hotbarInfo".into())),
(
"with",
Tag::List(
with.iter()
.map(|s| Tag::String(s.as_str().into()))
.collect(),
),
),
])),
);
items.push(paper);
}
}
self.creative_screen.set_saved_hotbars(items);
}
/// `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
+147 -10
View File
@@ -127,6 +127,27 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
.full_collision
}
}
/// `AbstractWindCharge` is pickable, so a thrown pearl's
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1;
fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> {
projectiles
.iter()
.enumerate()
.filter(|(j, q)| {
*j != skip
&& !q.stuck
&& matches!(
q.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
)
})
.map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb()))
.collect()
}
/// `FlameFeatureRenderer`: fire quads stacked up a burning entity's box,
/// turned to the camera around Y, at full block light. `at` is the entity's
/// feet relative to the camera.
@@ -257,6 +278,7 @@ impl Game<'_> {
snowy: has("minecraft:spawns_snow_foxes")
|| b.name.contains("snow")
|| b.name.contains("frozen"),
snow_fox: has("minecraft:spawns_snow_foxes"),
swamp: b.name.contains("swamp"),
ocean: has("minecraft:is_ocean"),
mushroom: b.name == "minecraft:mushroom_fields",
@@ -1919,6 +1941,34 @@ impl Game<'_> {
self.spawn_orbs(at, amount);
}
}
/// `ServerPlayer.tellNeutralMobsThatIDied`: the mobs within 32 blocks
/// across and 10 up and down get `NeutralMob.playerDied`, when the
/// `forgive_dead_players` rule is on. A mob's `Who::Player` is the guest
/// it is bound to in `bindings` (game/lan_mobs.rs), else the host's
/// player, so only the mobs whose binding is `conn` (`None` for the
/// host's player) hear it.
pub(super) fn tell_neutral_mobs_player_died(
&mut self,
feet: [f64; 3],
conn: Option<u32>,
bindings: &std::collections::HashMap<u32, u32>,
) {
// A client of someone else's world has the server's mobs only to show.
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
return;
}
let block = feet.map(f64::floor);
let area = Aabb::new(
Vec3::new(block[0], block[1], block[2]),
Vec3::new(block[0] + 1.0, block[1] + 1.0, block[2] + 1.0),
)
.inflate(Vec3::new(32.0, 10.0, 32.0));
for m in self.mobs.iter_mut() {
if bindings.get(&m.id).copied() == conn && m.bounds().intersects(area) {
horizon_client::mobs::ai::forgive_dead_player(m);
}
}
}
/// Animal.spawnChildFromBreeding
fn breed(
&mut self,
@@ -1991,6 +2041,24 @@ impl Game<'_> {
}
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
// else either parent's variant. The draws come from the mother's random.
let axolotl = if p.species() == Species::Axolotl {
let other = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
self.mobs.iter_mut().find(|m| m.id == parent).map(|m| {
if m.random.next_int_bound(1200).unwrap_or(1) == 0 {
// Axolotl.Variant.getRareSpawnVariant: the one blue, still a draw.
let _ = m.random.next_int_bound(1);
4
} else if m.random.next_boolean() {
p.extra.variant
} else {
other
}
})
} else {
None
};
// A horse and a donkey have a mule.
let kind = match (p.species(), q.as_ref().map(|q| q.species())) {
(Species::Horse, Some(Species::Donkey)) | (Species::Donkey, Some(Species::Horse)) => {
@@ -2005,6 +2073,24 @@ impl Game<'_> {
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
child.extra.sound_variant = None;
// Fox.getBreedOffspring runs no finalizeSpawn: nothing in the
// baby fox's mouth.
if p.species() == Species::Fox {
child.extra.main_hand = None;
}
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
// screams if the parent a coin toss picks does, or on a 2% roll.
if p.species() == Species::Goat {
child.extra.lost_horns = [false; 2];
horizon_client::mobs::finalize::goat_init_memories(child, &mut self.world.random);
let pick_parent = self.world.random.next_boolean();
let parent_screams = if pick_parent {
p.extra.screaming
} else {
q.as_ref().unwrap_or(&p).extra.screaming
};
child.extra.screaming = parent_screams || self.world.random.next_double() < 0.02;
}
// A llama's baby was drawn in the parent's tick, with the
// experience.
if let Some(o) = llama {
@@ -2092,6 +2178,14 @@ impl Game<'_> {
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
// Axolotl.getBreedOffspring: the drawn variant, and the baby is persistent.
if let Some(variant) = axolotl {
child.extra.variant = variant;
child.persistent = true;
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
if p.species() == Species::Nautilus && p.extra.tame {
child.extra.tame = true;
@@ -2498,13 +2592,21 @@ impl Game<'_> {
.map(|g| g.conn)
.collect()
});
projectiles.retain_mut(|p| {
let mut keep_flags = vec![true; projectiles.len()];
for i in 0..projectiles.len() {
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
wind_charge_hitboxes(&projectiles, i)
} else {
Vec::new()
};
let p = &mut projectiles[i];
if vanish
&& matches!(p.kind, ProjectileKind::EnderPearl)
&& p.owner == Some(Who::Player)
&& p.guest.map_or(host_dead, |c| dead_guests.contains(&c))
{
return false;
keep_flags[i] = false;
continue;
}
let mob_shot = !matches!(p.owner, Some(Who::Player));
// A player's arrows, snowballs and eggs meet every player; a
@@ -2519,11 +2621,25 @@ impl Game<'_> {
if shooter.is_some() {
p.owner = shooter;
}
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp) {
(true, false) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
(true, true) => horizon_client::arrows::tick(p, &scene, &arrow_targets_guests),
(false, false) => p.tick(&pw),
(false, true) => p.tick(&pw_guests),
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp, winds.is_empty()) {
(true, false, _) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
(true, true, _) => {
horizon_client::arrows::tick(p, &scene, &arrow_targets_guests)
}
(false, false, true) => p.tick(&pw),
(false, true, true) => p.tick(&pw_guests),
(false, pvp_shot, false) => {
let mut targets = if pvp_shot {
pw_guests.targets.clone()
} else {
pw.targets.clone()
};
targets.extend(winds);
p.tick(&ProjWorld {
w: if pvp_shot { pw_guests.w } else { pw.w },
targets,
})
}
};
if shooter.is_some() {
p.owner = Some(Who::Player);
@@ -2569,7 +2685,7 @@ impl Game<'_> {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
deflections.push([at.x, at.y, at.z]);
return true;
continue;
}
// AbstractWindCharge.tick: out of the world's top it bursts.
if wind && keep && p.position.y > 350.0 {
@@ -2581,12 +2697,19 @@ impl Game<'_> {
face: [0; 3],
},
));
return false;
keep_flags[i] = false;
continue;
}
if hit != Hit::None {
hits.push((p.clone(), hit));
}
keep
keep_flags[i] = keep;
}
let mut idx = 0;
projectiles.retain(|_| {
let k = keep_flags[idx];
idx += 1;
k
});
}
self.smoke_trails(trails);
@@ -4144,6 +4267,12 @@ impl Game<'_> {
Some(sound) => {
consume = true;
eat = sound;
// Axolotl.usePlayerItem: a tropical fish bucket is
// emptied into a water bucket in its place.
if species == Species::Axolotl && &*item == "minecraft:tropical_fish_bucket"
{
give = Some("minecraft:water_bucket");
}
// Animal.setInLove: entity event 18 (a baby that
// only grew is not in love).
if self.mobs[index].extra.in_love > 0 {
@@ -4586,6 +4715,13 @@ impl Game<'_> {
if mob.fire_ticks > 0 || matches!(mob.species(), Species::Blaze | Species::MagmaCube) {
packed = (15 * 16) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
@@ -4941,6 +5077,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Drown => "minecraft:drown",
K::DryOut => "minecraft:dry_out",
K::Magic => "minecraft:magic",
K::IndirectMagic => "minecraft:indirect_magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
K::SweetBerryBush => "minecraft:sweet_berry_bush",
@@ -0,0 +1,187 @@
//! `DebugStickItem`: selecting a block-state property with the left button and
//! cycling its value with the right button, as vanilla does.
//!
//! The selected property is the `minecraft:debug_stick_state` component, a
//! compound of block id to property name. Horizon keeps unmodelled components
//! in `StackData::other`, so the component travels through NBT unchanged.
use super::Game;
use horizon_client::{Result, items::ItemStack, text::Text};
use horizon_core::position::BlockPos;
use horizon_world::level::flags;
use winit::keyboard::KeyCode;
const DEBUG_STICK: &str = "minecraft:debug_stick";
fn is_debug_stick(stack: &ItemStack) -> bool {
&*stack.item == DEBUG_STICK
}
/// `Util.findNextInIterable` / `findPreviousInIterable` on an index.
///
/// A missing current value starts at the first value forward and the last
/// value backward; both wrap around.
fn relative_index(len: usize, current: Option<usize>, backward: bool) -> usize {
match (current, backward) {
(Some(0), true) => len - 1,
(Some(i), true) => i - 1,
(Some(i), false) => (i + 1) % len,
(None, true) => len - 1,
(None, false) => 0,
}
}
fn text(key: &str, args: &[&str]) -> Text {
let args: Vec<Text> = args.iter().map(|arg| Text::literal(*arg)).collect();
Text::translate(key, &args)
}
impl Game<'_> {
/// `DebugStickItem.useOn`: a right click cycles the value of the property
/// in `debug_stick_state` (the first property when none is selected yet).
/// `Some` is the vanilla result (`SUCCESS`/`FAIL`), `None` another item.
pub(super) fn debug_stick_use_on(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
if !self.held().is_some_and(is_debug_stick) {
return Ok(None);
}
Ok(Some(self.debug_stick_interact(pos, true)?))
}
/// `DebugStickItem.canDestroyBlock`: a left click selects the next
/// property and never destroys the block.
pub(super) fn debug_stick_can_destroy(&mut self, pos: [i32; 3]) -> Result<bool> {
if !self.held().is_some_and(is_debug_stick) {
return Ok(true);
}
let _ = self.debug_stick_interact(pos, false)?;
Ok(false)
}
/// `DebugStickItem.handleInteraction`: whether the interaction succeeded.
fn debug_stick_interact(&mut self, pos: [i32; 3], cycle: bool) -> Result<bool> {
// `Player.canUseGameMasterBlocks`: creative with game-master commands.
if !self.can_use_game_master_blocks() {
return Ok(false);
}
let state_id = self.world.block(pos[0], pos[1], pos[2]);
let Some(state) = self.pack.assets.registry.state(state_id).cloned() else {
return Ok(false);
};
if state.properties.is_empty() {
self.debug_stick_message(text(
"item.minecraft.debug_stick.empty",
&[state.name.as_str()],
));
return Ok(false);
}
let selected = self
.held()
.and_then(ItemStack::data)
.and_then(|data| data.debug_stick_property(&state.name));
// `Player.isSecondaryUseActive` / `isShiftKeyDown`: the acting
// player's sneak input. `keys` holds the host's, toggled sneak
// included; `enter_guest` fills it from the guest's sneak state.
let backward = self.keys.contains(&KeyCode::ShiftLeft);
if cycle {
// `cycleState`: a property selected on another block falls back to
// the first one. The property list is sorted by name, as vanilla's
// `ImmutableSortedMap`.
let (property, value) = {
let registry = &self.pack.assets.registry;
let property = selected
.as_deref()
.filter(|property| state.properties.contains_key(*property))
.map(str::to_owned)
.unwrap_or_else(|| state.properties.keys().next().unwrap().clone());
let values = registry.property_values(&state.name, &property);
if values.is_empty() {
return Ok(false);
}
let current = state.properties.get(&property).map(String::as_str);
let current = current.and_then(|value| values.iter().position(|v| *v == value));
let value = values[relative_index(values.len(), current, backward)].to_owned();
(property, value)
};
let mut next = state;
next.properties.insert(property.clone(), value.clone());
let Ok(next_id) = self.pack.assets.registry.resolve(&next) else {
return Ok(false);
};
let at = BlockPos::new(pos[0], pos[1], pos[2]);
// `Level.setBlock(pos, newState, 18)`: clients and the known shape,
// without neighbour updates.
self.with_level(|b, level, ticks| {
b.set_block(
level,
ticks,
at,
next_id,
flags::UPDATE_CLIENTS | flags::UPDATE_KNOWN_SHAPE,
);
});
self.process_level_events()?;
self.debug_stick_message(text(
"item.minecraft.debug_stick.update",
&[property.as_str(), value.as_str()],
));
} else {
// `getRelative(properties, property, backward)`; the property list
// is sorted, so its key order is vanilla's.
let keys: Vec<&String> = state.properties.keys().collect();
let current = selected
.as_deref()
.and_then(|selected| keys.iter().position(|key| key.as_str() == selected));
let property = keys[relative_index(keys.len(), current, backward)].clone();
let value = state.properties.get(&property).cloned().unwrap_or_default();
self.set_debug_stick_property(&state.name, &property);
self.debug_stick_message(text(
"item.minecraft.debug_stick.select",
&[property.as_str(), value.as_str()],
));
}
Ok(true)
}
/// `DebugStickState.withProperty(block, property)` on the acting hand.
fn set_debug_stick_property(&mut self, block: &str, property: &str) {
let Some(stack) = self.held_mut().as_mut() else {
return;
};
stack.data_mut().set_debug_stick_property(block, property);
stack.tidy();
}
/// `ServerPlayer.sendOverlayMessage`: the steady player sees it on this
/// game's HUD; a server client receives it as a packet.
fn debug_stick_message(&mut self, message: Text) {
if self.lan_acting.is_some() {
self.guest_packets
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
horizon_protocol::packets::play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
},
));
} else if self.options.dedicated.is_none() {
self.titles.set_overlay(message, false);
}
}
}
#[cfg(test)]
mod tests {
use super::relative_index;
#[test]
fn relative_index_wraps_like_util() {
assert_eq!(relative_index(4, None, false), 0);
assert_eq!(relative_index(4, None, true), 3);
assert_eq!(relative_index(4, Some(0), false), 1);
assert_eq!(relative_index(4, Some(0), true), 3);
assert_eq!(relative_index(4, Some(2), false), 3);
assert_eq!(relative_index(4, Some(2), true), 1);
assert_eq!(relative_index(4, Some(3), false), 0);
assert_eq!(relative_index(4, Some(3), true), 2);
}
}
@@ -763,6 +763,8 @@ impl Game<'_> {
self.sync_ops(&lists);
if let Some(host) = self.lan.as_mut() {
host.hub.spawn_protection = props.spawn_protection;
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
host.hub.command_spam_threshold = props.command_spam_threshold_seconds.wrapping_mul(20);
}
log(
"INFO",
+31 -2
View File
@@ -11,7 +11,7 @@ use horizon_client::{
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
hud::{HudBatch, rgba},
text::Text,
text::{ClickEvent, Text},
ui,
};
use horizon_world::nbt::Tag;
@@ -544,6 +544,7 @@ impl Game<'_> {
};
let [_, ay, _, ah] = layout.area;
let mut action = None;
let mut text_click: Option<ClickEvent> = None;
open.focus = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
@@ -592,11 +593,39 @@ impl Game<'_> {
} if ui::hovered(cursor, x, y, w, h) => {
action = Some(Some(index));
}
// AbstractStringWidget.setComponentClickHandler: the click event
// of the styled text under a centred message line.
Part::Line(line, x, y)
if text_click.is_none()
&& cursor.0 >= x
&& (0.0..9.0).contains(&(cursor.1 - y)) =>
{
let content = line.resolve_legacy();
let mut left = cursor.0 - x;
for (c, style) in content.chars() {
left -= self.gui.atlas.font.advance(c, style);
if left < 0.0 {
text_click = style.click.clone();
break;
}
}
}
_ => {}
}
}
let Some(action) = action else {
return Ok(());
// A text click runs its event (DialogScreen.runAction) with no button sound.
let click = match text_click {
Some(ClickEvent::OpenUrl(url)) => Click::OpenUrl(url),
Some(ClickEvent::ChangePage(page)) => Click::ChangePage(page),
Some(ClickEvent::SuggestCommand(command)) => Click::SuggestCommand(command),
Some(ClickEvent::RunCommand(command)) => Click::RunCommand(command),
Some(ClickEvent::CopyToClipboard(value)) => Click::CopyToClipboard(value),
Some(ClickEvent::ShowDialog(dialog)) => Click::ShowDialog(dialog),
Some(ClickEvent::OpenFile(_)) | None => return Ok(()),
};
let after = open.dialog.after_action;
return self.run_dialog_action(Some(click), after);
};
self.audio.play_button_click();
let Some(index) = action else {
@@ -362,7 +362,7 @@ impl Game<'_> {
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
/// a burst at the player's feet that only knocks back and triggers
/// blocks (`ExplodeEffect` with `block_interaction: trigger`).
fn post_attack_bursts(&mut self, held: &ItemStack, victim: &str) {
pub(super) fn post_attack_bursts(&mut self, held: &ItemStack, victim: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
+9 -2
View File
@@ -329,7 +329,13 @@ impl Game<'_> {
}
}
fn explosion_hurt_entities(&mut self, center: [f64; 3], radius: f32, damage_type: &str) {
fn explosion_hurt_entities(
&mut self,
center: [f64; 3],
radius: f32,
damage_type: &str,
interaction: BlockInteraction,
) {
if radius < 1.0e-5 {
return;
}
@@ -440,6 +446,7 @@ impl Game<'_> {
);
}
self.mobs = mobs;
self.explosion_breaks_frames(center, diameter, interaction);
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| {
@@ -579,7 +586,7 @@ impl Game<'_> {
let ctx = horizon_client::sculk::Context::default();
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.exploded_positions(center, radius, resists);
self.explosion_hurt_entities(center, radius, damage_type);
self.explosion_hurt_entities(center, radius, damage_type, interaction);
if interaction != BlockInteraction::Keep {
// Util.shuffle(list, level.random)
for i in (2..=targets.len()).rev() {
+246 -24
View File
@@ -46,6 +46,8 @@ pub(super) struct Hook {
open_water: bool,
luck: i32,
lure: i32,
/// The position a server's update put it heading for.
lerp: HookLerp,
}
/// A player a hook can catch (`canHitEntity`): their entity id, which
/// guest they are (none: the host) and their box.
@@ -146,6 +148,7 @@ impl Game<'_> {
open_water: true,
luck,
lure,
lerp: HookLerp::default(),
});
}
self.swing_arm();
@@ -898,6 +901,81 @@ pub(super) struct RemoteHook {
owner: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
velocity: Vec3,
state: State,
on_ground: bool,
lerp: HookLerp,
}
/// The `onGround` of a server's update (`handleMoveEntity`), except that a
/// hook resting on the ground stays so while the update does not lift it: the
/// server's tracker can send the flag a tick behind the landing.
fn resting(was: bool, at: Vec3, target: [f64; 3], sent: bool) -> bool {
sent || (was && target[1] <= at.y + 1.0e-3)
}
/// `LinearInterpolationHandler`: where a server's update has a hook heading
/// and the steps left (`DEFAULT_INTERPOLATION_STEPS` 3).
#[derive(Default)]
pub(super) struct HookLerp {
target: Vec3,
steps: u32,
}
impl HookLerp {
/// `interpolateTo`: a new target restarts the steps, the same one does not.
fn start(&mut self, target: [f64; 3]) {
let target = Vec3::new(target[0], target[1], target[2]);
if self.steps == 0 || self.target != target {
self.target = target;
self.steps = 3;
}
}
/// `doInterpolate`: a step of the way there.
fn step(&mut self, at: Vec3) -> Vec3 {
if self.steps == 0 {
return at;
}
let n = f64::from(self.steps);
self.steps -= 1;
Vec3::new(
at.x + (self.target.x - at.x) / n,
at.y + (self.target.y - at.y) / n,
at.z + (self.target.z - at.z) / n,
)
}
/// `applyPredictedMovement`: what the hook moved by itself carries the
/// target along while it is heading there.
fn moved(&mut self, by: Vec3) {
if self.steps > 0 {
self.target = Vec3::new(
self.target.x + by.x,
self.target.y + by.y,
self.target.z + by.z,
);
}
}
}
impl Hook {
pub(super) fn id(&self) -> u32 {
self.id
}
/// A server's update of this client's own hook (`handleMoveEntity` also
/// sets `onGround`).
pub(super) fn move_to(&mut self, target: [f64; 3], on_ground: bool) {
self.on_ground = resting(self.on_ground, self.position, target, on_ground);
self.lerp.start(target);
}
}
impl RemoteHook {
pub(super) fn id(&self) -> u32 {
self.id
}
/// A server's update of another player's hook.
pub(super) fn move_to(&mut self, target: [f64; 3], on_ground: bool) {
self.on_ground = resting(self.on_ground, self.position, target, on_ground);
self.lerp.start(target);
}
}
impl Hook {
@@ -919,6 +997,7 @@ impl Hook {
open_water: true,
luck: 0,
lure: 0,
lerp: HookLerp::default(),
}
}
/// `FishingHook`'s entity for the players tracking it: the owner's id in
@@ -965,52 +1044,195 @@ impl Game<'_> {
/// `handleAddEntity` for a fishing bobber: this player's own becomes its
/// hook (`Player.fishing`), another player's is drawn on its line to them.
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, owner: i32) {
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, velocity: Vec3, owner: i32) {
let own = self.remote.as_ref().map(|r| r.entity_id);
if own == Some(owner) {
self.hook = Some(Hook::remote(id, at));
let mut hook = Hook::remote(id, at);
hook.velocity = velocity;
self.hook = Some(hook);
} else {
self.remote_hooks.push(RemoteHook {
id,
owner: owner as u32,
position: at,
previous: at,
velocity,
state: State::Flying,
on_ground: false,
lerp: HookLerp::default(),
});
}
}
/// One tick of interpolation for the hooks a server tracks.
pub(super) fn step_remote_hooks(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
if self.remote.is_none() {
return;
}
if let Some(h) = self.hook.as_mut() {
h.previous = h.position;
h.position = step(h.id, h.position);
}
for h in &mut self.remote_hooks {
h.previous = h.position;
h.position = step(h.id, h.position);
}
}
/// `FishingHook.onSyncedDataUpdated`: this player's hook is biting
/// (DATA_BITING), which `status` shows as a nibble.
pub(super) fn remote_hook_data(&mut self, id: u32, values: &[(u8, i32, Value)]) {
let Some(h) = self
// DATA_HOOKED_ENTITY (the hooked id plus one, 0 for none): a hook on a
// client follows what it hooked (`FishingHook.onSyncedDataUpdated`).
let hooked = |value: &Value| match value {
Value::Int(v) if *v > 0 => State::Hooked(*v as u32 - 1),
_ => State::Flying,
};
if let Some(h) = self
.hook
.as_mut()
.filter(|h| h.id == id && self.remote.is_some())
else {
return;
};
for (index, _, value) in values {
if let (9, Value::Bool(biting)) = (index, value) {
h.nibble = i32::from(*biting);
{
for (index, _, value) in values {
match (index, value) {
(9, Value::Bool(biting)) => h.nibble = i32::from(*biting),
(8, value) => h.state = hooked(value),
_ => {}
}
}
} else if let Some(h) = self.remote_hooks.iter_mut().find(|h| h.id == id) {
for (index, _, value) in values {
if *index == 8 {
h.state = hooked(value);
}
}
}
}
/// `Entity.lerpMotion` for a hook a server tracks: the motion its own tick
/// moves it on with between the tracker's updates.
pub(super) fn remote_hook_motion(&mut self, id: u32, v: Vec3) -> bool {
if self.remote.is_none() {
return false;
}
if let Some(h) = self.hook.as_mut().filter(|h| h.id == id) {
h.velocity = v;
} else if let Some(h) = self.remote_hooks.iter_mut().find(|h| h.id == id) {
h.velocity = v;
} else {
return false;
}
true
}
/// `FishingHook.tick` on a client: the interpolation step towards the
/// server's last update, then its own movement between the tracker's
/// updates (every five ticks), floating in water and riding what it
/// hooked, as the server's does.
pub(super) fn tick_remote_hooks(&mut self) {
if self.remote.is_none() {
return;
}
if let Some(mut h) = self.hook.take() {
h.previous = h.position;
h.position = h.lerp.step(h.position);
let before = h.position;
(h.position, h.velocity, h.state, h.on_ground) =
self.client_hook_step((h.position, h.velocity, h.state, h.on_ground));
h.lerp.moved(h.position.subtract(before));
self.hook = Some(h);
}
let mut hooks = std::mem::take(&mut self.remote_hooks);
for h in &mut hooks {
h.previous = h.position;
h.position = h.lerp.step(h.position);
let before = h.position;
(h.position, h.velocity, h.state, h.on_ground) =
self.client_hook_step((h.position, h.velocity, h.state, h.on_ground));
h.lerp.moved(h.position.subtract(before));
}
self.remote_hooks = hooks;
}
/// One tick of `FishingHook.tick`'s movement without the fishing (the
/// server's): position, motion, state and whether it rests on the ground.
fn client_hook_step(
&mut self,
(mut position, mut velocity, state, mut on_ground): (Vec3, Vec3, State, bool),
) -> (Vec3, Vec3, State, bool) {
let b = [
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
];
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
let in_water = liquid > 0.0;
match state {
State::Flying if in_water => {
velocity = Vec3::new(velocity.x * 0.3, velocity.y * 0.2, velocity.z * 0.3);
return (position, velocity, State::Bobbing, on_ground);
}
// A landed hook: the server stopped it dead (zero motion) and sends no
// more than the odd update; it does not fall on from here.
State::Flying if velocity == Vec3::ZERO && on_ground => {
return (position, velocity, state, on_ground);
}
State::Flying => {}
State::Hooked(id) => {
// updateHookedEntity: at 0.8 of the hooked entity's height.
let hooked = if self
.remote
.as_ref()
.is_some_and(|r| r.entity_id as u32 == id)
{
self.player
.as_ref()
.map(|p| (p.position, f64::from(p.dimensions.height)))
} else {
self.mobs
.iter()
.find(|m| m.id == id && m.alive())
.map(|m| (m.position, f64::from(m.height())))
.or_else(|| {
self.dropped
.iter()
.find(|i| i.id == id)
.map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH))
})
};
return match hooked {
Some((at, height)) => (
Vec3::new(at.x, at.y + height * 0.8, at.z),
Vec3::ZERO,
state,
on_ground,
),
None => (position, velocity, State::Flying, on_ground),
};
}
State::Bobbing => {
let mut force = position.y + velocity.y - f64::from(b[1]) - f64::from(liquid);
if force.abs() < 0.01 {
force += force.signum() * 0.1;
}
let r = f64::from(self.world.random.next_float());
velocity = Vec3::new(
velocity.x * 0.9,
velocity.y - force * r * 0.2,
velocity.z * 0.9,
);
}
}
if !in_water && !on_ground {
velocity.y -= 0.03;
}
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
if let Ok(moved) = scene.resolve(bounds(position), velocity, 0.0, on_ground) {
let horizontal =
(moved.x - velocity.x).abs() > 1.0e-7 || (moved.z - velocity.z).abs() > 1.0e-7;
on_ground = velocity.y < 0.0 && moved.y > velocity.y;
position = Vec3::new(
position.x + moved.x,
position.y + moved.y,
position.z + moved.z,
);
if state == State::Flying && (on_ground || horizontal) {
velocity = Vec3::ZERO;
}
}
velocity = Vec3::new(velocity.x * 0.92, velocity.y * 0.92, velocity.z * 0.92);
(position, velocity, state, on_ground)
}
pub(super) fn remove_remote_hook(&mut self, id: u32) {
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
+40
View File
@@ -296,6 +296,46 @@ impl Game<'_> {
self.spawn_item(item, at, [0.0; 3], 10);
}
}
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
/// is killed and dropped with its item (`BlockAttachedEntity.hurtServer`).
/// A fixed frame is not hurt by an explosion. Without `mobGriefing` a blast
/// that keeps blocks (`ServerExplosion.shouldAffectBlocklikeEntities` through
/// `BlockAttachedEntity.ignoreExplosion`) and a mob's blast spare frames.
/// Not ported: `ignoreExplosion` also spares frames when the exploding
/// entity is in water.
pub(super) fn explosion_breaks_frames(
&mut self,
blast: [f64; 3],
diameter: f64,
interaction: super::explosion::BlockInteraction,
) {
if !self.ui.rules.bool("mob_griefing")
&& (interaction == super::explosion::BlockInteraction::Keep
|| self.combat.explosion_source.is_some())
{
return;
}
let mut i = 0;
while i < self.item_frames.len() {
let c = center(&self.item_frames[i]);
let dist =
((c[0] - blast[0]).powi(2) + (c[1] - blast[1]).powi(2) + (c[2] - blast[2]).powi(2))
.sqrt()
/ diameter;
if dist > 1.0 || self.item_frames[i].fixed {
i += 1;
continue;
}
let f = self.item_frames.remove(i);
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
}
}
/// BlockAttachedEntity.tick for frames.
pub(super) fn tick_item_frames(&mut self) {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
+5 -3
View File
@@ -11,7 +11,8 @@ use horizon_world::registry::BlockStateId;
impl Game<'_> {
/// Client `EntityGetter.getEntityCollisions` for the player: the
/// entities that `canBeCollidedWith` it, solid happy ghasts and boats
/// entities that `canBeCollidedWith` it, solid happy ghasts, living
/// shulkers (`Shulker.canBeCollidedWith`) and boats
/// (`AbstractBoat.canBeCollidedWith`), except the player's own vehicle.
pub(super) fn player_entity_collisions(&self) -> Vec<Aabb> {
let Some(p) = &self.player else {
@@ -20,9 +21,10 @@ impl Game<'_> {
self.mobs
.iter()
.filter(|m| {
m.species() == horizon_client::mobs::Species::HappyGhast
(m.species() == horizon_client::mobs::Species::HappyGhast
&& Some(m.id) != self.riding
&& happy_ghast::collides_with_player(m, p.position.y)
&& happy_ghast::collides_with_player(m, p.position.y))
|| (m.species() == horizon_client::mobs::Species::Shulker && m.alive())
})
.map(|m| m.bounds())
.chain(
+13 -7
View File
@@ -20,15 +20,21 @@ impl Game<'_> {
/// (`BlockPattern.find`), snow golems first: the snow column ("^", "#",
/// "#") spawns at its bottom, the iron T ("~^~", "###", "~#~") under its
/// head. The blocks come column by column, top first, as
/// `clearPatternBlocks` visits them.
fn find_golem(&self, head: [i32; 3]) -> Option<(&'static str, Vec<[i32; 3]>, [i32; 3])> {
/// `clearPatternBlocks` visits them. With `base`, the head's cell takes
/// any block, as in the base patterns (" ", "#", "#") and ("~ ~", "###",
/// "~#~") that `canSpawnGolem` checks before the head is there.
fn find_golem(
&self,
head: [i32; 3],
base: bool,
) -> Option<(&'static str, Vec<[i32; 3]>, [i32; 3])> {
let air = |p: [i32; 3]| self.res.facts().is_air(self.world.block(p[0], p[1], p[2]));
let snow = |c: char, p: [i32; 3]| match c {
'^' => self.is_golem_head(p),
'^' => base || self.is_golem_head(p),
_ => self.golem_block(p) == "minecraft:snow_block",
};
let iron = |c: char, p: [i32; 3]| match c {
'^' => self.is_golem_head(p),
'^' => base || self.is_golem_head(p),
'#' => self.golem_block(p) == "minecraft:iron_block",
_ => air(p),
};
@@ -46,7 +52,7 @@ impl Game<'_> {
/// `CarvedPumpkinBlock.canSpawnGolem`: a snow or iron golem base, or a
/// copper block (`#minecraft:copper`) below.
pub(super) fn can_spawn_golem(&self, head: [i32; 3]) -> bool {
self.find_golem(head).is_some() || self.copper_golem_base(head)
self.find_golem(head, true).is_some() || self.copper_golem_base(head)
}
/// The copper golem pattern ("^", "#"): a block of `#minecraft:copper` under the head.
fn copper_golem_base(&self, head: [i32; 3]) -> bool {
@@ -63,7 +69,7 @@ impl Game<'_> {
if !self.is_golem_head(head) {
return Ok(());
}
let Some((kind, blocks, base)) = self.find_golem(head) else {
let Some((kind, blocks, base)) = self.find_golem(head, false) else {
return self.try_spawn_copper_golem(head);
};
// CarvedPumpkinBlock.spawnGolemInWorld
@@ -113,7 +119,7 @@ impl Game<'_> {
}
/// Whether placing a golem head at `head` completes a golem.
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
self.find_golem(head).is_some() || self.copper_golem_base(head)
self.find_golem(head, true).is_some() || self.copper_golem_base(head)
}
/// `Level.setBlock` of the golem head `state` at `head`, then
/// `CarvedPumpkinBlock.onPlace`. When the head completes a golem, onPlace
+17 -12
View File
@@ -19,7 +19,6 @@ use super::{
use horizon_client::{
Result,
audio::Audio,
hud::HudAtlas,
lan::{Lan, MAX_PLAYERS, NetConfig, hub::HubConfig},
settings::Settings,
title::WorldOptions,
@@ -52,6 +51,9 @@ struct Flags {
/// server follows while it runs (IntegratedServer.tickServer).
render_distance: AtomicI32,
simulation_distance: AtomicI32,
/// `options.entityDistanceScaling` (an `f32`'s bits), which
/// `IntegratedServer.getScaledTrackingDistance` reads on every call.
entity_distance: AtomicU32,
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
saving: AtomicBool,
}
@@ -79,12 +81,16 @@ impl Integrated {
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
}
/// The window's render and simulation distances, for the server to follow.
pub fn set_distances(&self, render: i32, simulation: i32) {
/// The window's render and simulation distances and its Entity Distance
/// option, for the server to follow.
pub fn set_distances(&self, render: i32, simulation: i32, entity: f32) {
self.flags.render_distance.store(render, Ordering::Relaxed);
self.flags
.simulation_distance
.store(simulation, Ordering::Relaxed);
self.flags
.entity_distance
.store(entity.to_bits(), Ordering::Relaxed);
}
/// `IntegratedServer.getPort` while `isPublished`.
pub fn published(&self) -> Option<u16> {
@@ -255,6 +261,9 @@ impl Game<'_> {
flags
.simulation_distance
.store(settings.simulation_distance, Ordering::Relaxed);
flags
.entity_distance
.store(settings.entity_distance.to_bits(), Ordering::Relaxed);
let (ready_tx, ready) = channel();
let (tasks, server_tasks) = channel();
let server_flags = flags.clone();
@@ -330,6 +339,7 @@ impl Game<'_> {
return Ok(());
};
handle.world_name = world_name;
self.server_packs_world_pack(&folder);
self.connect_local(pipe, &player)?;
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
// the Select World list shows, and needs the save folder for it.
@@ -663,6 +673,9 @@ impl Game<'_> {
/// IntegratedServer.tickServer: the server follows the window's render
/// and simulation distances when they change.
fn follow_distances(&mut self, flags: &Flags) {
// IntegratedServer.getScaledTrackingDistance reads the option each time.
self.settings.entity_distance =
f32::from_bits(flags.entity_distance.load(Ordering::Relaxed));
let Some(host) = self.lan.as_mut() else {
return;
};
@@ -836,15 +849,7 @@ fn server_game(
options: Options,
settings: Settings,
) -> Result<Game<'_>> {
// It draws nothing: only the font, which books and chat measure with.
let atlas = HudAtlas::fonts(&options.resources)?;
let gui = UiResources {
atlas,
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
}),
};
let gui = UiResources::server(&options.resources)?;
let audio = Audio::silent(&options.resources);
Game::new(res, pack, gui, options, settings, audio, None)
}
+43 -6
View File
@@ -440,12 +440,12 @@ fn mob_meta(
}
defaults.push((16, serializer::BYTE, Value::Byte(16)));
}
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
// MushroomCow.DATA_TYPE (after AgeableMob's two): 0 red, 1 brown.
"minecraft:mooshroom" => {
if e.variant == 1 {
meta.push((17, serializer::INT, Value::Int(1)));
meta.push((18, serializer::INT, Value::Int(1)));
}
defaults.push((17, serializer::INT, Value::Int(0)));
defaults.push((18, serializer::INT, Value::Int(0)));
}
// AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (16: with the dimension it
// makes it shake) and Piglin.DATA_IS_DANCING (after the baby flag and
@@ -719,6 +719,15 @@ fn mob_meta(
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// GlowSquid.DATA_DARK_TICKS_REMAINING (after AgeableMob's two): the
// dimming a hit gave, counted down by the server and the client alike.
"minecraft:glow_squid" => {
let ticks = horizon_client::mobs::aquatic::dark_ticks(m);
if ticks != 0 {
meta.push((18, serializer::INT, Value::Int(ticks)));
}
defaults.push((18, serializer::INT, Value::Int(0)));
}
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
"minecraft:creaking" => {
if e.flag2 {
@@ -1423,7 +1432,8 @@ impl Game<'_> {
crouching: p.pose == horizon_client::pose::Pose::Crouching,
spin_attack: p.auto_spin_attack_ticks > 0,
on_fire: p.fire_ticks > 0,
invisible: self.potions.effects.get("invisibility").is_some(),
// ServerPlayer.updateInvisibilityStatus: a spectator is always invisible.
invisible: self.potions.effects.get("invisibility").is_some() || self.spectator(),
glowing: self.potions.effects.get("glowing").is_some(),
},
})
@@ -1594,6 +1604,13 @@ impl Game<'_> {
self.ui.rules.bool("player_movement_check"),
self.ui.rules.bool("elytra_movement_check"),
);
// IntegratedServer.getScaledTrackingDistance: the Entity Distance
// option scales what an integrated server or a LAN host tracks (a
// dedicated one keeps its entity-broadcast-range-percentage).
if host.hub.config.integrated || !host.hub.config.dedicated {
host.hub.entity_range_percent =
(self.settings.entity_distance * 100.0).round() as i32;
}
host.hub
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
// A server with several levels runs a pass for each
@@ -1621,7 +1638,17 @@ impl Game<'_> {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::PlayerChat { .. } => {}
HostEvent::PlayerChat { sender, .. } => {
if let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.uuid == sender)
.map(|g| g.conn)
{
self.detect_rate_spam(&mut host, conn, false);
}
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
@@ -1840,9 +1867,18 @@ impl Game<'_> {
if self.audio.capture.is_none() {
return;
}
// The acting guest's own break: `Block.playerWillDestroy` excludes the
// player from the destroy events up to the broken block's. What a
// break sets off after that (a torch popping off, a stalk falling) is
// `destroyBlock` with no player, which the breaker hears and sees too.
let mut own_break = self.lan_break_pos.is_some();
for e in events {
match e {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
let own = *id == 2001 && own_break;
if own && self.lan_break_pos == Some([pos.x, pos.y, pos.z]) {
own_break = false;
}
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, anvil result sounds, or a
// finished brushing or an eye inserted in a portal frame (these calls name no
@@ -1851,7 +1887,8 @@ impl Game<'_> {
let except = if matches!(
*id,
2015 | 1503 | 1505 | 1029 | 1030 | 1045 | 1052 | 2019 | 2020 | 3000 | 3008
) {
) || (*id == 2001 && self.lan_break_pos.is_some() && !own)
{
None
} else {
self.lan_acting
+149 -5
View File
@@ -22,7 +22,10 @@ use horizon_client::{
motion::{MotionEvent, MotionWorld, PlayerMotion},
pose::Dimensions,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
};
use horizon_protocol::{
packets::play::{self, Clientbound as C, Serverbound as S},
text::literal,
@@ -44,6 +47,10 @@ pub(super) struct GuestPlay {
/// `ServerGamePacketListenerImpl.dropSpamThrottler` (`TickThrottler(20, 1480)`): 20 for each
/// item a creative player drops from the creative screen, one less each tick.
creative_drops: u32,
/// `ServerGamePacketListenerImpl.chatSpamThrottler`: 20 for each chat message, one less each tick.
pub(super) chat_spam: u32,
/// `commandSpamThrottler`: 20 for each command, one less each tick.
pub(super) command_spam: u32,
/// The guest's `Player.getCooldowns()` (`Game::cooldowns` for its turn).
pub(super) cooldowns: horizon_client::cooldowns::Cooldowns,
attack_ticks: u32,
@@ -132,6 +139,8 @@ pub(super) struct GuestSwap {
selected: usize,
mode: horizon_client::command::args::GameMode,
creative: bool,
/// The host's command permission, swapped for the guest's for its turn.
permission: u8,
xp: Experience,
host_keys: std::collections::HashSet<KeyCode>,
screen: Screen,
@@ -271,6 +280,12 @@ impl Game<'_> {
return None;
}
let i = host.hub.guests.iter().position(|g| g.conn == conn)?;
// `ServerPlayer.permissions()`: the guest's own command level for its
// turn, so game-master checks see it (not the host's).
let permission = std::mem::replace(
&mut self.ui.registries.permission,
host.hub.guests[i].permission,
);
let fresh = host.hub.guests[i].motion.is_none();
if fresh {
let p = Vec3::new(
@@ -308,6 +323,7 @@ impl Game<'_> {
let g = &mut host.hub.guests[i];
// The guest's body.
let Some(mut motion) = g.motion.take() else {
self.ui.registries.permission = permission;
host.play.insert(conn, play);
return None;
};
@@ -463,6 +479,7 @@ impl Game<'_> {
selected,
mode,
creative,
permission,
xp,
host_keys,
screen,
@@ -525,6 +542,7 @@ impl Game<'_> {
selected,
mode,
creative,
permission,
xp,
host_keys,
screen,
@@ -569,6 +587,8 @@ impl Game<'_> {
projectiles,
modifiers,
} = swap;
// `ServerPlayer.permissions()` back to the host's after the turn.
self.ui.registries.permission = permission;
self.sculk.swap_player(&mut play.sculk);
// The guest's damage record back into their slot, the host's in place.
let guest_combat = std::mem::replace(&mut self.combat, combat);
@@ -1092,12 +1112,19 @@ impl Game<'_> {
// The pause between two blocks is the client's (MultiPlayerGameMode's
// destroyDelay); the server digs from the START it receives.
play.destroy_delay = 0;
// ServerPlayerGameMode.handleBlockBreakAction: a survival START
// ServerPlayerGameMode.handleBlockBreakAction: a creative START
// destroys the block at once (destroyAndAck "creative destroy"),
// so a UseItemOn sent behind it in the same tick (both buttons
// at once, #3006) replaces the air it left. A survival START
// attacks the block at once, so a tap released in the same tick
// still plays a note. The dig is marked begun for tick_mining.
let pos = a.pos;
self.as_guest(host, conn, |g| {
if g.may_build() && !g.creative {
let destroyed = self.as_guest(host, conn, |g| {
if g.creative {
let _ = g.break_block(pos);
return true;
}
if g.may_build() {
let _ = g.attack_block(pos);
g.mining = Some(Mining {
pos,
@@ -1105,7 +1132,13 @@ impl Game<'_> {
ticks: 0,
});
}
false
});
if destroyed == Some(true)
&& let Some(play) = host.play.get_mut(&conn)
{
play.digging = None;
}
}
1 => {}
3 => {
@@ -1827,7 +1860,10 @@ impl Game<'_> {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
{
let host_on_ground = self.player.as_ref().map(|p| p.ground.on_ground);
let on_ground = Self::pvp_victim_on_ground(host, victim, host_on_ground);
self.as_guest(host, conn, |g| {
g.weapon_hit_player(on_ground);
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
@@ -2231,6 +2267,11 @@ impl Game<'_> {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
continue;
};
// ServerGamePacketListenerImpl.tick: the chat and command throttlers
// decay one a tick, dead or alive.
let play = host.play.entry(conn).or_default();
play.chat_spam = play.chat_spam.saturating_sub(1);
play.command_spam = play.command_spam.saturating_sub(1);
self.guest_sleep_tick(host, gi);
if host.hub.guests[gi].dead {
self.guest_tick_death(host, gi);
@@ -2331,6 +2372,7 @@ impl Game<'_> {
host.hub.flush_progress(conn);
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
self.guest_arrows(host, gi);
self.guest_orbs(host, gi);
self.guest_die(host, gi, conn);
}
@@ -2346,6 +2388,7 @@ impl Game<'_> {
play.hook = None;
}
}
let here = host.hub.here(&host.hub.guests[gi]);
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
@@ -2362,6 +2405,11 @@ impl Game<'_> {
let drops =
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
// ServerPlayer.die: the neutral mobs near a guest in this level
// forget their anger at them.
if here {
self.tell_neutral_mobs_player_died(at, Some(conn), &host.mob_players);
}
// ServerPlayer.die: with showDeathMessages, the message goes to
// the dying player's death screen and to everyone the team lets
// see it; without it, the death screen gets an empty one.
@@ -2765,6 +2813,20 @@ impl Game<'_> {
count,
speed,
}) => self.landing_dust(Some(host), state, position, count, speed),
// LivingEntity.causeFallDamage: Player.playSound skips the
// guest, whose own client plays it; everyone else hears it.
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
sound,
volume,
pitch,
..
}) => {
if let Some(name) = sound.player_fall_sound() {
let source = horizon_client::settings::sound_source(name) as i32;
self.audio.play_unshared(name, Some(at), volume, pitch);
host.hub.sound(name, at, volume, pitch, source, Some(conn));
}
}
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// LivingEntity.baseTick's entity event 67, to every client
// around, the guest's own included; this window draws it too.
@@ -3064,7 +3126,8 @@ impl Game<'_> {
);
}
/// Dropped items touching a guest go into their inventory.
/// Dropped items touching a guest go into their inventory
/// (`ItemEntity.playerTouch`, then `ServerPlayer.onItemPickup`).
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) {
let (conn, mode, position, id, uuid) = {
let g = &host.hub.guests[gi];
@@ -3097,16 +3160,28 @@ impl Game<'_> {
for i in near.into_iter().rev() {
let (stack, item_id) = (self.dropped[i].stack.clone(), self.dropped[i].id);
let before = stack.count;
let thrower = self.dropped[i].thrower;
let rest = self.as_guest(host, conn, |g| g.give(stack)).flatten();
match rest {
None => {
let item = self.dropped.remove(i);
if let Some(thrower) = thrower {
self.as_guest(host, conn, |g| {
g.trigger_thrown_picked_up_by_player(&item.stack, thrower);
});
}
self.guest_pickup(host, id, item);
taken.push((item_id, before));
}
Some(rest) if rest.count < before => {
self.guest_pickup(host, id, self.dropped[i].clone());
self.dropped[i].stack = rest;
if let Some(thrower) = thrower {
let left = self.dropped[i].stack.clone();
self.as_guest(host, conn, |g| {
g.trigger_thrown_picked_up_by_player(&left, thrower);
});
}
taken.push((item_id, before - self.dropped[i].stack.count));
}
Some(_) => {}
@@ -3119,6 +3194,75 @@ impl Game<'_> {
}
}
/// `Player.aiStep` touching arrows in the guest's box grown by (1, 0.5, 1):
/// `AbstractArrow.playerTouch` for a guest, as `touch_arrows` does for the
/// host's player. The guest's own client plays the pickup sound
/// (`handleTakeItemEntity`).
fn guest_arrows(&mut self, host: &mut LanHost, gi: usize) {
use horizon_client::arrows::Touch;
let (conn, id, at, creative, unable, dims) = {
let g = &host.hub.guests[gi];
(
g.conn,
g.entity_id,
g.position,
g.creative(),
g.spectator() || g.dead || g.health <= 0.0,
g.dimensions(),
)
};
if unable || self.creatures.projectiles.is_empty() {
return;
}
let w = f64::from(dims.width) / 2.0;
let reach = Aabb::new(
Vec3::new(at[0] - w, at[1], at[2] - w),
Vec3::new(at[0] + w, at[1] + f64::from(dims.height), at[2] + w),
)
.inflate(Vec3::new(1.0, 0.5, 1.0));
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
let mut picked = Vec::new();
projectiles.retain(|shot| {
let Some(a) = shot.arrow.as_ref().filter(|a| !a.removed) else {
return true;
};
let p = shot.position;
let bounds = Aabb::new(
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
);
if !bounds.intersects(reach) {
return true;
}
let taken = match a.player_touch(creative) {
Touch::Ignore => false,
Touch::Take => true,
// Inventory.add: a creative player takes what does not fit.
Touch::Give(stack) => {
self.as_guest(host, conn, |g| g.give(stack))
.flatten()
.is_none()
|| creative
}
};
if taken {
picked.push(shot.clone());
}
!taken
});
self.creatures.projectiles = projectiles;
for shot in picked {
host.hub.take_item(shot.id as i32, id, 1, at);
if let Some(target) = super::pickups::guest_target(host, id as u32) {
self.add_pickup_toward(
super::pickups::Taken::Arrow(Box::new(shot)),
super::pickups::Collector::Guest(id as u32),
target,
);
}
}
}
/// `Player.tick`'s takeXpDelay and `Player.aiStep`'s touch of the
/// experience orbs in the guest's box grown by 1, 0.5, 1: one of them at
/// random, at most every 2 ticks (`ExperienceOrb.playerTouch`), repairs
@@ -54,6 +54,35 @@ impl Game<'_> {
out
}
/// ServerGamePacketListenerImpl.detectRateSpam: a chat message or command adds 20 to the
/// guest's throttler; over the threshold, a guest who is not an operator is kicked.
pub(super) fn detect_rate_spam(&mut self, host: &mut LanHost, conn: u32, command: bool) {
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let threshold = if command {
host.hub.command_spam_threshold
} else {
host.hub.chat_spam_threshold
};
let uuid = host.hub.guests[i].uuid;
let play = host.play.entry(conn).or_default();
let count = if command {
&mut play.command_spam
} else {
&mut play.chat_spam
};
*count = count.saturating_add(20);
// TickThrottler.isUnderThreshold: a threshold of 0 or less never kicks.
let under = threshold <= 0 || i64::from(*count) < i64::from(threshold);
if !under && !host.hub.ops.contains_key(&uuid) {
host.hub.kick(
conn,
horizon_protocol::text::translatable("disconnect.spam", &[]),
);
}
}
/// handleChatCommand (performUnsignedChatCommand): refused when the
/// server enforces secure chat and the command has a message to sign.
pub(super) fn guest_chat_command(&mut self, host: &mut LanHost, conn: u32, command: &str) {
@@ -77,9 +106,11 @@ impl Game<'_> {
content: component_nbt(&failure.component()),
overlay: false,
}));
self.detect_rate_spam(host, conn, true);
return;
}
self.guest_command(host, conn, command, None);
self.detect_rate_spam(host, conn, true);
}
/// handleSignedChatCommand: the acknowledgements, then each message
@@ -95,9 +126,11 @@ impl Game<'_> {
};
let mut events = Vec::new();
let mut signed = None;
let mut ran = false;
if let Some(last_seen) = host.hub.apply_last_seen(i, &p.last_seen, &mut events)
&& host.hub.try_handle_chat(i, &p.command, true)
{
ran = true;
let permission = host.hub.guests[i].permission;
let parsed = self.signable_arguments(permission, &p.command);
match host
@@ -114,6 +147,10 @@ impl Game<'_> {
if let Some(signed) = signed {
self.guest_command(host, conn, &p.command, Some(signed));
}
// The lambda counts the command even when its arguments failed to decode.
if ran {
self.detect_rate_spam(host, conn, true);
}
}
/// SayCommand, EmoteCommands and MsgCommand for a guest: chat to the
@@ -342,6 +342,8 @@ pub(super) struct Present {
pub creative: bool,
pub spectator: bool,
pub alive: bool,
/// Trial Omen, else Bad Omen, among the guest's effects.
pub omen: Option<horizon_client::mobs::trial_spawner::Omen>,
}
impl Game<'_> {
@@ -355,6 +357,12 @@ impl Game<'_> {
.filter(|g| g.loaded)
.map(|g| {
let [x, y, z] = g.position;
// The acting guest's effects are on the game while their turn runs.
let effects = if self.lan_acting == Some(g.conn) {
&self.potions.effects
} else {
&g.effects
};
Present {
uuid: horizon_client::lan::playerdata::uuid_ints(g.uuid),
position: g.position,
@@ -363,6 +371,7 @@ impl Game<'_> {
creative: g.creative(),
spectator: g.spectator(),
alive: !g.dead,
omen: omen_of(effects),
}
})
.collect()
@@ -376,6 +385,18 @@ impl Game<'_> {
}
}
/// `TrialSpawner.findPlayerWithOminousEffect`: Trial Omen first, then Bad Omen.
pub(super) fn omen_of(
effects: &horizon_client::effects::PlayerEffects,
) -> Option<horizon_client::mobs::trial_spawner::Omen> {
use horizon_client::mobs::trial_spawner::Omen;
if effects.get("trial_omen").is_some() {
Some(Omen::Trial)
} else {
effects.get("bad_omen").map(|_| Omen::Bad)
}
}
/// A guest's bounding box, by its pose (`Avatar.POSES`).
pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb {
g.bounds()
@@ -217,6 +217,11 @@ impl Game<'_> {
)),
T::DragonBreath | T::SculkCharge => Some(ParticleData::Scalar(options.scale)),
T::Shriek => Some(ParticleData::Delay(options.scale as i32)),
T::Geyser | T::GeyserPlume => Some(ParticleData::Geyser(options.geyser?.0)),
T::GeyserBase | T::GeyserPoof => {
let (water_blocks, burst_impulse_base) = options.geyser?;
Some(ParticleData::GeyserBase(water_blocks, burst_impulse_base))
}
T::Item | T::Trail | T::Vibration => return None,
_ => None,
};
@@ -266,6 +271,10 @@ impl Game<'_> {
..ParticleOptions::simple(ty)
},
Some(ParticleData::Delay(d)) => ParticleOptions::shriek(*d),
Some(ParticleData::Geyser(water_blocks)) => ParticleOptions::geyser(ty, *water_blocks),
Some(ParticleData::GeyserBase(water_blocks, burst_impulse_base)) => {
ParticleOptions::geyser_base(ty, *water_blocks, *burst_impulse_base)
}
Some(ParticleData::Trail(target, color, duration)) => {
ParticleOptions::trail(*target, *color as u32 & 0xFF_FFFF, *duration)
}
+18
View File
@@ -107,6 +107,7 @@ impl Game<'_> {
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
{
self.weapon_hit_player(Self::pvp_victim_on_ground(&host, Victim::Guest(conn), None));
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
@@ -116,6 +117,23 @@ impl Game<'_> {
true
}
/// Whether the player a hit landed on stands on the ground (the host's
/// `ground` is passed in as `host_on_ground` when the host is the victim).
pub(super) fn pvp_victim_on_ground(
host: &LanHost,
victim: Victim,
host_on_ground: Option<bool>,
) -> bool {
match victim {
Victim::Host => host_on_ground.unwrap_or(true),
Victim::Guest(conn) => host
.hub
.guest(conn)
.and_then(|g| g.motion.as_deref())
.is_some_and(|p| p.ground.on_ground),
}
}
/// The player `id` that guest `conn` attacks, if it is one.
pub(super) fn pvp_victim(host: &LanHost, conn: u32, id: i32) -> Option<Victim> {
if id == host.host_entity {
+38 -4
View File
@@ -237,9 +237,16 @@ impl Game<'_> {
/// the knockback around the target.
fn mace_hurt_enemy(&mut self, index: usize) {
let target_on_ground = self.mobs[index].on_ground;
let Some(p) = self.player.as_mut() else {
return;
};
if let Some(fall) = self.mace_smash_self(target_on_ground) {
self.smash_knockback(index, fall);
}
}
/// The attacker's side of `MaceItem.hurtEnemy` for a smash: the fall
/// stops, its damage is waived from where the hit landed, and the ground
/// or air smash sound plays. The fall distance it was, if there is a
/// player.
fn mace_smash_self(&mut self, target_on_ground: bool) -> Option<f64> {
let p = self.player.as_mut()?;
p.velocity = Vec3::new(p.velocity.x, f64::from(0.01f32), p.velocity.z);
// A server player's client is told (ClientboundSetEntityMotionPacket): it moves by
// itself, so without it a Wind Burst's push would add to the speed of the fall.
@@ -263,7 +270,34 @@ impl Game<'_> {
"minecraft:item.mace.smash_air"
};
self.audio.play(sound, Some(feet), 1.0, 1.0);
self.smash_knockback(index, fall);
Some(fall)
}
/// `Player.itemAttackInteraction` after the player's hit hurt another
/// player: the weapon's side of it, as `weapon_hit` does for a mob. A
/// smash stops the fall and its Wind Burst (a `post_attack` explosion on
/// the attacker) launches the attacker.
pub(super) fn weapon_hit_player(&mut self, victim_on_ground: bool) {
let Some(stack) = self.held().cloned() else {
return;
};
let smash = self.smash_attack();
if smash {
self.mace_smash_self(victim_on_ground);
}
if self
.res
.items()
.get(&stack.item)
.is_some_and(|i| i.weapon_damage.is_some())
{
self.stats.add("used", &stack.item, 1);
}
self.post_attack_bursts(&stack, "minecraft:player");
// MaceItem.postHurtEnemy
if smash && let Some(p) = self.player.as_mut() {
p.fall_distance = 0.0;
}
self.weapon_wear(&stack);
}
/// `MaceItem.knockback`: the dust pillar (level event 2013) under the
/// target, then every living entity within 3.5 blocks of it but the
@@ -62,6 +62,19 @@ impl CartKind {
_ => 0,
}
}
/// Whether this cart's `interact` passes on the client, so that
/// `Minecraft.startUseItem` uses the held item instead: `MinecartTNT` has
/// none of its own (`Entity.interact`), `MinecartCommandBlock.interact`
/// needs a game master, and `Minecart.interact` a player not sneaking at
/// an empty cart.
pub fn interact_passes(self, sneaking: bool, ridden: bool, game_master: bool) -> bool {
match self {
Self::Tnt => true,
Self::CommandBlock => !game_master,
Self::Rideable => sneaking || ridden,
Self::Chest | Self::Hopper | Self::Furnace => false,
}
}
}
#[derive(Clone, Debug)]
pub(super) struct Minecart {
@@ -1598,6 +1611,20 @@ impl Minecart {
mod tests {
use super::*;
#[test]
fn a_tnt_cart_lets_the_held_item_be_used() {
// Entity.interact passes: a crossbow aimed at it shoots.
assert!(CartKind::Tnt.interact_passes(false, false, true));
assert!(CartKind::CommandBlock.interact_passes(false, false, false));
assert!(!CartKind::CommandBlock.interact_passes(false, false, true));
assert!(!CartKind::Rideable.interact_passes(false, false, false));
assert!(CartKind::Rideable.interact_passes(true, false, false));
assert!(CartKind::Rideable.interact_passes(false, true, false));
for kind in [CartKind::Chest, CartKind::Hopper, CartKind::Furnace] {
assert!(!kind.interact_passes(true, true, false));
}
}
#[test]
fn display_offset_matches_vanilla_for_each_cart_kind() {
assert_eq!(display_offset(CartKind::Hopper), 1.0);
+6 -1
View File
@@ -186,7 +186,12 @@ impl Game<'_> {
let tamed = m.extra.tame;
let sound = if item == "minecraft:saddle" && tamed && !m.extra.saddled {
m.set_saddle(held);
Some("minecraft:entity.horse.saddle")
// Camel.getSaddleSound (the equip sound of a camel's saddle).
Some(match species {
Species::Camel => "minecraft:entity.camel.saddle",
Species::CamelHusk => "minecraft:entity.camel_husk.saddle",
_ => "minecraft:entity.horse.saddle",
})
} else if item.ends_with("_horse_armor")
&& species == Species::Horse
&& tamed
+16 -4
View File
@@ -181,11 +181,23 @@ impl Game<'_> {
.options
.shortcut(&self.gui.atlas, shortcut, &mut self.clipboard)
}
pub(super) fn options_scroll(&mut self, amount: f32) {
pub(super) fn options_scroll(&mut self, amount: f32) -> Result<()> {
let framebuffer = self.renderer.as_ref().map_or([1280, 720], |r| {
[r.config.width as i32, r.config.height as i32]
});
if self.ui.options.page() == Page::Video && self.has_control_down() {
// VideoSettingsScreen.mouseScrolled: Ctrl with the wheel steps the GUI scale.
let mut ui = std::mem::take(&mut self.ui.options);
let events = ui.video_wheel_gui_scale(&mut self.settings, framebuffer, amount);
self.ui.options = ui;
return self.handle_option_events(events);
}
let size = self.gui_size();
self.ui
.options
.scroll_at(&self.settings, size, self.cursor, amount);
let events =
self.ui
.options
.wheel(&mut self.settings, size, self.cursor, framebuffer, amount);
self.handle_option_events(events)
}
/// Mouse buttons pressed while a key is being bound.
pub(super) fn options_waiting_for_key(&self) -> bool {
+16
View File
@@ -223,10 +223,26 @@ impl Game<'_> {
// Nuggets come whole, anything else one at a time.
let (taken_item, id) = (e.clone(), self.mobs[i].id);
let item = e.stack.item.clone();
let carried = e.stack.clone();
let owner = e.thrower;
self.add_pickup(
super::pickups::Taken::Item(Box::new(taken_item)),
super::pickups::Collector::Mob(id),
);
// LivingEntity.onItemPickup, before PiglinAi.pickUpItem splits the stack.
// The owner is the host or a guest: whoever it is gets the trigger.
if let Some(owner) = owner {
let target = self.mob_entity(&self.mobs[i]);
self.for_each_player(|g| {
if g.acting_uuid() == owner {
g.trigger_item_entity(
"minecraft:thrown_item_picked_up_by_entity",
&carried,
&target,
);
}
});
}
let stack = if &*item == "minecraft:gold_nugget" {
self.dropped.remove(j).stack
} else {
@@ -0,0 +1,485 @@
//! Potent sulfur geysers: the tickers `PotentSulfurBlock.getTicker` gives
//! `PotentSulfurBlockEntity` by the block's state. The server's make living
//! entities near the gas nauseous, count a dormant or erupting geyser down to
//! its next change of state and push the entities an eruption throws up. The
//! client's draw the gas clouds and the eruption's plume, play its sound and
//! push the local player, who moves on its own client and not on the server.
use super::{
Game,
potions::{mob_can_be_affected, mob_effect_id},
};
use horizon_client::{
Result,
block_entities::Pos,
containers::BlockEntity,
effects::EffectInstance,
mobs::Mob,
particles::{ParticleLevel, ParticleOptions, ParticleType as T},
potent_sulfur::{
can_be_reached_by_noxious_gas, find_noxious_gas_source_block, unobstructed_block_count,
},
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::{PositionalRandom, RandomSource, XoroshiroRandom},
};
use horizon_world::{level::flags, registry::BlockStateId};
/// `PotentSulfurBlockEntity.GEYSER_SALT`.
const GEYSER_SALT: i64 = -904_011_478;
/// `PotentSulfurBlockEntity.EFFECT_APPLICATION_FREQUENCY_TICKS`.
const EFFECT_APPLICATION_FREQUENCY_TICKS: i64 = 10;
/// `PotentSulfurBlockEntity.EFFECT_DURATION_IN_TICKS`.
const EFFECT_DURATION_IN_TICKS: i32 = 80;
/// `PotentSulfurBlockEntity.PARTICLE_FREQUENCY_TICKS`.
const PARTICLE_FREQUENCY_TICKS: i64 = 20;
/// `PotentSulfurBlockEntity.SOUND_FREQUENCY_TICKS`.
const SOUND_FREQUENCY_TICKS: i64 = 40;
/// `SoundEvents.GEYSER_ERUPTION_ACTIVE`.
const GEYSER_ERUPTION_ACTIVE: &str = "minecraft:block.potent_sulfur.geyser_eruption_active";
/// `SoundEvents.GEYSER_CONTINUOUS_ACTIVE`.
const GEYSER_CONTINUOUS_ACTIVE: &str =
"minecraft:block.potent_sulfur.geyser_continuous_eruption_active";
/// `PotentSulfurState`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Geyser {
Dry,
Wet,
Dormant,
Erupting,
Continuous,
}
/// `PotentSulfurBlockEntity.geyserPositional`: the random of the sulfur block
/// at `pos`, forked from the level seed and the geyser salt.
fn geyser_positional(seed: i64, pos: Pos) -> XoroshiroRandom {
let mut base = XoroshiroRandom::new(seed ^ GEYSER_SALT);
base.fork_positional()
.at(BlockPos::new(pos.0, pos.1, pos.2))
}
/// `PotentSulfurBlockEntity.getNearbyLivingEntities`: the box of the source
/// block grown 2.5 blocks sideways.
fn nearby_living_area(source: [i32; 3]) -> Aabb {
let [x, y, z] = source.map(f64::from);
Aabb::new(
Vec3::new(x - 2.5, y, z - 2.5),
Vec3::new(x + 1.0 + 2.5, y + 1.0, z + 1.0 + 2.5),
)
}
/// The box `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` pushes entities in:
/// the block above the sulfur, `expandTowards` the unobstructed height.
fn launch_area(pos: Pos, height: i32) -> Aabb {
let (x, y, z) = (f64::from(pos.0), f64::from(pos.1 + 1), f64::from(pos.2));
let up = f64::from(height - 1);
let (min_y, max_y) = if up < 0.0 {
(y + up, y + 1.0)
} else {
(y, y + 1.0 + up)
};
Aabb::new(Vec3::new(x, min_y, z), Vec3::new(x + 1.0, max_y, z + 1.0))
}
/// `PotentSulfurBlockEntity.applyNauseaEffect` on a mob, if it can be
/// affected.
fn apply_nausea_effect_to_mob(
mob: &mut Mob,
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
) {
if mob_can_be_affected(tags, &mob.kind.id, "nausea")
&& let Some(nausea) = mob_effect_id("nausea")
{
mob.add_effect(nausea, EFFECT_DURATION_IN_TICKS, 0);
}
}
impl Game<'_> {
/// The `potent_sulfur_state` of the block at `pos`, with its block state.
fn potent_sulfur_state(&self, pos: Pos) -> Option<(BlockStateId, Geyser)> {
let state = self.world.block(pos.0, pos.1, pos.2);
let value = self
.pack
.assets
.registry
.state(state)?
.properties
.get("potent_sulfur_state")?;
let geyser = match value.as_str() {
"dry" => Geyser::Dry,
"wet" => Geyser::Wet,
"dormant" => Geyser::Dormant,
"erupting" => Geyser::Erupting,
"continuous" => Geyser::Continuous,
_ => return None,
};
Some((state, geyser))
}
/// `PotentSulfurBlockEntity.findNoxiousGasSourceBlock` of the sulfur at
/// `pos`.
fn potent_sulfur_source(&self, pos: Pos) -> Option<[i32; 3]> {
let level = ParticleLevel {
world: &self.world,
facts: self.res.facts(),
collision: self.collision,
player: None,
};
find_noxious_gas_source_block(&level, [pos.0, pos.1, pos.2])
}
/// `PotentSulfurBlock.getTicker` on the server, for the block entity at
/// `pos`: a wet block gives nausea; a dormant one counts down, then gives
/// nausea; an erupting one pushes entities, then counts down; a continuous
/// one pushes entities. A dry block has no ticker.
pub(super) fn tick_potent_sulfur(&mut self, pos: Pos) -> Result<()> {
let Some((state, geyser)) = self.potent_sulfur_state(pos) else {
return Ok(());
};
match geyser {
Geyser::Dry => {}
Geyser::Wet => self.server_nausea_effect_ticker(pos),
Geyser::Dormant => {
self.server_waiting_countdown_ticker(pos, state, geyser)?;
self.server_nausea_effect_ticker(pos);
}
Geyser::Erupting => {
self.launch_entity_ticker(pos);
self.server_waiting_countdown_ticker(pos, state, geyser)?;
}
Geyser::Continuous => self.launch_entity_ticker(pos),
}
Ok(())
}
/// `PotentSulfurBlockEntity.SERVER_NAUSEA_EFFECT_TICKER`: every 10 ticks,
/// the living entities near the source block that its gas reaches (at
/// their eyes) get nausea for 4 seconds.
fn server_nausea_effect_ticker(&mut self, pos: Pos) {
if self.world.game_time % EFFECT_APPLICATION_FREQUENCY_TICKS != 0 {
return;
}
let Some(source) = self.potent_sulfur_source(pos) else {
return;
};
let area = nearby_living_area(source);
for player in self
.fight_players()
.into_iter()
.filter(|p| p.alive && p.bounds.intersects(area))
{
self.as_fight_player(player.who, |g| g.apply_nausea_effect_to_player(source));
}
let level = ParticleLevel {
world: &self.world,
facts: self.res.facts(),
collision: self.collision,
player: None,
};
let tags = &self.res.stations().enchantments.entity_tags;
for mob in self
.mobs
.iter_mut()
.filter(|m| m.alive() && m.bounds().intersects(area))
{
let eye = [mob.position.x, mob.eye_y(), mob.position.z];
if can_be_reached_by_noxious_gas(&level, source, eye) {
apply_nausea_effect_to_mob(mob, tags);
}
}
}
/// `PotentSulfurBlockEntity.applyNauseaEffect` on the player acting (a
/// guest is swapped in as the player) when the gas reaches their eyes;
/// spectators are not affected.
fn apply_nausea_effect_to_player(&mut self, source: [i32; 3]) {
if self.spectator() {
return;
}
let Some(p) = self.player.as_ref() else {
return;
};
let eye = [
p.position.x,
p.position.y + f64::from(p.dimensions.eye_height),
p.position.z,
];
let reached = {
let level = ParticleLevel {
world: &self.world,
facts: self.res.facts(),
collision: self.collision,
player: None,
};
can_be_reached_by_noxious_gas(&level, source, eye)
};
if reached {
// MobEffectInstance(NAUSEA, 80, 0, true, true).
let mut nausea = EffectInstance::new("nausea", EFFECT_DURATION_IN_TICKS, 0);
nausea.ambient = true;
self.add_player_effect(nausea);
}
}
/// `PotentSulfurBlockEntity.SERVER_WAITING_COUNTDOWN_TICKER`: every second
/// the countdown runs down; a dormant geyser waits `10 * (water - 1)` plus
/// 15 to 30, an erupting one `water - 1` plus 1 or 2, and at zero the
/// block turns erupting (dormant) or dormant (erupting). `state` is the
/// block state the tick began with.
fn server_waiting_countdown_ticker(
&mut self,
pos: Pos,
state: BlockStateId,
geyser: Geyser,
) -> Result<()> {
if self.world.game_time % 20 != 0 {
return Ok(());
}
let Some(source) = self.potent_sulfur_source(pos) else {
return Ok(());
};
let Some(BlockEntity::PotentSulfur(sulfur)) = self.world.block_entities.get_mut(&pos)
else {
return Ok(());
};
if sulfur.waiting_countdown <= 0 {
let water_blocks = source[1] - pos.1 - 1;
let mut positional = geyser_positional(self.level.seed, pos);
sulfur.waiting_countdown = if geyser == Geyser::Dormant {
10 * (water_blocks - 1)
+ positional.next_int_between_inclusive(15, 30).unwrap_or(15)
} else {
positional.next_int();
water_blocks - 1 + positional.next_int_between_inclusive(1, 2).unwrap_or(1)
};
}
if sulfur.waiting_countdown > 0 {
sulfur.waiting_countdown -= 1;
}
if sulfur.waiting_countdown != 0 {
return Ok(());
}
let to_set = if geyser == Geyser::Dormant {
"erupting"
} else {
"dormant"
};
let registry = self.pack.assets.registry;
let Some(mut next) = registry.state(state).cloned() else {
return Ok(());
};
next.properties
.insert("potent_sulfur_state".into(), to_set.into());
let Some(next) = registry.id(&next) else {
return Ok(());
};
let at = BlockPos::new(pos.0, pos.1, pos.2);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, next, flags::UPDATE_ALL));
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
if to_set == "dormant" {
self.post_block_game_event(
"minecraft:block_deactivate",
[pos.0, pos.1, pos.2],
None,
Some(state),
);
}
self.process_level_events()
}
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the server: mobs and
/// items and primed TNT in the geyser's column with less than
/// `0.3 + 0.1 * water` upward speed get 0.2 more, unless they ride
/// something or are of a type geysers do not move. Players are moved by
/// their own client.
fn launch_entity_ticker(&mut self, pos: Pos) {
let Some(source) = self.potent_sulfur_source(pos) else {
return;
};
let water_blocks = source[1] - pos.1 - 1;
let level = ParticleLevel {
world: &self.world,
facts: self.res.facts(),
collision: self.collision,
player: None,
};
let height = unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks);
let area = launch_area(pos, height);
let limit = f64::from(0.3f32) + f64::from(water_blocks) * 0.1;
let push = f64::from(0.2f32);
let tags = &self.res.stations().enchantments.entity_tags;
let immune = tags.get("minecraft:not_affected_by_geysers");
for mob in self
.mobs
.iter_mut()
.filter(|m| m.alive() && m.bounds().intersects(area))
{
// Entity.checkFallDistanceAccumulation.
if mob.velocity.y > -0.5 && mob.fall_distance > 1.0 {
mob.fall_distance = 1.0;
}
if !mob.extra.passenger
&& !immune.is_some_and(|t| t.contains(&mob.kind.id))
&& mob.velocity.y < limit
{
mob.velocity.y += push;
}
}
for item in self
.dropped
.iter_mut()
.filter(|i| i.bounds().intersects(area))
{
if item.velocity.y < limit {
item.velocity.y += push;
}
}
for tnt in self.tnts.iter_mut().filter(|t| t.bounds().intersects(area)) {
if tnt.velocity.y < limit {
tnt.velocity.y += push;
}
}
}
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the client: the local
/// player in the column, unless spectating, flying or riding.
fn launch_local_player_ticker(&mut self, pos: Pos) {
let Some(source) = self.potent_sulfur_source(pos) else {
return;
};
let water_blocks = source[1] - pos.1 - 1;
let height = {
let level = ParticleLevel {
world: &self.world,
facts: self.res.facts(),
collision: self.collision,
player: None,
};
unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks)
};
let area = launch_area(pos, height);
let (spectator, riding) = (self.spectator(), self.riding.is_some());
let Some(p) = self.player.as_mut() else {
return;
};
if spectator || p.health.dead_or_dying() || !p.bounds().intersects(area) {
return;
}
// Entity.checkFallDistanceAccumulation.
if p.velocity.y > -0.5 && p.fall_distance > 1.0 {
p.fall_distance = 1.0;
}
if !p.flying && !riding && p.velocity.y < f64::from(0.3f32) + f64::from(water_blocks) * 0.1
{
p.velocity.y += f64::from(0.2f32);
}
}
/// `PotentSulfurBlockEntity.spawnGeyserParticle`: the eruption particle at
/// the bottom of the source block, as high as the water column is deep.
fn spawn_geyser_particle(&mut self, sulfur: Pos, source: [i32; 3]) {
let water_blocks = source[1] - sulfur.1 - 1;
self.particles.add(
&ParticleOptions::geyser(T::Geyser, water_blocks),
[
f64::from(source[0]) + 0.5,
f64::from(source[1]),
f64::from(source[2]) + 0.5,
],
[0.0; 3],
);
}
/// `PotentSulfurBlockEntity.spawnNoxiousGasCloudParticle` at the centre of
/// the source block.
fn spawn_noxious_gas_cloud_particle(&mut self, source: [i32; 3]) {
self.particles.add(
&ParticleOptions::simple(T::NoxiousGasCloud),
source.map(|c| f64::from(c) + 0.5),
[0.0; 3],
);
}
/// `PotentSulfurBlock.getTicker` on the client, for the block entity at
/// `pos`: a wet or dormant geyser releases gas clouds every second; an
/// erupting or continuous one shows its plume every second (from its last
/// eruption event), plays its sound every two, and pushes the local
/// player.
pub(super) fn client_tick_potent_sulfur(&mut self, pos: Pos) {
let Some((_, geyser)) = self.potent_sulfur_state(pos) else {
return;
};
let time = self.world.game_time;
// PotentSulfurBlockEntity.setLevel.
let eruption_tick = match self.world.block_entities.get_mut(&pos) {
Some(BlockEntity::PotentSulfur(sulfur)) => {
if sulfur.eruption_tick == -1 {
sulfur.eruption_tick = time;
}
sulfur.eruption_tick
}
_ => return,
};
match geyser {
Geyser::Dry => {}
// CLIENT_NOXIOUS_GAS_TICKER.
Geyser::Wet | Geyser::Dormant => {
if time % PARTICLE_FREQUENCY_TICKS == 0
&& let Some(source) = self.potent_sulfur_source(pos)
{
self.spawn_noxious_gas_cloud_particle(source);
}
}
// CLIENT_GEYSER_PLUME_TICKER, then LAUNCH_ENTITY_TICKER.
Geyser::Erupting | Geyser::Continuous => {
if let Some(source) = self.potent_sulfur_source(pos) {
let eruption_time = time - eruption_tick;
if eruption_time % PARTICLE_FREQUENCY_TICKS == 0 {
self.spawn_geyser_particle(pos, source);
}
if eruption_time % SOUND_FREQUENCY_TICKS == 0 {
let sound = if geyser == Geyser::Erupting {
GEYSER_ERUPTION_ACTIVE
} else {
GEYSER_CONTINUOUS_ACTIVE
};
let at = source.map(|c| f64::from(c) + 0.5);
// playLocalSound: guests play their own.
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
}
}
self.launch_local_player_ticker(pos);
}
}
}
/// `ClientLevel.tickBlockEntities` for the potent sulfur of a server's
/// level: every one the client knows ticks.
pub(super) fn tick_remote_potent_sulfur(&mut self) {
let keys: Vec<Pos> = self
.world
.block_entities
.iter()
.filter_map(|(k, e)| matches!(e, BlockEntity::PotentSulfur(_)).then_some(*k))
.collect();
for key in keys {
self.client_tick_potent_sulfur(key);
}
}
/// `PotentSulfurBlock.triggerEvent`: the eruption block event stamps the
/// game time the plume counts from; false when `pos` holds no potent
/// sulfur block entity.
pub(super) fn potent_sulfur_eruption_event(&mut self, pos: Pos) -> bool {
let time = self.world.game_time;
match self.world.block_entities.get_mut(&pos) {
Some(BlockEntity::PotentSulfur(sulfur)) => {
sulfur.eruption_tick = time;
true
}
_ => false,
}
}
}
+53 -8
View File
@@ -7,6 +7,7 @@ use super::{
dragon::{Breath, FightPlayer, FightWho},
item_draw::owner_at,
remote_projectiles::cloud_client_tick,
splash_water::thrown_block_hit,
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
@@ -90,7 +91,7 @@ pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
/// decide first, then the tag of mobs that ignore poison and regeneration.
fn mob_can_be_affected(
pub(super) fn mob_can_be_affected(
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
kind: &str,
effect: &str,
@@ -781,6 +782,9 @@ impl Game<'_> {
p.status = status;
}
}
/// `ThrowableProjectile.tick` for the potions in flight: the motion, then
/// the first block or mob the step meets (`ProjectileUtil.getHitResultOnMoveVector`),
/// where the potion splashes (`Projectile.onHit`).
fn tick_thrown_potions(&mut self) {
let mut thrown = std::mem::take(&mut self.potions.thrown);
let mut landed = Vec::new();
@@ -814,20 +818,25 @@ impl Game<'_> {
&& p.y <= m.position.y + m.kind.height
});
if self.res.facts().get(state).full_collision || mob.is_some() {
hit = Some((p, mob.map(|m| m.position)));
// A block hit has the face it came in by
// (`onHitBlock`); a mob hit has none.
let block = mob
.is_none()
.then(|| thrown_block_hit(t.position, p, t.velocity));
hit = Some((p, mob.map(|m| m.position), block));
break;
}
}
if let Some((p, mob)) = hit {
landed.push((t.stack.clone(), p, mob, t.age, t.owner.clone()));
if let Some((p, mob, block)) = hit {
landed.push((t.stack.clone(), p, mob, block, t.age, t.owner.clone()));
return false;
}
t.position = next;
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
for (stack, at, mob, age, owner) in landed {
self.splash(&stack, at, mob, age, owner);
for (stack, at, mob, block, age, owner) in landed {
self.splash(&stack, at, mob, block, age, owner);
}
}
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
@@ -1128,13 +1137,16 @@ impl Game<'_> {
}
}
}
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
/// for a lingering potion; `mob` is where it hit a mob, `age` its age.
/// `AbstractThrownPotion.onHit` with `ThrownSplashPotion.onHitAsPotion`, or
/// `ThrownLingeringPotion.onHitAsPotion` for a lingering potion, read from
/// the stack's `potion_contents`; `mob` is where it hit a mob, `hit` the
/// block and face it hit, `age` its age.
fn splash(
&mut self,
stack: &ItemStack,
at: Vec3,
mob: Option<Vec3>,
hit: Option<([i32; 3], Direction)>,
age: u32,
owner: Option<Text>,
) {
@@ -1149,6 +1161,12 @@ impl Game<'_> {
self.spawn_orbs([at.x, at.y, at.z], value as u32);
return;
}
// Projectile.onHit calls onHitBlock first, then the potion's
// affectEntitiesAround; both read the stack's `potion_contents`.
if let Some((cell, face)) = hit {
self.potion_hit_block(stack, cell, face);
}
self.affect_entities_around(stack, at, owner.is_some());
let lingering = &*stack.item == "minecraft:lingering_potion";
// AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a
// potion with instant effects, 2002 and 1053 otherwise.
@@ -1161,6 +1179,33 @@ impl Game<'_> {
let color = brewing::potion_color(stack.potion());
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
// AbstractThrownPotion.affectEntitiesAround: a water potion (the only
// #rehydrates_axolotls) refills the air of the axolotls in the
// projectile's box inflated by 4, 2 and 4 (Axolotl.rehydrate, 1800).
if stack.potion() == Some("minecraft:water") {
let (lo, hi) = (
[at.x - 0.125 - 4.0, at.y - 2.0, at.z - 0.125 - 4.0],
[at.x + 0.125 + 4.0, at.y + 0.25 + 2.0, at.z + 0.125 + 4.0],
);
for m in self
.mobs
.iter_mut()
.filter(|m| m.species() == horizon_client::mobs::Species::Axolotl && !m.removed)
{
let (w, h, _) = m.dims();
let half = w / 2.0;
let (x, y, z) = (m.position.x, m.position.y, m.position.z);
if x - half < hi[0]
&& x + half > lo[0]
&& y < hi[1]
&& y + h > lo[1]
&& z - half < hi[2]
&& z + half > lo[2]
{
m.air = (m.air + 1800).min(horizon_client::mobs::axolotl::MAX_AIR);
}
}
}
let Some(def) = potion else {
return;
};
@@ -57,7 +57,7 @@ impl Game<'_> {
Ok(())
}
/// Entity.playEntityOnFireExtinguishedSound
fn extinguish_sound(&mut self, at: [f64; 3]) {
pub(super) fn extinguish_sound(&mut self, at: [f64; 3]) {
let r = self.particles.random();
let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4;
self.audio.play(
@@ -1101,6 +1101,20 @@ impl Game<'_> {
let calls = screen::ghost(self.swap_index(), ox as i32, oy as i32, big_result, &ghosts);
self.paint_book(batch, &calls);
}
/// `GhostSlots.extractTooltip`: the stack a ghost shows on menu slot
/// `slot` now, cycling like its draw; only while the open book is visible
/// (`RecipeBookComponent.extractTooltip`).
pub(super) fn ghost_stack(&self, slot: usize) -> Option<ItemStack> {
if !self.book_open(self.active_book()?) {
return None;
}
let ghost = self
.recipe_ui
.ghost
.iter()
.find(|g| g.slot == slot && !g.items.is_empty())?;
Some(ghost.items[self.swap_index() % ghost.items.len()].clone())
}
/// `RecipeBookComponent.extractRenderState` and `extractTooltip`, laid
/// out by `horizon_client::recipe_book_screen`.
fn draw_book_panel(&mut self, batch: &mut HudBatch, book_type: BookType) {
+301 -12
View File
@@ -177,6 +177,10 @@ pub(super) struct Remote {
pub chat: Vec<(String, Instant)>,
/// Lines received since joining, for the control socket's events.
pub chat_received: u64,
/// `ChatListener.delayedMessageQueue`: player chat held back by Chat Delay.
chat_queue: VecDeque<(horizon_client::text::Text, horizon_client::chat::ChatSource)>,
/// `ChatListener.previousMessageTime`: when a message last showed, if one has.
chat_previous: Option<Instant>,
/// The server's command tree (ClientboundCommandsPacket).
pub commands: Option<horizon_client::command::Dispatcher>,
players: HashMap<u128, String>,
@@ -511,6 +515,8 @@ impl Game<'_> {
game_mode: 0,
chat: Vec::new(),
chat_received: 0,
chat_queue: VecDeque::new(),
chat_previous: None,
commands: None,
players: HashMap::new(),
tab_players: HashMap::new(),
@@ -762,9 +768,9 @@ impl Game<'_> {
&mut self,
content: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
) {
) -> bool {
if !self.ui.chat.shows(source) {
return;
return false;
}
let text = content.plain();
// addServerSystemMessage and addClientSystemMessage both carry
@@ -786,6 +792,98 @@ impl Game<'_> {
r.chat.remove(0);
}
}
true
}
/// `ChatListener.handleMessage` for player and disguised chat: held in the
/// queue while Chat Delay is set, shown at once otherwise.
pub(super) fn handle_player_chat(&mut self, line: horizon_client::text::Text) {
let source = horizon_client::chat::ChatSource::Player;
if self.chat_delay_ms() > 0 {
if let Some(r) = self.remote.as_mut() {
r.chat_queue.push_back((line, source));
}
} else {
self.accept_chat(line, source);
}
}
/// A message's `accept`: shown, it starts the Chat Delay wait again.
fn accept_chat(
&mut self,
line: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
) -> bool {
let shown = self.chat_line(line, source);
if shown && let Some(r) = self.remote.as_mut() {
r.chat_previous = Some(Instant::now());
}
shown
}
/// `ChatListener.messageDelay`: Chat Delay in milliseconds, as Java
/// truncates it.
fn chat_delay_ms(&self) -> i64 {
(self.settings.chat_delay * 1000.0) as i64
}
/// `ChatListener.tick`: while paused the wait does not run down; with no
/// Chat Delay the queue flushes; otherwise the next message the chat takes
/// shows once the delay has passed since the last shown one.
pub(super) fn tick_chat_queue(&mut self) {
let delay = self.chat_delay_ms();
// Minecraft.isPaused: a single-player pausing screen, not every pause menu.
if self.pause {
if delay > 0
&& let Some(r) = self.remote.as_mut()
{
r.chat_previous = r.chat_previous.map(|t| t + Duration::from_millis(50));
}
return;
}
if delay == 0 {
if self
.remote
.as_ref()
.is_some_and(|r| !r.chat_queue.is_empty())
{
self.flush_chat_queue();
}
return;
}
let ready = self.remote.as_ref().is_some_and(|r| {
r.chat_previous
.is_none_or(|t| t.elapsed() >= Duration::from_millis(delay as u64))
});
if !ready {
return;
}
// A message the Chat option hides is dropped and the next one is tried,
// until one shows.
while let Some((line, source)) = self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
{
if self.accept_chat(line, source) {
break;
}
}
}
/// `ChatListener.flushQueue`: every held-back message shows now, and the
/// delay starts over.
fn flush_chat_queue(&mut self) {
let Some(queue) = self
.remote
.as_mut()
.map(|r| std::mem::take(&mut r.chat_queue))
else {
return;
};
for (line, source) in queue {
self.chat_line(line, source);
}
if let Some(r) = self.remote.as_mut() {
r.chat_previous = None;
}
}
/// The minecarts the server sent, for the control socket's `status`
@@ -1598,6 +1696,18 @@ impl Game<'_> {
{
return;
}
// A fishing hook keeps a LinearInterpolationHandler: eased towards the
// update over three ticks, on top of its own movement (`tick_remote_hooks`).
if path.is_some() {
if let Some(h) = self.hook.as_mut().filter(|h| h.id() == id as u32) {
h.move_to(position, on_ground);
return;
}
if let Some(h) = self.remote_hooks.iter_mut().find(|h| h.id() == id as u32) {
h.move_to(position, on_ground);
return;
}
}
// A thrown trident is an arrow: placed at once, flown on by its tick.
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
if path.is_some() {
@@ -1927,6 +2037,7 @@ impl Game<'_> {
})
.clamp(-90.0, 90.0);
}
C::PlayerLookAt(p) => self.remote_look_at(&p),
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
C::LightUpdate(p) => self.remote_light_update(&p),
C::ForgetLevelChunk(p) => {
@@ -2534,6 +2645,13 @@ impl Game<'_> {
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// LivingEntity.handleEntityEvent 55: another player swapped
// its main and off hand (`swapHandItems`).
if p.event == 55
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
@@ -2953,12 +3071,12 @@ impl Game<'_> {
.as_ref()
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
let line = self.format_chat(&p.chat_type, content);
self.chat_line(line, horizon_client::chat::ChatSource::Player);
self.handle_player_chat(line);
}
C::DisguisedChat(p) => {
let content = horizon_client::scoreboard::tag_to_json(&p.message);
let line = self.format_chat(&p.chat_type, content);
self.chat_line(line, horizon_client::chat::ChatSource::Player);
self.handle_player_chat(line);
}
C::PlayerInfoUpdate(p) => {
if let Some(r) = self.remote.as_mut() {
@@ -3710,6 +3828,7 @@ impl Game<'_> {
t.velocity = v;
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
r.velocity = v;
} else if self.remote_hook_motion(id as u32, v) {
} else if let Some(p) = self
.remote
.as_mut()
@@ -3763,6 +3882,34 @@ impl Game<'_> {
handler.cancel();
}
}
/// `ClientPacketListener.handleLookAt`: `Entity.lookAt` from this
/// player's feet or eyes, towards the packet's entity (its feet or eyes)
/// when this client tracks it, else towards the packet's point.
fn remote_look_at(&mut self, p: &play::PlayerLookAt) {
// `EntityAnchorArgument.Anchor`: 0 feet, 1 eyes.
const EYES: i32 = 1;
let Some(player) = self.player.as_ref() else {
return;
};
let mut from = [player.position.x, player.position.y, player.position.z];
if p.from == EYES {
from[1] += f64::from(player.dimensions.eye_height);
}
let to = p
.entity
.as_ref()
.and_then(|e| {
let m = self.mobs.iter().find(|m| m.id == e.id as u32)?;
let y = if e.anchor == EYES {
m.eye_y()
} else {
m.position.y
};
Some([m.position.x, y, m.position.z])
})
.unwrap_or(p.target);
(self.yaw, self.pitch) = look_at_rotation(from, to);
}
/// ClientPacketListener.handleMovePlayer.
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
use play::relative as rel;
@@ -3925,6 +4072,7 @@ impl Game<'_> {
self.tick_block_entity_anims();
self.tick_remote_spawners();
self.client_tick_beacons();
self.tick_remote_potent_sulfur();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
@@ -4139,6 +4287,21 @@ impl Game<'_> {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
}
// LivingEntity.causeFallDamage on the client's own level: the
// fall sound is heard here (LocalPlayer.playSound); the
// server's copy skips this player (Player.playSound).
horizon_client::motion::MotionEvent::Fall(
horizon_client::landing::FallEvent::Sound {
sound,
volume,
pitch,
..
},
) => {
if let Some(name) = sound.player_fall_sound() {
self.audio.play(name, Some(feet), volume, pitch);
}
}
_ => {}
}
}
@@ -4461,6 +4624,12 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Chicken {
horizon_client::mobs::farm::chicken_flap(m);
}
// Parrot.calculateFlapping: the wings follow the server's
// on-ground flag and the shoulder it sits on (the glide is the
// server's).
if m.species() == horizon_client::mobs::Species::Parrot {
horizon_client::mobs::parrot::flap(m);
}
// LivingEntity.updatingUsingItem: the ticks the item has been
// used before this one. Another player's use particles; a mob's
// drawn bow only needs the count (its pull follows it).
@@ -4510,7 +4679,7 @@ impl Game<'_> {
m.body_rotation_tick();
}
// Squid.aiStep follows LivingEntity's interpolation and head turn.
horizon_client::mobs::aquatic::squid_client_step(m);
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
// WalkAnimationState.update from the horizontal distance moved.
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0);
@@ -4765,6 +4934,9 @@ impl Game<'_> {
/// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock.
fn remote_mining(&mut self, start: bool) {
let debug_stick = self
.held()
.is_some_and(|stack| &*stack.item == "minecraft:debug_stick");
if !self
.res
.items()
@@ -4797,7 +4969,11 @@ impl Game<'_> {
}
if self.creative {
let seq = self.next_sequence();
self.predict_break(hit.pos, state, seq);
// DebugStickItem.canDestroyBlock never lets the client break the
// block; the server only selects the property on this START.
if !debug_stick {
self.predict_break(hit.pos, state, seq);
}
self.send(S::PlayerAction(play::PlayerAction {
action: 0,
pos: hit.pos,
@@ -4847,7 +5023,9 @@ impl Game<'_> {
self.tutorial_destroy(state, 0.0);
if progress >= 1.0 {
// startDestroyBlock: instant survival breaks have no destroy delay.
self.predict_break(hit.pos, state, seq);
if !debug_stick {
self.predict_break(hit.pos, state, seq);
}
return;
}
if let Some(r) = self.remote.as_mut() {
@@ -4872,7 +5050,11 @@ impl Game<'_> {
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
if done >= 1.0 {
let seq = self.next_sequence();
self.predict_break(pos, state, seq);
// DebugStickItem.canDestroyBlock: the server selects the property
// and sends the block update instead; never predict a break.
if !debug_stick {
self.predict_break(pos, state, seq);
}
self.send(S::PlayerAction(play::PlayerAction {
action: 3,
pos,
@@ -4977,16 +5159,31 @@ impl Game<'_> {
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
// The main hand's turn takes it; there is no off hand turn.
if hand == Hand::Off {
// A cart whose interact passes (a TNT minecart) leaves each hand's
// turn to its item: a crossbow aimed at it shoots.
let passes = self
.carts
.iter()
.find(|c| c.id as i32 == hit.id)
.is_some_and(|c| {
let ridden = self.riding == Some(c.id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(c.id));
c.kind
.interact_passes(sneaking, ridden, self.can_use_game_master_blocks())
});
// Otherwise the main hand's turn takes it; there is no off hand turn.
if hand == Hand::Off && !passes {
return false;
}
self.send(S::Interact(play::Interact {
id: hit.id,
hand: 0,
hand: hand_id(hand),
location: hit.location,
secondary: sneaking,
}));
if passes {
return false;
}
self.swing_arm();
return true;
}
@@ -5040,6 +5237,9 @@ impl Game<'_> {
.as_ref()
.and_then(|h| self.items().get(&h.item))
.is_some_and(|i| i.block.is_some());
let debug_stick = held
.as_ref()
.is_some_and(|stack| &*stack.item == "minecraft:debug_stick");
if let Some(hit) = self.pick() {
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
// without predicting the inventory swap or swinging.
@@ -5066,6 +5266,13 @@ impl Game<'_> {
};
(result, DryUse::default())
};
// DebugStickItem.useOn once the block passed: SUCCESS on the client
// too (the hand swings below); only the server changes the block.
let (result, changes) = if debug_stick && !spectator && !result.consumes_action() {
(InteractionResult::Success, DryUse::default())
} else {
(result, changes)
};
let reacts = result.consumes_action();
// ArmorStandItem.useOn runs in startPrediction before the packet
// goes out (the server may spawn the stand at once).
@@ -5125,6 +5332,16 @@ impl Game<'_> {
}
});
return true;
} else if held
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:bone_meal")
&& self.remote_bone_meal_succeeds(&hit)
{
// BoneMealItem.useOn succeeded on this client's level (SUCCESS;
// the server grows the plant): this hand swings and the click
// ends, so the other hand (a torch) is never tried.
self.with_hand(hand, Game::swing_arm);
return true;
} else if stand_fits {
// ArmorStandItem.useOn on a client level: SUCCESS once the
// stand fits, before the server spawns it. This hand swings
@@ -5214,6 +5431,16 @@ impl Game<'_> {
}
}
/// `BoneMealItem.useOn` on this client's level (a copy: nothing grows
/// here): whether it succeeds.
fn remote_bone_meal_succeeds(&mut self, hit: &super::Hit) -> bool {
let pos = horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
let face = horizon_core::geometry::Direction::ALL[face_direction(hit.face) as usize];
self.with_level(|b, level, _| {
b.bone_meal_succeeds_on_client(&mut DryLevel::new(level), pos, face)
})
}
/// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the
/// use changed on this client's level at once, each kept as predicted
/// under the use's sequence until the server acknowledges it (a use the
@@ -5282,6 +5509,12 @@ impl Game<'_> {
{
return (InteractionResult::Success, DryUse::default());
}
// ComposterBlock.useItemOn: a compostable item on a composter below
// level 8 is SUCCESS on the client too (only the server adds the
// layer), so the other hand (a torch) is never tried.
if self.composter_takes(hit.pos, held) {
return (InteractionResult::Success, DryUse::default());
}
use horizon_core::geometry::Direction;
let face = match hit.face {
[0, 1, 0] => Direction::Up,
@@ -5910,7 +6143,7 @@ impl Game<'_> {
match key {
KeyCode::KeyT | KeyCode::Slash | KeyCode::Enter => {
// ChatScreen, over the server's command tree.
self.open_chat(if key == KeyCode::Slash { "/" } else { "" });
self.open_chat_method(if key == KeyCode::Slash { "/" } else { "" });
Ok(true)
}
KeyCode::KeyQ => {
@@ -5940,6 +6173,7 @@ impl Game<'_> {
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
self.refresh_saved_hotbars();
} else {
self.screen = Screen::Inventory;
}
@@ -6648,6 +6882,18 @@ impl Game<'_> {
}
}
/// `Entity.lookAt`'s rotation from `from` towards `to`, as (yaw, pitch).
fn look_at_rotation(from: [f64; 3], to: [f64; 3]) -> (f32, f32) {
use horizon_core::geometry::trig::atan2;
let (dx, dy, dz) = (to[0] - from[0], to[1] - from[1], to[2] - from[2]);
let horizontal = (dx * dx + dz * dz).sqrt();
// `* 180.0F / (float)Math.PI` on the double.
let degrees = |r: f64| r * 180.0 / f64::from(std::f32::consts::PI);
let pitch = wrap_degrees((-degrees(atan2(dy, horizontal))) as f32);
let yaw = wrap_degrees(degrees(atan2(dz, dx)) as f32 - 90.0);
(yaw, pitch)
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(a: f32) -> f32 {
let a = a % 360.0;
@@ -6974,6 +7220,12 @@ impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_
}
impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_, '_> {
fn pick_hit_result(&mut self) {
self.0.tick_hit = Some(self.0.pick());
}
fn release_hit_result(&mut self) {
self.0.tick_hit = None;
}
fn using_item(&self) -> bool {
self.0.using.is_some()
}
@@ -7167,6 +7419,43 @@ mod tests {
use super::Game;
/// `Entity.lookAt`: yaw 0 faces +Z, -90 faces +X; pitch -90 looks up.
#[test]
fn look_at_turns_towards_the_target() {
let close = |(yaw, pitch): (f32, f32), want: (f32, f32)| {
assert!(
(yaw - want.0).abs() < 1e-3 && (pitch - want.1).abs() < 1e-3,
"{yaw} {pitch}, want {want:?}"
);
};
let at = [10.0, 64.0, -5.0];
let off = |d: [f64; 3]| [at[0] + d[0], at[1] + d[1], at[2] + d[2]];
close(
super::look_at_rotation(at, off([0.0, 0.0, 3.0])),
(0.0, 0.0),
);
close(
super::look_at_rotation(at, off([3.0, 0.0, 0.0])),
(-90.0, 0.0),
);
close(
super::look_at_rotation(at, off([-3.0, 0.0, 0.0])),
(90.0, 0.0),
);
close(
super::look_at_rotation(at, off([0.0, 0.0, -3.0])),
(-180.0, 0.0),
);
close(
super::look_at_rotation(at, off([0.0, 2.0, 2.0])),
(0.0, -45.0),
);
close(
super::look_at_rotation(at, off([0.0, -4.0, 4.0])),
(0.0, 45.0),
);
}
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
@@ -119,8 +119,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
}
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on.
(Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0,
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
(Species::Mooshroom, 17, Value::Int(variant)) => {
// MushroomCow.DATA_TYPE (after AgeableMob's DATA_BABY_ID and
// AGE_LOCKED): 0 red, 1 brown.
(Species::Mooshroom, 18, Value::Int(variant)) => {
m.extra.variant = (*variant == 1) as u8
}
// Piglin.DATA_IS_DANCING: the dance pose.
@@ -180,6 +181,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
}
// Vex.DATA_FLAGS_ID: charging in bit 0.
(Species::Vex, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
// GlowSquid.DATA_DARK_TICKS_REMAINING (after AgeableMob's two).
(Species::GlowSquid, 18, Value::Int(ticks)) => {
horizon_client::mobs::aquatic::set_dark_ticks(m, *ticks);
}
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
(Species::Creaking, 16, Value::Bool(can_move)) => m.extra.flag2 = !*can_move,
(Species::Creaking, 17, Value::Bool(active)) => m.extra.flag = *active,
@@ -525,6 +530,21 @@ mod tests {
}
}
#[test]
fn a_mooshroom_takes_its_type_after_the_age_lock() {
let Some(mut m) = mob("minecraft:mooshroom") else {
return;
};
// AgeableMob.AGE_LOCKED (17) is not the colour.
apply(&mut m, &[(17, s::BOOLEAN, Value::Bool(true))]);
assert_eq!(m.extra.variant, 0);
// MushroomCow.DATA_TYPE (18): what MushroomCow.thunderHit changes.
apply(&mut m, &[(18, s::INT, Value::Int(1))]);
assert_eq!(m.extra.variant, 1);
apply(&mut m, &[(18, s::INT, Value::Int(0))]);
assert_eq!(m.extra.variant, 0);
}
#[test]
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
let Some(mut m) = mob("minecraft:sheep") else {
@@ -285,7 +285,7 @@ impl Game<'_> {
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
}
// FishingHook.recreateFromPacket: the owner's id is the data.
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, data),
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, velocity, data),
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
id,
position: at,
@@ -599,7 +599,7 @@ impl Game<'_> {
c.position = step(c.id, c.position);
}
self.step_remote_thrown();
self.step_remote_hooks(&mut step);
self.tick_remote_hooks();
self.end.step_remote_eyes();
self.tick_remote_clouds();
// The dragon's fireballs fly on between the tracker's updates, with
@@ -167,9 +167,11 @@ impl Game<'_> {
Some(c.seat())
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
// AbstractBoat.tick on the controlling client: steer and move.
let mut b = self.boats[i].clone();
// It steers with the input `LocalPlayer.rideTick` latched on the
// previous tick, so the paddle packet describes the motion the
// server is sent; this tick's keys are latched after it ticked.
let mut b = self.tick_boat(self.boats[i].clone(), true);
b.input = [keys.left, keys.right, keys.forward, keys.backward];
let b = self.tick_boat(b, true);
// positionRider: the seat turns with the boat; clampRotation.
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
@@ -251,6 +251,21 @@ impl Game<'_> {
];
self.block_explosion(center, in_water.then_some(pos))
}
/// `StrawBedBlock.destroyBed`: the break sound, then the bed is air.
pub(super) fn destroy_straw_bed(&mut self, head: [i32; 3]) -> Result<()> {
let center = [
f64::from(head[0]) + 0.5,
f64::from(head[1]) + 0.5,
f64::from(head[2]) + 0.5,
];
self.audio.play(
"minecraft:block.straw_bed.break_leave",
Some(center),
1.0,
1.0,
);
self.level_set(head, self.world.air)
}
/// `BedRule.destroyOnUse` of the dimension's `bed_rule` and
/// `straw_bed_rule`: true in the Nether and the End.
pub(super) fn beds_destroy_on_use(&self) -> bool {
@@ -268,14 +283,7 @@ impl Game<'_> {
f64::from(head[2]) + 0.5,
];
if &*name == "minecraft:straw_bed" {
// StrawBedBlock.destroyBed.
self.audio.play(
"minecraft:block.straw_bed.break_leave",
Some(center),
1.0,
1.0,
);
return self.level_set(head, self.world.air);
return self.destroy_straw_bed(head);
}
// removeBlock(head), then the foot behind it if it is still this bed.
let facing = self
+25 -2
View File
@@ -1614,7 +1614,16 @@ impl Game<'_> {
}
fn tick_mob_sculk_events(&mut self) {
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>, Option<u32>)> = self
let snapshot: Vec<(
u32,
[f64; 3],
bool,
bool,
Species,
Option<i64>,
Option<u32>,
u32,
)> = self
.mobs
.iter()
.filter(|m| m.alive())
@@ -1627,10 +1636,11 @@ impl Game<'_> {
m.species(),
m.last_damage.map(|(_, t)| t),
m.extra.mount,
m.age,
)
})
.collect();
for (id, at, on_ground, in_water, species, damaged, vehicle) in snapshot {
for (id, at, on_ground, in_water, species, damaged, vehicle, age) in snapshot {
if on_ground {
self.step_on_redstone_ore(at, false);
}
@@ -1640,6 +1650,19 @@ impl Game<'_> {
let emits = !matches!(species, Species::Shulker);
if emits {
self.entity_motion_events(Who::Mob(id), at, on_ground, in_water, false, false);
// Entity.processFlappingMovement with Bee.isFlapping: a bee off
// the ground over air flaps on every other tick (the mob's own
// `MobEvent::GameEvent` is not read here).
if species == Species::Bee && !on_ground && age.is_multiple_of(2) {
let below = self.world.block(
at[0].floor() as i32,
(at[1] - 1.0e-5).floor() as i32,
at[2].floor() as i32,
);
if self.res.facts().is_air(below) {
self.post_entity_event("minecraft:flap", Who::Mob(id));
}
}
}
if let Some(t) = damaged {
let seen = self.sculk.damaged.insert(id, t);
@@ -201,6 +201,9 @@ pub(super) fn saves() -> PathBuf {
std::env::current_dir().unwrap_or_default().join("saves")
}
/// `WorldOpenFlows.WORLD_PACK_ID`: the id of a world's own resource pack.
const WORLD_PACK_ID: u128 = 0x640a6a92_b6cb_48a0_b391_831586500359;
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
const RELOAD_LINES: [&str; 4] = [
"Preparing resources",
@@ -217,6 +220,19 @@ pub(super) struct PackReload {
}
impl Game<'_> {
/// `WorldOpenFlows.loadBundledResourcePack`: a world's own
/// `resourcepacks/resources.zip` is pushed as a local pack before the world
/// connects, after `configureForLocalWorld` so the pack is accepted at once.
pub(super) fn server_packs_world_pack(&mut self, folder: &str) {
let path = saves()
.join(folder)
.join("resourcepacks")
.join("resources.zip");
if path.is_file() {
self.server_packs.packs.local_world();
self.server_packs.packs.push_local(WORLD_PACK_ID, path);
}
}
/// `ConnectScreen`: the server's setting decides what its packs do
/// (`configureForServerControl`); single player's own server takes them
/// all, quietly (`configureForLocalWorld`).
+124 -7
View File
@@ -1,12 +1,16 @@
//! The sign editing screen (`SignEditScreen` / `HangingSignEditScreen`):
//! opened when a sign is placed, four lines limited to the sign's line
//! width, Up/Down/Enter move between lines, Escape or Done closes.
use super::{Game, lan::LanHost};
use super::{
Game,
commands::{Target, execute::Source},
lan::LanHost,
};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
items::ItemStack,
signs::{DYE_TEXT, wood_type},
signs::{ClickAction, DYE_TEXT, click_action, wood_type},
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
@@ -90,11 +94,124 @@ impl Game<'_> {
let diff = if diff >= 180.0 { 360.0 - diff } else { diff };
diff <= 90.0
}
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed
/// sign plays the failed-interaction sound; otherwise a player who may
/// build edits the side they face.
/// `SignBlockEntity.canExecuteClickCommands` for the side the acting
/// player faces: a waxed sign with a `run_command` click event on it.
pub(super) fn sign_can_execute_click_commands(&self, pos: (i32, i32, i32)) -> bool {
// Only a waxed sign can; skip the facing maths for everything else.
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&pos) else {
return false;
};
sign.waxed && sign.can_execute_click_commands(self.facing_sign_front(pos))
}
/// `SignBlockEntity.createCommandSourceStack`: the acting player runs the
/// command from the middle of the sign's block, rotation zero, at the
/// game master level, with `CommandSource.NULL` (no feedback, no failure
/// messages).
fn sign_command_source(&self, pos: [i32; 3]) -> Source {
Source {
entity: self.player.is_some().then_some(Target::Player),
pos: pos.map(|v| f64::from(v) + 0.5),
rot: [0.0, 0.0],
eyes: false,
silent: true,
store: None,
dimension: self.dim,
level: None,
block: None,
}
}
/// Run a sign's `run_command` line as the prefixed command vanilla runs
/// it as: a leading slash is allowed, errors are not reported (the NULL
/// source).
fn run_sign_command(&mut self, pos: [i32; 3], command: &str) {
let source = self.sign_command_source(pos);
let line = command.strip_prefix('/').unwrap_or(command);
self.exec = Default::default();
let permission = std::mem::replace(&mut self.ui.registries.permission, 2);
let _ = self.execute_line(line, source);
self.ui.registries.permission = permission;
// InventoryChangeTrigger fires as slots change, not on the next tick.
if self.player.is_some() {
self.check_inventory_trigger();
}
}
/// `SignBlockEntity.executeClickCommandsIfPresent`: every line of the
/// side whose style has a `run_command`, `show_dialog` or `custom` click
/// event runs it as the acting player when the sign allows op features;
/// otherwise the player is told click actions are disabled. Whether any
/// line had one.
fn execute_sign_click_commands(&mut self, pos: [i32; 3], front: bool) -> bool {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return false;
};
let allow_op_features = sign.allow_op_features;
let side = if front { &sign.front } else { &sign.back };
// The side as it was when the loop began: a command may change it.
let events: Vec<_> = side.click.iter().flatten().cloned().collect();
let mut any_click_command = false;
for event in &events {
match click_action(event) {
Some(ClickAction::RunCommand(command)) => {
if allow_op_features {
self.run_sign_command(pos, &command);
}
any_click_command = true;
}
Some(ClickAction::ShowDialog(dialog)) => {
if allow_op_features {
self.show_click_dialog(dialog);
}
any_click_command = true;
}
// MinecraftServer.handleCustomClickAction only logs it.
Some(ClickAction::Custom { id, payload }) => {
if allow_op_features {
log::debug!("Received custom click action {id} with payload {payload:?}");
}
any_click_command = true;
}
None => {}
}
}
if !allow_op_features && any_click_command {
self.sign_click_actions_disabled();
}
any_click_command
}
/// `Player.sendOverlayMessage(CLICK_ACTIONS_DISABLED)`: red text on the
/// action bar. A guest gets a packet, so its own client translates it.
fn sign_click_actions_disabled(&mut self) {
const KEY: &str = "sign.click_actions_disabled";
if self.lan_acting.is_some() {
let message = horizon_client::text::Text::translate(KEY, &[])
.with_color(horizon_client::text::RED);
self.guest_packets.push(C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
}));
} else {
self.message = Some((
self.translate(KEY, "Click actions are disabled for this sign"),
std::time::Instant::now(),
));
}
}
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): the side
/// the player faces runs its click commands first; then a waxed sign
/// plays the failed-interaction sound, a sign that ran one does nothing
/// more, and otherwise a player who may build edits the side they face.
pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Sign(_))
) {
return;
}
let front = self.facing_sign_front(key);
let executed = self.execute_sign_click_commands(pos, front);
// The commands may have changed the sign, or removed it.
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return;
};
@@ -107,8 +224,7 @@ impl Game<'_> {
};
self.audio
.play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0);
} else if self.may_build() {
let front = self.facing_sign_front(key);
} else if !executed && self.may_build() {
self.open_sign_editor(key, front);
}
}
@@ -183,6 +299,7 @@ impl Game<'_> {
}
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.mark_block_entity_updated(key);
self.execute_sign_click_commands(pos, front);
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event(
"minecraft:block_change",
@@ -0,0 +1,259 @@
//! `AbstractThrownPotion`: what a splash or lingering potion does around where
//! it lands besides its effects. A potion in `#minecraft:douses_fire` puts out
//! the fire, candles and campfires next to the block it hit; one in
//! `#minecraft:hurts_water_sensitive_entities` hurts the water-sensitive mobs
//! near it and one in `#minecraft:extinguishes_entities` puts out burning
//! entities. Only plain water is in those tags in vanilla.
use super::Game;
use horizon_client::{
items::ItemStack,
mobs::{Damage, Species, Who},
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
};
use horizon_world::{chunk::BlockState, registry::BlockStateId};
/// The block a thrown potion hit and the face it hit it on, as the
/// `BlockHitResult` of `ProjectileUtil.getHitResultOnMoveVector`: `end` is a
/// point of the path inside the block, `start` where the step began. The face
/// is the one the path enters the block cube through, or, when the step began
/// inside the block, the one facing back along the motion.
pub(super) fn thrown_block_hit(start: Vec3, end: Vec3, velocity: Vec3) -> ([i32; 3], Direction) {
let cell = [end.x, end.y, end.z].map(|v| v.floor() as i32);
let face = Aabb::clip_boxes(
[Aabb::unit_cube(Vec3::ZERO)],
start,
end,
BlockPos::new(cell[0], cell[1], cell[2]),
)
.map_or_else(
|| Direction::approximate_nearest(velocity.reverse()),
|hit| hit.direction,
);
(cell, face)
}
impl Game<'_> {
/// `PotionContents.is`: the potion of `stack` is in the potion tag `tag`
/// (`minecraft:douses_fire` and the others, read from the data pack's
/// `tags/potion`). A stack with no potion is in no tag.
pub(super) fn potion_in_tag(&self, stack: &ItemStack, tag: &str) -> bool {
stack.potion().is_some_and(|potion| {
self.res
.stations()
.trades
.potion_tags
.get(tag)
.is_some_and(|members| members.iter().any(|m| m.as_str() == potion))
})
}
/// `AbstractThrownPotion.onHitBlock` on the server: a potion (the stack's
/// `potion_contents`) in `#douses_fire` douses the cell in front of the
/// face it hit, the cell behind that (the block it hit) and the four
/// horizontal neighbours of the cell in front. `hit` is the block and
/// `face` the face it was hit on.
pub(super) fn potion_hit_block(&mut self, stack: &ItemStack, hit: [i32; 3], face: Direction) {
// `PotionContents.is(PotionTags.DOUSES_FIRE)` on the stack's `potion_contents`.
if !self.potion_in_tag(stack, "minecraft:douses_fire") {
return;
}
let relative = |pos: [i32; 3], direction: Direction| {
let n = direction.normal();
[pos[0] + n[0], pos[1] + n[1], pos[2] + n[2]]
};
let effect = relative(hit, face);
self.douse_fire(effect);
self.douse_fire(relative(effect, face.opposite()));
// Direction.Plane.HORIZONTAL.
for direction in [
Direction::North,
Direction::East,
Direction::South,
Direction::West,
] {
self.douse_fire(relative(effect, direction));
}
}
/// `AbstractThrownPotion.douseFire`: a fire block is destroyed without
/// drops, a lit candle or candle cake goes out, and a lit campfire goes
/// out after level event 1009 and its block-change game event.
fn douse_fire(&mut self, pos: [i32; 3]) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let state = self.world.block(pos[0], pos[1], pos[2]);
let name = self.res.facts().get(state).name.clone();
let fire = self.block_in_tag("minecraft:fire", &name);
if fire {
// Level.destroyBlock(pos, false, this).
self.with_level(|b, level, ticks| b.destroy_block(level, ticks, at, false));
}
let candle =
!fire && self.with_level(|b, level, ticks| b.extinguish_candle(level, ticks, at));
if !fire
&& !candle
&& let Some(mut lit) = self.lit_campfire(state, &name)
{
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the block-change game event, whose source
// is the potion's owner (not tracked here).
self.post_block_game_event("minecraft:block_change", pos, None, None);
lit.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = self.pack.assets.registry.resolve(&lit) {
let _ = self.level_set(pos, unlit);
}
}
let _ = self.process_level_events();
}
/// `CampfireBlock.isLitCampfire`: the state of a lit block in
/// `#minecraft:campfires`.
fn lit_campfire(&self, state: BlockStateId, name: &str) -> Option<BlockState> {
if !self.block_in_tag("minecraft:campfires", name) {
return None;
}
self.pack
.assets
.registry
.state(state)
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
}
/// `AbstractThrownPotion.affectEntitiesAround`: the living entities whose
/// box meets the potion's box grown by (4, 2, 4) and that are less than 4
/// blocks from it. A potion in `#hurts_water_sensitive_entities` hurts
/// the water-sensitive ones for 1 (`indirectMagic`, the owner being a
/// player when `by_player`), an Enderman then trying to teleport
/// (`Enderman.hurtServer`); one in `#extinguishes_entities` puts out
/// those that burn; one in `#rehydrates_axolotls` refills the air of
/// the axolotls in the box.
pub(super) fn affect_entities_around(&mut self, stack: &ItemStack, at: Vec3, by_player: bool) {
let hurts = self.potion_in_tag(stack, "minecraft:hurts_water_sensitive_entities");
let extinguishes = self.potion_in_tag(stack, "minecraft:extinguishes_entities");
// The potion's box is 0.25 wide and high.
let reach = Aabb::new(
Vec3::new(at.x - 0.125, at.y, at.z - 0.125),
Vec3::new(at.x + 0.125, at.y + 0.25, at.z + 0.125),
)
.inflate(Vec3::new(4.0, 2.0, 4.0));
let near = |p: Vec3| {
let (dx, dy, dz) = (p.x - at.x, p.y - at.y, p.z - at.z);
dx * dx + dy * dy + dz * dz < 16.0
};
if hurts || extinguishes {
let source =
Damage::indirect_magic(by_player.then_some(Who::Player), [at.x, at.y, at.z]);
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
// WATER_SENSITIVE_OR_ON_FIRE.
let sensitive = m.kind.flags.water_sensitive;
if !(sensitive || m.is_on_fire())
|| !reach.intersects(m.bounds())
|| !near(m.position)
{
continue;
}
if hurts && sensitive {
let invulnerable = m.invulnerable_flag;
m.damage(1.0, source);
// `Enderman.hurtServer` for a thrown potion: the hit is
// `Enderman.hurtWithCleanWater`, then
// `Enderman.repeatedlyTryToTeleport`, which does nothing
// while it rides or once it is dead (`teleport` needs
// `isAlive`) and which the AI step runs.
if m.species() == Species::Enderman
&& !invulnerable
&& m.alive()
&& !m.extra.passenger
{
m.extra.counter2 = 64;
}
}
if extinguishes && m.is_on_fire() && m.alive() {
m.extinguish_fire();
}
}
}
if extinguishes {
self.for_each_player(|g| g.extinguish_player_in_splash(reach, at));
// Armor stands are living entities too.
let mut put_out = Vec::new();
for stand in self.armor_stands.iter_mut() {
if stand.fire_ticks > 0
&& stand.health > 0.5
&& reach.intersects(stand.bounds())
&& near(stand.position)
{
stand.fire_ticks = 0;
put_out.push([stand.position.x, stand.position.y, stand.position.z]);
}
}
for stand_at in put_out {
self.extinguish_sound(stand_at);
}
}
if self.potion_in_tag(stack, "minecraft:rehydrates_axolotls") {
// Axolotl.rehydrate: 1800 more air, up to the maximum.
let (lo, hi) = (
[at.x - 0.125 - 4.0, at.y - 2.0, at.z - 0.125 - 4.0],
[at.x + 0.125 + 4.0, at.y + 0.25 + 2.0, at.z + 0.125 + 4.0],
);
for m in self
.mobs
.iter_mut()
.filter(|m| m.species() == Species::Axolotl && !m.removed)
{
let (w, h, _) = m.dims();
let half = w / 2.0;
let (x, y, z) = (m.position.x, m.position.y, m.position.z);
if x - half < hi[0]
&& x + half > lo[0]
&& y < hi[1]
&& y + h > lo[1]
&& z - half < hi[2]
&& z + half > lo[2]
{
m.air = (m.air + 1800).min(horizon_client::mobs::axolotl::MAX_AIR);
}
}
}
}
/// `Entity.extinguishFire` for the player in place (the host's or a
/// guest's) when it burns, is alive and is in a splash's reach: the
/// extinguish sound, then the fire out.
fn extinguish_player_in_splash(&mut self, reach: Aabb, at: Vec3) {
let Some(p) = self.player.as_ref() else {
return;
};
let w = f64::from(p.dimensions.width) / 2.0;
let bounds = Aabb::new(
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
Vec3::new(
p.position.x + w,
p.position.y + f64::from(p.dimensions.height),
p.position.z + w,
),
);
let (dx, dy, dz) = (
p.position.x - at.x,
p.position.y - at.y,
p.position.z - at.z,
);
if p.fire_ticks <= 0
|| p.health.dead_or_dying()
|| !reach.intersects(bounds)
|| dx * dx + dy * dy + dz * dz >= 16.0
{
return;
}
let feet = [p.position.x, p.position.y, p.position.z];
self.extinguish_sound(feet);
if let Some(p) = self.player.as_mut() {
let ticks = p.fire_ticks.min(0);
p.set_remaining_fire_ticks(ticks);
}
}
}
+20 -9
View File
@@ -149,9 +149,12 @@ impl StartupMenu {
)
}
fn title_key(&mut self, event: &KeyEvent) {
/// `Screen.keyPressed` on the title screens: the action a key starts (Enter confirms, see `confirm_key` in title.rs).
fn title_key(&mut self, event: &KeyEvent) -> Action {
let shortcut = self.shortcut(event);
let Some(title) = &mut self.title else { return };
let Some(title) = &mut self.title else {
return Action::None;
};
if let Some(shortcut) = shortcut {
if title.has_caret_field() {
title.field_shortcut(shortcut, &mut self.clipboard);
@@ -164,13 +167,16 @@ impl StartupMenu {
{
title.search_changed();
}
return;
return Action::None;
}
match event.physical_key {
PhysicalKey::Code(KeyCode::Escape) => title.escape(),
PhysicalKey::Code(KeyCode::F5) => title.refresh_servers(),
PhysicalKey::Code(KeyCode::Tab) => title.tab(),
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => title.confirm_edit(),
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => return title.confirm_key(),
PhysicalKey::Code(KeyCode::Space) if title.list_focused() => {
return title.confirm_key();
}
PhysicalKey::Code(
key @ (KeyCode::Backspace
| KeyCode::Delete
@@ -202,6 +208,7 @@ impl StartupMenu {
}
}
}
Action::None
}
}
@@ -244,6 +251,7 @@ pub(super) fn spawn_loader(
&settings,
started,
true,
false,
&progress,
&mut |gui, root| {
let _ = sender.send(LoaderMsg::Ui(gui, root.to_path_buf()));
@@ -603,14 +611,17 @@ impl Loading {
} else {
raw
} * self.settings.mouse_wheel_sensitivity;
let batch = HudBatch::new(self.menu_size());
let size = self.menu_size();
let batch = HudBatch::new(size);
if self.menu.options_open {
self.menu.options.scroll_at(
&self.settings,
let events = self.menu.options.wheel(
&mut self.settings,
(batch.width, batch.height),
self.menu.cursor,
[size[0] as i32, size[1] as i32],
amount,
);
self.menu_option_events(events);
} else if let Some(title) = &mut self.menu.title {
title.scroll_worlds(amount, self.menu.cursor, (batch.width, batch.height));
}
@@ -638,8 +649,8 @@ impl Loading {
self.menu_option_events(events);
}
} else {
self.menu.title_key(&event);
self.save_menu_address();
let action = self.menu.title_key(&event);
self.menu_action(event_loop, action);
}
}
_ => {}
+8 -3
View File
@@ -2488,12 +2488,17 @@ impl Game<'_> {
}
/// Hovered-slot tooltip on the plain inventory, crafting and container screens.
pub(super) fn draw_slot_tooltip(&mut self, batch: &mut HudBatch) {
if self.carried.is_some() {
return;
}
let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) else {
return;
};
// The recipe book's tooltip is drawn after the slot's own, so a ghost wins.
if let Some(ghost) = self.ghost_stack(slot) {
self.draw_stack_tooltip(batch, &ghost, self.cursor.0, self.cursor.1);
return;
}
if self.carried.is_some() {
return;
}
let stack = self.slot_view(slot);
if let Some(stack) = stack {
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
@@ -275,6 +275,8 @@ impl Game<'_> {
let scroll = self.stats_ui.scroll.amount;
let row_y = |i: usize| top + 4.0 + i as f32 * row_h - scroll;
let visible = |y: f32| y >= top && y + row_h <= bottom;
// The hovered item's tooltip, drawn over the list.
let mut tip = None;
match self.stats_ui.tab {
0 => {
let mut sorted: Vec<(Text, String)> = CUSTOM
@@ -348,6 +350,10 @@ impl Game<'_> {
y + 1.0,
);
self.draw_item(batch, &stack, left + 2.0, y + 2.0);
// `ItemRowWidget.extractTooltip`: the item's tooltip at the slot's corner.
if ui::hovered(self.cursor, left + 1.0, y + 1.0, 18.0, 18.0) {
tip = Some((stack.clone(), left + 19.0, y + 19.0));
}
let atlas = &self.gui.atlas;
let color = if (i + 1) % 2 == 0 {
u32::MAX
@@ -424,6 +430,9 @@ impl Game<'_> {
self.stats_ui.scroll.amount,
);
self.draw_stats_footer(batch, w, h);
if let Some((stack, x, y)) = tip {
self.draw_stack_tooltip(batch, &stack, x, y);
}
}
fn draw_stats_footer(&self, batch: &mut HudBatch, w: f32, h: f32) {
let atlas = &self.gui.atlas;
+66 -25
View File
@@ -10,8 +10,8 @@ use horizon_client::{
Who,
spawner::SpawnerSpawn,
trial_spawner::{
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
TrialSpawnerExt, TrialSpawnerLevel, TrialState, Uuid,
TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner, TrialSpawnerExt,
TrialSpawnerLevel, TrialState, Uuid,
},
},
};
@@ -41,8 +41,7 @@ impl TrialSpawnerLevel for GameLevel<'_, '_> {
position: g.position,
height: g.height,
alive: g.alive,
// Guests carry no effects yet.
omen: None,
omen: g.omen,
});
let (Some(p), Some(view)) = (self.game.player.as_ref(), self.game.player_view()) else {
return guests.collect();
@@ -56,11 +55,7 @@ impl TrialSpawnerLevel for GameLevel<'_, '_> {
position: [p.position.x, p.position.y, p.position.z],
height: p.dimensions.height,
alive: self.game.level.health > 0.0,
omen: if self.game.potions.effects.get("trial_omen").is_some() {
Some(Omen::Trial)
} else {
self.game.potions.effects.get("bad_omen").map(|_| Omen::Bad)
},
omen: super::lan_mobs::omen_of(&self.game.potions.effects),
};
std::iter::once(host).chain(guests).collect()
}
@@ -252,7 +247,7 @@ impl Game<'_> {
let who = by_uuid.get(&uuid).map(|id| Who::Mob(*id));
self.post_block_game_event("minecraft:entity_place", at, who, None);
}
TrialEvent::TrialOmen(_) => self.bad_omen_to_trial_omen(),
TrialEvent::TrialOmen(uuid) => self.bad_omen_to_trial_omen(uuid),
TrialEvent::Discard(uuid) => {
let id = self.persist.uuids.iter().find(|(_, u)| **u == uuid);
if let Some((id, _)) = id
@@ -297,22 +292,27 @@ impl Game<'_> {
self.level_set([key.0, key.1, key.2], id)
}
/// `transformBadOmenIntoTrialOmen`: Bad Omen becomes Trial Omen, 15
/// minutes per level, with Trial Omen's sound when it is new.
fn bad_omen_to_trial_omen(&mut self) {
let Some(amplifier) = self.potions.effects.amplifier("bad_omen") else {
return;
/// minutes per level, with Trial Omen's sound when it is new. `uuid` is
/// the player found: the one in the game's frame, else a guest.
fn bad_omen_to_trial_omen(&mut self, uuid: Uuid) {
let eye = if self.acting_uuid() == uuid {
transform_bad_omen(&mut self.potions.effects)
.then(|| self.eye_position().unwrap_or_default())
} else {
let acting = self.lan_acting;
let Some(guest) = self.lan.as_mut().and_then(|h| {
h.hub.guests.iter_mut().find(|g| {
horizon_client::lan::playerdata::uuid_ints(g.uuid) == uuid
&& Some(g.conn) != acting
})
}) else {
return;
};
let [x, y, z] = guest.position;
let eye = [x, y + guest.eye_height(), z];
transform_bad_omen(&mut guest.effects).then_some(eye)
};
self.potions.effects.list.retain(|e| e.id != "bad_omen");
let new = self.potions.effects.get("trial_omen").is_none();
self.potions
.effects
.add(horizon_client::effects::EffectInstance::new(
"trial_omen",
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1),
0,
));
if new {
let eye = self.eye_position().unwrap_or_default();
if let Some(eye) = eye {
self.audio
.play("minecraft:event.mob_effect.trial_omen", Some(eye), 1.0, 1.0);
}
@@ -325,3 +325,44 @@ impl Game<'_> {
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
}
/// Swap Bad Omen for Trial Omen on `effects`; true when Trial Omen is new.
fn transform_bad_omen(effects: &mut horizon_client::effects::PlayerEffects) -> bool {
let Some(amplifier) = effects.amplifier("bad_omen") else {
return false;
};
effects.list.retain(|e| e.id != "bad_omen");
let new = effects.get("trial_omen").is_none();
effects.add(horizon_client::effects::EffectInstance::new(
"trial_omen",
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1),
0,
));
new
}
#[cfg(test)]
mod omen_tests {
use super::*;
use horizon_client::{
effects::{EffectInstance, PlayerEffects},
mobs::trial_spawner::Omen,
};
#[test]
fn bad_omen_becomes_trial_omen_per_level() {
let omen_of = super::super::lan_mobs::omen_of;
let mut e = PlayerEffects::default();
assert_eq!(omen_of(&e), None);
e.add(EffectInstance::new("bad_omen", 100, 1));
assert_eq!(omen_of(&e), Some(Omen::Bad));
assert!(transform_bad_omen(&mut e));
assert!(e.get("bad_omen").is_none());
assert_eq!(
e.get("trial_omen").unwrap().duration,
TRIAL_OMEN_TICKS_PER_LEVEL * 2
);
assert_eq!(omen_of(&e), Some(Omen::Trial));
assert!(!transform_bad_omen(&mut e));
}
}
+2 -2
View File
@@ -94,8 +94,8 @@ impl Game<'_> {
} else if slot >= Creative::DISPLAY {
// An item tab's display slot shows the tab's list.
let first = self.creative_screen.first_row(&self.pack.names);
let items = self.creative_screen.items(&self.pack.names);
items.get(first * 9 + slot - Creative::DISPLAY).cloned()
self.creative_screen
.shown(&self.pack.names, first * 9 + slot - Creative::DISPLAY)
} else {
self.slot_mut(slot).clone()
};
@@ -236,6 +236,11 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
} else if path.ends_with("_banner_pattern") {
// The item name component of the pattern items that have one.
Some(format!("item.minecraft.{path}.new"))
} else if &*stack.item == "minecraft:shield"
&& let Some(color) = stack.data().and_then(|d| d.base_color.as_deref())
{
// `ShieldItem.getName`: the banner's base colour names the shield.
Some(format!("item.minecraft.shield.{color}"))
} else {
None
};
+3 -2
View File
@@ -797,8 +797,9 @@ fn login_and_configure(
channel: "minecraft:brand".into(),
data: {
let mut w = horizon_protocol::buf::Writer::new();
// MinecraftServer.getServerModName.
w.string("vanilla");
// MinecraftServer.getServerModName: "vanilla" there; a modded
// server names itself, and Horizon's says "horizon", as its client does.
w.string("horizon");
w.data
},
}),
+47 -7
View File
@@ -202,6 +202,11 @@ pub struct Hub {
/// `spawn-protection` (dedicated servers): the radius around the world
/// spawn that only operators may change.
pub spawn_protection: i32,
/// `chat-spam-threshold-seconds` as a TickThrottler threshold (20 a second):
/// over it, a guest who is not an operator is kicked. 0 never kicks.
pub chat_spam_threshold: i32,
/// `command-spam-threshold-seconds`, as `chat_spam_threshold`.
pub command_spam_threshold: i32,
chat_type: i32,
dimension_type: i32,
clock_ids: Vec<i32>,
@@ -373,6 +378,8 @@ impl Hub {
ops: Default::default(),
entity_range_percent: 100,
spawn_protection: 0,
chat_spam_threshold: 0,
command_spam_threshold: 0,
weather: (false, 0.0, 0.0),
level_sync: Default::default(),
chat_type,
@@ -621,7 +628,7 @@ impl Hub {
latency: Some(latency),
display_name: Some(None),
list_order: Some(0),
show_hat: Some(true),
show_hat: Some(guest.is_none_or(|g| g.info.model_customisation & 0x40 != 0)),
}
}
/// ClientboundPlayerInfoUpdatePacket.createPlayerInitializing: every action.
@@ -2099,6 +2106,15 @@ impl Hub {
d > f64::from(1.0E-5_f32)
}
/// A move still queued for guest `i`, which a packet the game handles
/// after this pass must not overtake.
fn move_queued(&self, i: usize) -> bool {
self.guests[i]
.inbox
.iter()
.any(|p| matches!(p, S::MovePlayerPos(_) | S::MovePlayerPosRot(_)))
}
fn process_inbox(&mut self, i: usize, ctx: &mut TickCtx, events: &mut Vec<HostEvent>) {
// ServerGamePacketListenerImpl.tickPlayer ran between the last
// tick's packets and these: resetPosition, and the moves it counted.
@@ -2208,10 +2224,25 @@ impl Hub {
}
S::ClientInformation(info) => {
let g = &mut self.guests[i];
// PlayerModelPart.HAT (bit 6): the tab list shows the hat layer.
let was_hat = g.info.model_customisation & 0x40 != 0;
g.view_distance =
i32::from(info.view_distance).clamp(2, self.config.view_distance);
g.info = info;
g.center = None;
let hat = g.info.model_customisation & 0x40 != 0;
let uuid = g.uuid;
// ServerGamePacketListenerImpl.handleClientInformation: UPDATE_HAT to everyone.
if hat != was_hat {
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: 128,
entries: vec![play::PlayerInfo {
uuid,
show_hat: Some(hat),
..Default::default()
}],
}));
}
}
S::PlayerInput(p) => {
self.guests[i].input = p.flags;
@@ -2245,11 +2276,19 @@ impl Hub {
// and the move.
S::PlayerAction(p) if p.action <= 3 => {
events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p))));
if self.guests[i]
.inbox
.iter()
.any(|p| matches!(p, S::MovePlayerPos(_) | S::MovePlayerPosRot(_)))
{
if self.move_queued(i) {
break;
}
}
// handleUseItemOn is the game's too, and BlockItem.canPlace checks
// the player's box where they clicked from (Level.isUnobstructed), as
// their client's prediction did. After the move behind it, a player
// jumping up a pillar was already clear of the cell their client had
// refused: the block went down without the place sound that only the
// placer's client plays (BlockItem.place's playSound(player, ...)).
S::UseItemOn(p) => {
events.push(HostEvent::Packet(conn, Box::new(S::UseItemOn(p))));
if self.move_queued(i) {
break;
}
}
@@ -2790,7 +2829,8 @@ pub fn guest_body(g: &Guest) -> BodyFlags {
crouching: m.is_some_and(|m| m.pose == crate::pose::Pose::Crouching),
spin_attack: m.is_some_and(|m| m.auto_spin_attack_ticks > 0),
on_fire: m.is_some_and(|m| m.fire_ticks > 0),
invisible: g.effects.get("invisibility").is_some(),
// ServerPlayer.updateInvisibilityStatus: a spectator is always invisible.
invisible: g.effects.get("invisibility").is_some() || g.spectator(),
glowing: g.effects.get("glowing").is_some(),
}
}
+29
View File
@@ -69,6 +69,15 @@ pub enum SceneSound {
BigFall,
}
impl SceneSound {
/// The sound event of `LivingEntity.getFallDamageSound` for a player, if
/// this is one of the two fall sounds.
pub fn player_fall_sound(self) -> Option<&'static str> {
match self {
Self::BigFall => Some("minecraft:entity.player.big_fall"),
Self::SmallFall => Some("minecraft:entity.player.small_fall"),
Self::Block(_) => None,
}
}
fn name(self) -> &'static str {
match self {
Self::SmallFall => "small",
@@ -529,3 +538,23 @@ impl LivingFallWorld for FallCall<'_, '_, '_, '_> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fall_sounds_map_to_player_events() {
assert_eq!(
SceneSound::BigFall.player_fall_sound(),
Some("minecraft:entity.player.big_fall")
);
assert_eq!(
SceneSound::SmallFall.player_fall_sound(),
Some("minecraft:entity.player.small_fall")
);
assert_eq!(
SceneSound::Block(LandingSound::HoneyFall).player_fall_sound(),
None
);
}
}
+4 -4
View File
@@ -12,10 +12,10 @@ pub use horizon_client_render::{
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hud, hud_layout,
hud_overlays, inventory_effects, java_text, level_loading, light, lod, logging, menu_blur,
mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, providers, screen_draw,
screen_effects, sculk, settings, shader_pack_list, signs, skins, sound_engine, sound_events,
spans, split_layout, sprites, streaming, tab_list, telemetry, testgen, title_edition, trims,
ui, vault, weather, widgets, world, worldborder, zip,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, potent_sulfur,
providers, screen_draw, screen_effects, sculk, settings, shader_pack_list, signs, skins,
sound_engine, sound_events, spans, split_layout, sprites, streaming, tab_list, telemetry,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+39 -1
View File
@@ -530,6 +530,9 @@ pub enum DamageKind {
/// `dry_out`: an axolotl out of water for too long.
DryOut,
Magic,
/// `indirect_magic`: a thrown potion's hit (`DamageSources.indirectMagic`),
/// which, unlike `magic`, knocks back.
IndirectMagic,
Sting,
Cactus,
/// `sweet_berry_bush`.
@@ -618,6 +621,16 @@ impl Damage {
from: Some(from),
}
}
/// `DamageSources.indirectMagic(potion, owner)`: `attacker` is the owner
/// (none for a dispenser's potion), `from` the potion's position, which
/// the knockback pushes away from.
pub fn indirect_magic(attacker: Option<Who>, from: [f64; 3]) -> Self {
Self {
kind: DamageKind::IndirectMagic,
attacker,
from: Some(from),
}
}
pub fn mob(id: u32, from: [f64; 3]) -> Self {
Self {
kind: DamageKind::MobAttack,
@@ -654,6 +667,7 @@ impl Damage {
| DamageKind::OnFire
| DamageKind::Drown
| DamageKind::Magic
| DamageKind::IndirectMagic
| DamageKind::InWall
| DamageKind::Generic
| DamageKind::SonicBoom
@@ -1684,6 +1698,23 @@ impl Mob {
.max((f64::from(ticks) * self.burning_time()).ceil() as i32);
self.ticks_frozen = 0;
}
/// `Entity.extinguishFire`: a burning mob first plays the extinguish sound
/// (`playEntityOnFireExtinguishedSound`: volume 0.7, pitch from two draws
/// of its random, at its feet), then `clearFire` keeps only the
/// fire-immune countdown.
pub fn extinguish_fire(&mut self) {
if self.is_on_fire() {
let pitch = 1.6 + (self.random.next_float() - self.random.next_float()) * 0.4;
let at = [self.position.x, self.position.y, self.position.z];
self.outbox.push(MobEvent::Sound(
"entity.generic.extinguish_fire",
at,
0.7,
pitch,
));
}
self.fire_ticks = self.fire_ticks.min(0);
}
/// `Entity.isFullyFrozen`.
pub fn is_fully_frozen(&self) -> bool {
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
@@ -2283,7 +2314,10 @@ impl Mob {
amount = 0.0;
} else if matches!(
source.kind,
DamageKind::Magic | DamageKind::SonicBoom | DamageKind::Thorns
DamageKind::Magic
| DamageKind::IndirectMagic
| DamageKind::SonicBoom
| DamageKind::Thorns
) {
amount *= 0.15;
}
@@ -2372,6 +2406,9 @@ impl Mob {
if equine::is_equine(self.species()) {
equine::after_hurt_server(self);
}
if self.species() == Species::GlowSquid {
aquatic::hurt_server(self);
}
true
}
/// A splash or cloud's harming effect on this mob: the potion's
@@ -2393,6 +2430,7 @@ impl Mob {
source.kind,
DamageKind::Drown
| DamageKind::Magic
| DamageKind::IndirectMagic
| DamageKind::InWall
| DamageKind::Thorns
| DamageKind::OutOfWorld
+14 -1
View File
@@ -234,7 +234,7 @@ pub fn register(m: &mut Mob) {
Family::Generic if m.species() == Species::Armadillo => super::armadillo::register(m),
Family::Generic if m.species() == Species::Frog => super::frog::register(m),
Family::Generic if m.species() == Species::Fox => super::fox::register(m),
Family::Generic if m.species() == Species::Parrot => super::tame::register_parrot(m),
Family::Generic if m.species() == Species::Parrot => super::parrot::register(m),
Family::Generic if m.species() == Species::Rabbit => super::rabbit::register(m),
Family::Generic if m.species() == Species::Ocelot => super::tame::register_ocelot(m),
Family::Generic => {
@@ -1409,6 +1409,7 @@ fn species_ai_step(
}
Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events),
Species::Chicken => farm::chicken_ai_step(m, w, events),
Species::Parrot => super::parrot::calculate_flapping(m),
Species::Armadillo => super::armadillo::tick(m),
Species::Frog => super::frog::tick(m),
Species::Goat => super::goat::tick(m),
@@ -2014,11 +2015,23 @@ pub fn ate(m: &mut Mob) {
}
}
}
/// `NeutralMob.playerDied` for a mob that is neutral: the anger at the
/// player who died ends. The caller checks `forgive_dead_players`.
pub fn forgive_dead_player(m: &mut Mob) {
match m.species() {
Species::Bee => super::bee::forgive_dead_player(m),
Species::PolarBear => super::polar_bear::forgive_dead_player(m),
_ => super::tame::forgive_dead_player(m),
}
}
/// Species-specific travel (fish, squid, dolphins swim with their own rules).
pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>) -> bool {
if m.species() == Species::Drowned {
return undead::drowned_travel(m, w, input, events);
}
if m.species() == Species::Turtle {
return super::turtle::travel(m, w, input, events);
}
match family(m.species()) {
Family::Aquatic => aquatic::travel(m, w, input, events),
Family::Guardian => super::guardian::travel(m, w, input, events),
+66 -2
View File
@@ -12,6 +12,7 @@ use super::{
nav::NavKind,
};
use crate::blocks::Fluid;
use crate::particles::ParticleType;
use horizon_core::geometry::trig::Mth;
use horizon_core::{geometry::Vec3, random::RandomSource};
use std::f32::consts::PI;
@@ -206,7 +207,11 @@ pub fn puff_scale(state: i32) -> f64 {
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
use Species as S;
match m.species() {
S::Squid | S::GlowSquid => squid_ai_step(m),
S::Squid => squid_ai_step(m),
S::GlowSquid => {
squid_ai_step(m);
glow_squid_ai_step(m, events);
}
S::Tadpole => {
// Tadpole.aiStep: growing up into a frog after 24000 ticks.
if m.extra.age >= 0 && !m.extra.age_locked {
@@ -312,11 +317,70 @@ fn squid_ai_step(m: &mut Mob) {
/// Squid.aiStep also runs on remote entities: animate from packet velocity,
/// without running goals or replacing that velocity with the server's movement vector.
pub fn squid_client_step(m: &mut Mob) {
/// A glow squid's own `aiStep` follows it (the dark timer and the glow).
pub fn squid_client_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
if matches!(m.species(), Species::Squid | Species::GlowSquid) {
m.anim.vo = m.anim.v;
squid_step(m, true);
}
if m.species() == Species::GlowSquid {
glow_squid_ai_step(m, events);
}
}
/// `GlowSquid.getDarkTicksRemaining`: `DATA_DARK_TICKS_REMAINING`, the ticks
/// left of the dimming a hit gave (kept in `extra.counter`).
pub fn dark_ticks(m: &Mob) -> i32 {
m.extra.counter
}
/// `GlowSquid.setDarkTicks`: a server sends it to its clients as synched data.
pub fn set_dark_ticks(m: &mut Mob, ticks: i32) {
m.extra.counter = ticks;
}
/// `GlowSquid.hurtServer`: a hit that went through dims the squid for 100
/// ticks (called after `Squid.hurtServer`'s ink).
pub fn hurt_server(m: &mut Mob) {
set_dark_ticks(m, 100);
}
/// `GlowSquid.aiStep` after `super.aiStep()`: the dark timer counts down, and
/// a `glow` particle appears at `getRandomX(0.6)`, `getRandomY()`,
/// `getRandomZ(0.6)` (drawn in that order). A window that simulates the world
/// and a client of a server both draw it, so the server's own `ai_step` pushes
/// the event as well (as the nautilus bubbles do).
fn glow_squid_ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
let ticks = dark_ticks(m);
if ticks > 0 {
set_dark_ticks(m, ticks - 1);
}
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
let r = &mut m.random;
let x = m.position.x + w * ((2.0 * r.next_double() - 1.0) * 0.6);
let y = m.position.y + h * r.next_double();
let z = m.position.z + w * ((2.0 * r.next_double() - 1.0) * 0.6);
events.push(MobEvent::ParticleAt {
kind: ParticleType::Glow.name(),
at: [x, y, z],
velocity: [0.0; 3],
});
}
/// `GlowSquidRenderer.getBlockLightLevel`: the block light a glow squid is
/// drawn with, `lit` being what `EntityRenderer.getBlockLightLevel` gives (15
/// when on fire). It glows brighter the fewer dark ticks are left.
pub fn glow_squid_block_light(m: &Mob, lit: u32) -> u32 {
// Mth.clampedLerp(1.0F - darkTicks / 10.0F, 0.0F, 15.0F).
let factor = 1.0f32 - dark_ticks(m) as f32 / 10.0;
let glow = if factor < 0.0 {
0
} else if factor > 1.0 {
15
} else {
(factor * 15.0) as u32
};
if glow == 15 { 15 } else { glow.max(lit) }
}
/// Squid.handleEntityEvent(19): begin the next server-synchronised tentacle cycle.
+6
View File
@@ -209,6 +209,12 @@ pub fn stop_being_angry(m: &mut Mob) {
b.angry_at = None;
b.anger_end = -1;
}
/// `NeutralMob.playerDied`: the anger at the player who died ends.
pub fn forgive_dead_player(m: &mut Mob) {
if get(m).and_then(|b| b.angry_at) == Some(Who::Player) {
stop_being_angry(m);
}
}
/// `Bee.startPersistentAngerTimer`: 20 to 39 seconds.
fn start_anger_timer(m: &mut Mob, w: &MobWorld) {
let ticks = next_int(m, 400, 780);
+7 -1
View File
@@ -197,7 +197,13 @@ pub fn on_damage(m: &mut Mob, source: &Damage) -> bool {
true
}
pub fn after_hurt(m: &mut Mob, source: &Damage) {
if source.attacker.is_none() && m.random.next_int_bound(10).unwrap_or(0) != 0 {
// A thrown potion's hit (`indirect_magic`, which only a splash potion
// deals to a mob) leaves `Enderman.hurtServer` before the random chance:
// `repeatedlyTryToTeleport` replaces it.
if source.kind != super::DamageKind::IndirectMagic
&& source.attacker.is_none()
&& m.random.next_int_bound(10).unwrap_or(0) != 0
{
m.extra.counter2 = 1;
}
}
+12 -5
View File
@@ -210,7 +210,9 @@ pub fn controlled(m: &Mob) -> bool {
Species::Pig | Species::Strider => m.extra.saddled && r.steer,
Species::HappyGhast => super::happy_ghast::controlled(m),
Species::Nautilus | Species::ZombieNautilus => super::nautilus::controlled(m),
_ => m.extra.saddled || m.species() == Species::SkeletonHorse && m.extra.tame,
// AbstractHorse.getControllingPassenger: saddled with a player on it. A
// tamed skeleton horse (SkeletonTrapGoal only tames it) needs the saddle too.
_ => m.extra.saddled,
}
}
/// `ItemBasedSteering.boost`: 140 to 980 ticks of extra speed.
@@ -537,10 +539,15 @@ pub fn ridden_step(m: &mut Mob) {
if m.species() == Species::HappyGhast {
return super::happy_ghast::ridden(m);
}
m.yaw = r.yaw;
m.head_pitch = r.pitch * 0.5;
m.body_yaw = r.yaw;
m.head_yaw = r.yaw;
// AbstractHorse.tickRidden: setRot(getRiddenRotation), then the body and
// head turn with it. A camel that refuses to move keeps its rotation
// (Camel.getRiddenRotation).
if !(super::camel::is_camel(m.species()) && super::camel::refuse_to_move(m)) {
m.yaw = r.yaw;
m.head_pitch = r.pitch * 0.5;
}
m.body_yaw = m.yaw;
m.head_yaw = m.yaw;
m.ai.nav.stop();
if matches!(m.species(), Species::Pig | Species::Strider) {
+30 -4
View File
@@ -827,6 +827,27 @@ fn age_boundary_reached(m: &mut Mob) {
_ => {}
}
}
/// `Goat.finalizeSpawn` on a natural spawn (the spawn path in spawning.rs): the ram
/// cooldowns (`GoatAi.initMemories`), screaming 2 percent of the time and a
/// horn lost 10 percent of the time, adults only.
pub fn goat_spawn(m: &mut Mob) {
let jump = m
.random
.next_int_between_inclusive(600, 1200)
.unwrap_or(600);
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
let ram = m
.random
.next_int_between_inclusive(600, 6000)
.unwrap_or(600);
set_memory(m, "ram_cooldown", i64::from(ram), i64::MAX);
m.extra.screaming = m.random.next_double() < 0.02;
age_boundary_reached(m);
if !m.is_baby() && m.random.next_float() < 0.1 {
let left = m.random.next_boolean();
m.extra.lost_horns[usize::from(!left)] = true;
}
}
/// `SpawnEggItem.spawnsEntity`: the spawn egg is for the mob's own kind, so
/// it is used on it (`Mob.checkAndHandleImportantInteractions`).
pub fn spawn_egg_is_for(egg: &str, kind: &str) -> bool {
@@ -1734,10 +1755,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
self.horse(m, g)
}
S::Goat => {
let jump = self.uniform(600, 1200);
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
let ram = self.uniform(600, 6000);
set_memory(m, "ram_cooldown", i64::from(ram), i64::MAX);
goat_init_memories(m, self.random);
m.extra.screaming = self.random.next_double() < 0.02;
age_boundary_reached(m);
if !m.is_baby() && self.float() < 0.1 {
@@ -2002,6 +2020,14 @@ fn set_memory(m: &mut Mob, name: &'static str, value: i64, ttl: i64) {
None => m.extra.memories.push((name, value, ttl)),
}
}
/// `GoatAi.initMemories`: a long jump cooldown (600 to 1200 ticks) and a ram
/// cooldown (600 to 6000), drawn from `random`.
pub fn goat_init_memories(m: &mut Mob, random: &mut impl RandomSource) {
let jump = random.next_int_between_inclusive(600, 1200).unwrap_or(600);
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
let ram = random.next_int_between_inclusive(600, 6000).unwrap_or(600);
set_memory(m, "ram_cooldown", i64::from(ram), i64::MAX);
}
/// `Panda.Gene.getRandom` (enum order: normal, lazy, worried, playful,
/// brown, weak, aggressive).
pub fn panda_gene(roll: i32) -> u8 {
+25
View File
@@ -60,6 +60,31 @@ pub fn resets_x_rot(m: &Mob) -> bool {
!flag(m, POUNCING | CROUCHING | INTERESTED | FACEPLANTED)
}
/// `Fox.populateDefaultEquipmentSlots`: one fox in five holds an emerald, an
/// egg, rabbit feet or hide, wheat, leather or a feather in its mouth.
pub fn populate_default_equipment(m: &mut Mob) {
if m.random.next_float() < 0.2 {
let odds = m.random.next_float();
let held = if odds < 0.05 {
"minecraft:emerald"
} else if odds < 0.2 {
"minecraft:egg"
} else if odds < 0.4 {
if m.random.next_boolean() {
"minecraft:rabbit_foot"
} else {
"minecraft:rabbit_hide"
}
} else if odds < 0.6 {
"minecraft:wheat"
} else if odds < 0.8 {
"minecraft:leather"
} else {
"minecraft:feather"
};
m.extra.main_hand = Some(held.into());
}
}
/// `Fox.registerGoals` and `setTargetGoals` (the targets wait for the variant:
/// red foxes prefer chickens and rabbits, snow foxes fish).
pub fn register(m: &mut Mob) {
+48 -16
View File
@@ -480,6 +480,14 @@ impl Mob {
(!water && m.malus(w.path_type_static(p[0], p[1], p[2])) == 0.0).then_some(p)
})
}
/// `WaterAvoidingRandomFlyingGoal.getPosition`: a hover spot ahead, else
/// any air or water spot ahead.
pub fn flying_stroll_pos(&mut self, w: &MobWorld) -> Option<[f64; 3]> {
let [xd, _, zd] = self.view_vector();
let max_rad = f64::from(std::f32::consts::FRAC_PI_2);
self.hover_random_pos(w, 8, 7, xd, zd, max_rad, 3, 1)
.or_else(|| self.air_and_water_random_pos(w, 8, 4, -2, xd, zd))
}
/// LandRandomPos.getPos
pub fn land_random_pos(&mut self, w: &MobWorld, h: i32, v: i32) -> Option<[f64; 3]> {
self.pick_best(w, |m| {
@@ -788,6 +796,8 @@ pub struct Stroll {
pub swim: bool,
/// WaterAvoidingRandomFlyingGoal: a hover spot, else an air or water pos.
pub flying: bool,
/// `Parrot.ParrotWanderGoal`: to a tree, else as `flying`.
pub parrot: bool,
pub force: bool,
wanted: [f64; 3],
}
@@ -800,6 +810,7 @@ impl Stroll {
water_avoiding: None,
swim: false,
flying: false,
parrot: false,
force: false,
wanted: [0.0; 3],
}
@@ -821,6 +832,14 @@ impl Stroll {
..Self::new(speed)
}
}
/// `Parrot.ParrotWanderGoal(speed)`, a WaterAvoidingRandomFlyingGoal.
pub fn parrot(speed: f64) -> Self {
Self {
water_avoiding: Some(0.001),
parrot: true,
..Self::new(speed)
}
}
/// RandomSwimmingGoal(speed, interval)
pub fn swimming(speed: f64, interval: i32) -> Self {
Self {
@@ -832,7 +851,9 @@ impl Stroll {
impl Goal for Stroll {
goal_clone!();
fn name(&self) -> &'static str {
if self.water_avoiding.is_some() {
if self.parrot {
"ParrotWander"
} else if self.water_avoiding.is_some() {
"Stroll"
} else if self.swim {
"RandomSwimming"
@@ -853,6 +874,7 @@ impl Goal for Stroll {
}
}
let pos = match self.water_avoiding {
Some(_) if self.parrot => super::parrot::wander_pos(m, cx.w),
Some(prob) => {
if m.fluids.in_water {
m.land_random_pos(cx.w, 15, 7)
@@ -880,21 +902,7 @@ impl Goal for Stroll {
}
p
}
None if self.flying => {
// WaterAvoidingRandomFlyingGoal.getPosition.
let [xd, _, zd] = m.view_vector();
m.hover_random_pos(
cx.w,
8,
7,
xd,
zd,
f64::from(std::f32::consts::FRAC_PI_2),
3,
1,
)
.or_else(|| m.air_and_water_random_pos(cx.w, 8, 4, -2, xd, zd))
}
None if self.flying => m.flying_stroll_pos(cx.w),
None => m.default_random_pos(cx.w, 10, 7),
};
let Some(p) = pos else { return false };
@@ -1633,6 +1641,18 @@ impl Goal for HurtByTarget {
let species = m.species();
let id = m.id;
let pos = m.position;
// TamableAnimal: its owner (`getOwner`, none for a wild one) and
// whether it is a pet at all.
let tamable = matches!(
species,
Species::Wolf
| Species::Cat
| Species::Parrot
| Species::Nautilus
| Species::ZombieNautilus
);
let owner = m.extra.tame.then_some(m.extra.owner).flatten();
let player = cx.w.player.as_ref().map(|p| p.uuid);
let ids: Vec<u32> =
cx.w.others
.iter()
@@ -1650,6 +1670,18 @@ impl Goal for HurtByTarget {
// PolarBearHurtByTargetGoal.alertOther: adults only.
.filter(|s| !(self.polar_bear && s.baby))
.filter(|s| Who::Mob(s.id) != by)
// HurtByTargetGoal.alertOthers: a pet is only called by a
// pet with the same owner.
.filter(|s| !tamable || s.tame.then_some(s.owner).flatten() == owner)
// TamableAnimal.considersEntityAsAlly: a pet tamed by the
// player who hurt it is allied with them, so it is not called.
.filter(|s| {
!(tamable
&& by == Who::Player
&& s.tame
&& s.owner.is_some()
&& s.owner == player)
})
.map(|s| s.id)
.collect();
// Bee.BeeHurtByOtherGoal.alertOther: only with the attacker in sight.
+3
View File
@@ -336,6 +336,9 @@ pub fn leash_offset(m: &Mob) -> [f64; 3] {
if super::llama::is_llama(m.species()) {
// Llama.getLeashOffset.
[0.0, 0.75 * m.eye_height(), f64::from(m.width()) * 0.5]
} else if m.species() == super::Species::Bee {
// Bee.getLeashOffset: half the eye height, a fifth of the width ahead.
[0.0, 0.5 * m.eye_height(), f64::from(m.width() * 0.2)]
} else {
[0.0, m.eye_height(), f64::from(m.width() * 0.4)]
}
+35 -2
View File
@@ -19,12 +19,13 @@ use horizon_world::{
registry::BlockStateId,
};
/// `Entity.moveDist`, `flyDist` and `nextStep`.
/// `Entity.moveDist`, `flyDist`, `nextStep` and `Parrot.nextFlap`.
#[derive(Clone, Copy, Debug)]
pub struct Steps {
pub move_dist: f32,
pub fly_dist: f32,
pub next_step: f32,
pub next_flap: f32,
/// `AbstractHorse.gallopSoundCounter`.
pub gallop: i32,
}
@@ -34,6 +35,7 @@ impl Default for Steps {
move_dist: 0.0,
fly_dist: 0.0,
next_step: 1.0,
next_flap: 1.0,
gallop: 0,
}
}
@@ -759,6 +761,22 @@ impl Mob {
));
}
}
} else if w.facts.is_air(state)
&& self.species() == super::Species::Bee
&& !self.on_ground
&& self.age.is_multiple_of(2)
{
// Entity.processFlappingMovement: a flying bee beats its wings on
// every other tick (Bee.isFlapping, TICKS_PER_FLAP is 2).
events.push(MobEvent::GameEvent {
event: "minecraft:flap",
at: [self.position.x, self.position.y, self.position.z],
source: self.id,
state: horizon_world::registry::BlockStateId(0),
});
} else if w.facts.is_air(state) && self.species() == super::Species::Parrot {
// Entity.processFlappingMovement: a move ending in the air.
super::parrot::process_flapping(self, events);
}
}
/// `Entity.nextStep`: the next whole metre walked; a warden's steps come
@@ -865,6 +883,19 @@ impl Mob {
if self.species() == super::Species::Creeper {
self.swell = (self.swell + (self.fall_distance * 1.5) as i32).min(30 - 5);
}
// AbstractHorse.causeFallDamage (llamas override it): a landing of more
// than a block thuds, and the horse's fall makes no generic fall sound.
let thud =
super::equine::is_equine(self.species()) && !super::llama::is_llama(self.species());
if thud && self.fall_distance > 1.0 && !self.silent {
let at = [self.position.x, self.position.y, self.position.z];
let sound = if self.is_baby() {
"entity.baby_horse.land"
} else {
"entity.horse.land"
};
events.push(MobEvent::Sound(sound, at, 0.4, 1.0));
}
if self.kind.flags.fall_immune {
return;
}
@@ -884,7 +915,9 @@ impl Mob {
(false, true) => "entity.generic.big_fall",
(false, false) => "entity.generic.small_fall",
};
events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
if !thud {
events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
}
self.damage(damage as f32, super::Damage::fall());
}
}
+191 -3
View File
@@ -1,6 +1,9 @@
//! What `Parrot.aiStep` adds: imitating the mobs around it (`imitateNearbyMobs`,
//! the ambient sound of `getAmbient`) and dancing near a playing jukebox.
use super::{Mob, MobEvent, MobWorld, Species};
//! Parrots: flying (`FlyingMoveControl`, `FlyingPathNavigation`, the wander
//! that heads for trees), flapping and gliding down (`calculateFlapping`),
//! and what `Parrot.aiStep` adds: imitating the mobs around it
//! (`imitateNearbyMobs`, the ambient sound of `getAmbient`) and dancing near
//! a playing jukebox.
use super::{Mob, MobEvent, MobWorld, Species, control::MoveKind, nav::NavKind, path::PathType};
use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
@@ -136,3 +139,188 @@ pub fn ambient(m: &mut Mob, w: &MobWorld) -> &'static str {
}
"entity.parrot.ambient"
}
/// `Parrot(EntityType, Level)` with `createNavigation` and `registerGoals`:
/// a `FlyingMoveControl(10, false)` that lets gravity back on when it has
/// nowhere to go, and a `FlyingPathNavigation` that floats and opens no doors.
pub fn register(m: &mut Mob) {
m.ai.mov.kind = MoveKind::Flying {
max_turn: 10.0,
hovers: false,
};
m.ai.nav.kind = NavKind::Flying;
m.ai.nav.can_open_doors = false;
m.ai.nav.can_float = true;
m.set_malus(PathType::FireInNeighbor, -1.0);
m.set_malus(PathType::Fire, -1.0);
m.set_malus(PathType::Cocoa, -1.0);
super::tame::register_parrot(m);
}
/// The wings of `Parrot.calculateFlapping`: `flapSpeed` rises in the air and
/// falls on the ground or a shoulder, `flapping` restarts at 1 in the air and
/// decays, and `flap` turns by it. A client runs it for the parrots it only
/// displays.
pub fn flap(m: &mut Mob) {
let grounded = m.on_ground || m.extra.mount.is_some();
let a = &mut m.anim;
a.flap_o = a.flap;
a.flap_speed_o = a.flap_speed;
a.flap_speed = (a.flap_speed + if grounded { -1.0 } else { 4.0 } * 0.3).clamp(0.0, 1.0);
if !m.on_ground && a.flapping < 1.0 {
a.flapping = 1.0;
}
a.flapping *= 0.9;
a.flap += a.flapping * 2.0;
}
/// `Parrot.calculateFlapping`, after the rest of `aiStep`: the wings, and a
/// parrot falling through the air glides down at 0.6 of the speed.
pub fn calculate_flapping(m: &mut Mob) {
flap(m);
if !m.on_ground && m.velocity.y < 0.0 {
m.velocity.y *= 0.6;
}
}
/// `Parrot.isFlapping` and `onFlap` (from `Entity.processFlappingMovement`,
/// when a move ends in the air): a wing beat each half `flapSpeed` flown.
pub fn process_flapping(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.ai.steps.fly_dist > m.ai.steps.next_flap {
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound("entity.parrot.fly", at, 0.15, 1.0));
}
m.ai.steps.next_flap = m.ai.steps.fly_dist + m.anim.flap_speed / 2.0;
}
}
/// `Parrot.ParrotWanderGoal.getPosition`: out of water onto land; otherwise
/// (all but one time in a thousand) the top of a tree nearby; failing both,
/// `WaterAvoidingRandomFlyingGoal.getPosition`.
pub fn wander_pos(m: &mut Mob, w: &MobWorld) -> Option<[f64; 3]> {
let mut pos = None;
if m.fluids.in_water {
pos = m.land_random_pos(w, 15, 15);
}
// WaterAvoidingRandomStrollGoal.probability.
if m.random.next_float() >= 0.001 {
pos = tree_pos(m, w);
}
pos.or_else(|| m.flying_stroll_pos(w))
}
/// `Parrot.ParrotWanderGoal.getTreePos`: within 3 blocks across and 6 up or
/// down (x fastest, then y, then z, as `BlockPos.betweenClosed` walks), the
/// first empty block with an empty one above over leaves or a log.
fn tree_pos(m: &Mob, w: &MobWorld) -> Option<[f64; 3]> {
let (x, y, z) = (m.position.x, m.position.y, m.position.z);
let at = [x.floor() as i32, y.floor() as i32, z.floor() as i32];
let air = |p: [i32; 3]| {
matches!(
w.block_name(p[0], p[1], p[2]),
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
)
};
for bz in (z - 3.0).floor() as i32..=(z + 3.0).floor() as i32 {
for by in (y - 6.0).floor() as i32..=(y + 6.0).floor() as i32 {
for bx in (x - 3.0).floor() as i32..=(x + 3.0).floor() as i32 {
let p = [bx, by, bz];
if p == at {
continue;
}
let below = w.block_name(bx, by - 1, bz);
let tree = below.ends_with("_leaves") || w.has_tag("minecraft:logs", below);
if tree && air(p) && air([bx, by + 1, bz]) {
return Some([f64::from(bx) + 0.5, f64::from(by), f64::from(bz) + 0.5]);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::sensor_scan_tests::kind;
use std::sync::Arc;
fn parrot() -> Mob {
let mut k = (*kind()).clone();
k.id = "minecraft:parrot".into();
k.species = Species::Parrot;
k.flags.omnidirectional = true;
Mob::new(1, Arc::new(k), Vec3::ZERO, 0.0)
}
/// `Parrot()`: `FlyingMoveControl(this, 10, false)`, the path maluses,
/// and `createNavigation`'s `FlyingPathNavigation` (no doors, floats).
#[test]
fn a_parrot_flies_by_its_navigation_and_move_control() {
let m = parrot();
assert_eq!(
m.ai.mov.kind,
MoveKind::Flying {
max_turn: 10.0,
hovers: false
}
);
assert_eq!(m.ai.nav.kind, NavKind::Flying);
assert!(!m.ai.nav.can_open_doors);
assert!(m.ai.nav.can_float);
for t in [PathType::FireInNeighbor, PathType::Fire, PathType::Cocoa] {
assert_eq!(m.malus(t), -1.0);
}
assert!(m.ai.goals.has("ParrotWander"));
}
/// `Parrot.calculateFlapping`: in the air the wings beat and a fall is
/// slowed to 0.6; on the ground or a shoulder they fold.
#[test]
fn a_falling_parrot_flaps_and_glides() {
let mut m = parrot();
m.on_ground = false;
m.velocity = Vec3::new(0.1, -0.5, 0.0);
calculate_flapping(&mut m);
assert!((m.velocity.y + 0.3).abs() < 1.0e-12);
assert_eq!(m.velocity.x, 0.1);
assert_eq!(m.anim.flap_speed, 1.0);
assert_eq!(m.anim.flapping, 0.9);
assert_eq!(m.anim.flap, 1.8);
// Rising is not slowed.
m.velocity = Vec3::new(0.0, 0.2, 0.0);
calculate_flapping(&mut m);
assert_eq!(m.velocity.y, 0.2);
assert_eq!(m.anim.flap_o, 1.8);
// On a shoulder (a passenger), flapSpeed falls by 0.3 a tick.
m.extra.mount = Some(7);
calculate_flapping(&mut m);
assert!((m.anim.flap_speed - 0.7).abs() < 1.0e-6);
m.extra.mount = None;
m.on_ground = true;
m.velocity = Vec3::new(0.0, -0.0784, 0.0);
calculate_flapping(&mut m);
assert!((m.anim.flap_speed - 0.4).abs() < 1.0e-6);
assert_eq!(m.velocity.y, -0.0784, "no glide on the ground");
}
/// `Parrot.onFlap`: a wing beat (`entity.parrot.fly`, 0.15) each half
/// `flapSpeed` flown.
#[test]
fn it_beats_its_wings_as_it_flies() {
let mut m = parrot();
m.anim.flap_speed = 1.0;
let mut events = Vec::new();
m.ai.steps.fly_dist = 0.5;
process_flapping(&mut m, &mut events);
assert!(events.is_empty(), "nextFlap starts at 1");
m.ai.steps.fly_dist = 1.2;
process_flapping(&mut m, &mut events);
assert!(matches!(
events.as_slice(),
[MobEvent::Sound("entity.parrot.fly", _, v, _)] if *v == 0.15
));
assert!((m.ai.steps.next_flap - 1.7).abs() < 1.0e-6);
}
}
@@ -182,6 +182,13 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
stop_being_angry(m);
}
}
/// `NeutralMob.playerDied`: a bear angry at the player (its target, kept in
/// `flag2` until the anger ends) stops.
pub fn forgive_dead_player(m: &mut Mob) {
if m.extra.flag2 {
stop_being_angry(m);
}
}
/// `NeutralMob.stopBeingAngry`.
fn stop_being_angry(m: &mut Mob) {
m.ai.last_hurt_by = None;
+28 -1
View File
@@ -89,7 +89,7 @@ pub fn blocked(m: &mut Mob, events: &mut Vec<MobEvent>) -> bool {
false
}
}
/// `Ravager.aiStep`: speed toward 0.35 while hunting, leaf trampling, and
/// `Ravager.aiStep`: speed toward 0.35 while hunting, leaf and crop trampling, and
/// the bite, stun and roar clocks.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if !m.alive() {
@@ -131,6 +131,33 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.ai.jumping = true;
}
}
// Entity.checkInsideBlocks -> CropBlock.entityInside: a ravager walking
// through a crop destroys it, drops and all, with mobGriefing.
if w.mob_griefing {
let b = m
.bounds()
.deflate(horizon_core::geometry::Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
let (lo, hi) = (b.min_position(), b.max_position());
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if matches!(
w.block_name(x, y, z),
"minecraft:wheat"
| "minecraft:carrots"
| "minecraft:potatoes"
| "minecraft:beetroots"
| "minecraft:torchflower_crop"
) {
events.push(MobEvent::BreakBlock {
pos: [x, y, z],
drop: true,
});
}
}
}
}
}
if m.extra.anim_ticks > 0 {
m.extra.anim_ticks -= 1;
if m.extra.anim_ticks == 10 {
+10 -1
View File
@@ -12,6 +12,8 @@ pub struct BiomeFacts {
/// Biome temperature at the position (strays in cold biomes, snow foxes).
pub temperature: f32,
pub snowy: bool,
/// `#minecraft:spawns_snow_foxes` (`Fox.Variant.byBiome`: snow, else red).
pub snow_fox: bool,
pub swamp: bool,
pub ocean: bool,
pub mushroom: bool,
@@ -305,7 +307,14 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
m.extra.variant = villager_type(biome);
m.extra.pick_up_loot = true;
}
S::Fox | S::Panda | S::Dolphin => m.extra.pick_up_loot = true,
// Fox.finalizeSpawn: the variant by biome, then the mouth's item.
S::Fox => {
m.extra.variant = u8::from(biome.snow_fox);
m.extra.pick_up_loot = true;
super::fox::populate_default_equipment(m);
}
S::Panda | S::Dolphin => m.extra.pick_up_loot = true,
S::Goat => super::finalize::goat_spawn(m),
S::TropicalFish => super::aquatic::finalize(m),
// Salmon.finalizeSpawn: a weighted size (small 30, medium 50, large 15).
S::Salmon => {
+13 -4
View File
@@ -101,7 +101,8 @@ pub fn register(m: &mut Mob) {
);
}
/// `Parrot.registerGoals`: a tamed parrot sits when told to and follows its
/// owner from five blocks away (the wander and the shoulder come as they are).
/// owner from five blocks away; it wanders to trees (`ParrotWanderGoal`) and
/// lands on its owner's shoulder.
pub fn register_parrot(m: &mut Mob) {
let g = &mut m.ai.goals;
g.add(0, TamePanic(Panic::new(1.25)));
@@ -109,7 +110,7 @@ pub fn register_parrot(m: &mut Mob) {
g.add(1, LookAtPlayer::new(8.0));
g.add(2, SitWhenOrdered);
g.add(2, FollowOwner::new(1.0, 5.0, 1.0));
g.add(2, Stroll::avoiding_water(1.0));
g.add(2, Stroll::parrot(1.0));
g.add(3, LandOnOwnersShoulder);
}
/// `LandOnOwnersShoulderGoal`: a standing parrot, 100 ticks old, lands on
@@ -227,10 +228,12 @@ fn teleport_to_owner(m: &mut Mob, w: &MobWorld) {
}
}
}
/// `TamableAnimal.canTeleportTo`: walkable, not on leaves, no collision.
/// `TamableAnimal.canTeleportTo`: walkable, not on leaves (unless it can
/// fly, `Parrot.canFlyToOwner`), no collision.
fn can_teleport_to(m: &Mob, w: &MobWorld, p: [i32; 3]) -> bool {
let below = w.facts.get(w.world.block(p[0], p[1] - 1, p[2]));
if !below.full_collision || below.name.ends_with("_leaves") {
let flies = m.species() == Species::Parrot;
if !below.full_collision || (!flies && below.name.ends_with("_leaves")) {
return false;
}
let here = w.facts.get(w.world.block(p[0], p[1], p[2]));
@@ -592,6 +595,12 @@ fn stop_being_angry(m: &mut Mob) {
m.extra.angry_at = None;
m.extra.anger = 0;
}
/// `NeutralMob.playerDied`: the anger at the player who died ends.
pub fn forgive_dead_player(m: &mut Mob) {
if m.extra.angry_at == Some(Who::Player) {
stop_being_angry(m);
}
}
/// `NeutralMob.updatePersistentAnger(level, true)` from `Wolf.aiStep`: any
/// target (the attacker, a pack-mate's attacker the `HurtByTargetGoal` alert
/// gave it, prey, the owner's enemy) is the anger's target, and the 20 to 39
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more