mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into this branch (creative display slots)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
ada669266f
133 files changed
+5914
-472
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user