Merge main into fix/server-mob-animation-sync-2

Brings main in so the branch has one merge base with it (GitHub saw a
conflict through the criss-cross bases; the merge itself is clean).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-27 12:08:06 +01:00
commit e5ca012ed8
144 files changed
+7145 -867

No files matched your search

Generated
+7
View File
@@ -1405,6 +1405,7 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
@@ -3774,6 +3775,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
+18 -1
View File
@@ -4,7 +4,10 @@
//! language used by every screen.
use std::{
collections::{BTreeMap, HashMap},
sync::{Arc, RwLock},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -23,11 +26,16 @@ impl LanguageInfo {
#[derive(Default)]
pub struct Language {
pub code: String,
/// `LanguageInfo.bidirectional`: text without a strong character reads
/// right to left (`ClientLanguage.defaultRightToLeft`).
bidirectional: bool,
map: HashMap<String, String>,
}
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
@@ -134,8 +142,12 @@ impl Language {
{
apply_deprecated(&mut map, &bytes);
}
let bidirectional = available(sources)
.iter()
.any(|info| info.code == code && info.bidirectional);
Self {
code: code.to_owned(),
bidirectional,
map,
}
}
@@ -197,11 +209,16 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
}
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
}
pub fn code() -> String {
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
}
+4 -2
View File
@@ -1,8 +1,9 @@
//! The client's lowest layer, below commands and gameplay: chat text and
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
//! bars, game rules, block entity NBT tables, and the registry, particle and
//! sound source tables commands name. horizon-client re-exports each module
//! under its old path (`horizon_client::text`, ...).
//! sound source tables commands name, and the `pack.mcmeta` filter regex.
//! horizon-client re-exports each module under its old path
//! (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
@@ -14,6 +15,7 @@ pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod particle_type;
pub mod pattern;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
+1
View File
@@ -62,6 +62,7 @@ gilrs = { version = "0.11", optional = true }
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
@@ -1061,6 +1061,7 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -1081,6 +1082,7 @@ pub fn main() -> horizon_client::Result<()> {
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
@@ -353,6 +353,7 @@ pub fn main() -> Result<()> {
horizon_world::food::Difficulty::Hard,
][op["difficulty"].as_u64().unwrap() as usize],
natural_regeneration: op["regenerate"].as_bool().unwrap(),
..Default::default()
};
}
"food" => {
+163 -22
View File
@@ -36,8 +36,9 @@ enum Animate {
spawn_particles: bool,
},
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
/// the biome foliage colour unless a fixed colour is given (azalea).
Leaves(Option<(T, f32)>, Option<u32>),
/// the biome foliage colour unless a fixed colour is given (azalea); and
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
Leaves(Option<(T, f32)>, Option<u32>, bool),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -48,8 +49,9 @@ enum Animate {
CandleCake,
BrewingStand,
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// FallingBlock with its dust colour; sand also has the ambient desert sound
/// (`SandBlock.animateTick`).
Falling(u32, bool),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
@@ -73,6 +75,8 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// `WetSpongeBlock.animateTick`.
WetSponge,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -107,17 +111,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
spawn_particles: false,
},
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
"azalea_leaves" | "flowering_azalea_leaves" => {
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
}
"spruce_leaves" => Animate::Leaves(None, None),
"spruce_leaves" => Animate::Leaves(None, None, false),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -126,16 +130,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"mycelium" => Animate::Mycelium,
"brewing_stand" => Animate::BrewingStand,
"ender_chest" => Animate::EnderChest,
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
"sand" => Animate::Falling(0xDBD3A0, true),
"red_sand" => Animate::Falling(0xA95821, true),
"gravel" => Animate::Falling(0x807C7B, false),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
"dragon_egg" => Animate::Falling(0x000000, false),
_ if name.ends_with("_concrete_powder") => Animate::Falling(
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
false,
),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
@@ -167,6 +172,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -233,6 +242,10 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_logs: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
@@ -308,6 +321,8 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
burnable,
ambient,
})
@@ -771,7 +786,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling, fixed_tint) => {
Animate::Leaves(falling, fixed_tint, poplar) => {
let below = [pos[0], pos[1] - 1, pos[2]];
// LeavesBlock.makeDrippingWaterParticles.
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
@@ -783,6 +798,9 @@ fn block_animate_tick(
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
if poplar {
leaves_ambient_sound(tables, engine, ctx, state, pos);
}
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
if let Some((ty, chance)) = falling
&& engine.random().next_float() < chance
@@ -963,6 +981,36 @@ fn block_animate_tick(
}
}
}
Animate::WetSponge => {
// Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST.
const DIRECTIONS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
let d = DIRECTIONS[next_int(engine.random(), 6) as usize];
// The sponge occludes, so only a sturdy neighbour face hides the drip.
if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) {
let r = engine.random();
let p = match d {
[0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()],
[dx, _, 0] => {
let y = fy + r.next_double() * 0.8;
let z = fz + r.next_double();
[fx + if dx > 0 { 1.1 } else { 0.05 }, y, z]
}
[_, _, dz] => {
let y = fy + r.next_double() * 0.8;
let x = fx + r.next_double();
[x, y, fz + if dz > 0 { 1.1 } else { 0.05 }]
}
};
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
Animate::Mycelium => {
let r = engine.random();
if next_int(r, 10) == 0 {
@@ -1045,7 +1093,7 @@ fn block_animate_tick(
add(engine, T::Portal, p, v);
}
}
Animate::Falling(color) => {
Animate::Falling(color, sand) => {
if next_int(engine.random(), 16) == 0 {
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
// FallingBlock.isFree: air, fire, liquids or replaceable.
@@ -1062,6 +1110,14 @@ fn block_animate_tick(
);
}
}
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
if sand
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
&& next_int(engine.random(), 2100) == 0
&& sand_sound_nearby(tables, ctx, pos)
{
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
@@ -1282,6 +1338,91 @@ fn block_animate_tick(
}
}
}
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
/// one log and three leaves of the same kind around the block.
fn leaves_ambient_sound(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if next_int(engine.random(), 300) != 0 {
return;
}
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
return;
};
let (mut logs, mut leaves) = (0, 0);
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
if tables
.poplar_leaf_logs
.get(neighbor.0 as usize)
.copied()
.unwrap_or(false)
{
logs += 1;
}
if ctx
.registry
.state(neighbor)
.is_some_and(|s| &s.name == name)
{
leaves += 1;
}
if logs == 1 && leaves == 3 {
let p = pos.map(f64::from);
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
return;
}
}
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
let is_sand = |p: [i32; 3]| {
tables
.desert_sand_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let (mut found, mut checked) = (0, 0);
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
// columnContainsTriggeringBlock.
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
let matches = if (surface - pos[1]).abs() > 5 {
let mut y = pos[1] + 6;
let mut above_air = ctx.is_air([x, y, z]);
y -= 1;
let mut hit = false;
for _ in 0..10 {
if above_air && is_sand([x, y, z]) {
hit = true;
break;
}
above_air = ctx.is_air([x, y, z]);
y -= 1;
}
hit
} else {
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
};
if matches {
found += 1;
if found > 3 {
return true;
}
}
checked += 1;
if 4 - checked + found < 3 {
return false;
}
}
false
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
+1 -1
View File
@@ -3,8 +3,8 @@
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
//! against the resource tree; a later source replaces an earlier sprite of
//! the same id and a filter removes the matching ones.
use crate::packs::pattern::Pattern;
use horizon_assets::palette::apply_palette;
use horizon_client_core::pattern::Pattern;
use serde_json::Value;
use std::{
collections::BTreeMap,
-5
View File
@@ -719,11 +719,6 @@ impl Audio {
self.play(event, at, volume, pitch);
self.capture = capture;
}
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
/// client (the entity's own random there, never the server's world random).
pub fn hurt_pitch(&mut self) -> f32 {
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
}
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
pub fn play_button_click(&mut self) {
self.play_instance(
+1 -1
View File
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
}
/// The discovery document, fetched once per game (a failure is retried next time).
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
if let Some(d) = CACHE.lock().unwrap().clone() {
return Ok(d);
+302
View File
@@ -0,0 +1,302 @@
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
//! `Language.getVisualOrder`): the logical text a player types becomes the
//! glyphs a font draws left to right. Arabic letters are shaped into their
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
//! text is reordered by the Unicode bidirectional algorithm, and characters in
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
use crate::text::{Span, Text};
use std::borrow::Cow;
use unicode_bidi::{
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
};
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
/// (diacritics) and `None` never joins.
#[derive(Clone, Copy, PartialEq)]
enum Join {
None,
Right,
Dual,
Mark,
}
use Join::{Dual, Mark, Right};
/// ICU's joining table (`araLink`): code point ranges and their class, with the
/// lam-alef ligatures at the end. Anything else is `Join::None`.
const JOINS: &[(u32, u32, Join)] = &[
(0x0622, 0x0625, Right),
(0x0626, 0x0626, Dual),
(0x0627, 0x0627, Right),
(0x0628, 0x0628, Dual),
(0x0629, 0x0629, Right),
(0x062A, 0x062E, Dual),
(0x062F, 0x0632, Right),
(0x0633, 0x063A, Dual),
(0x0640, 0x0647, Dual),
(0x0648, 0x0649, Right),
(0x064A, 0x064A, Dual),
(0x064B, 0x0656, Mark),
(0x0670, 0x0670, Mark),
(0x0671, 0x0673, Right),
(0x0675, 0x0677, Right),
(0x0678, 0x0687, Dual),
(0x0688, 0x0699, Right),
(0x069A, 0x06B9, Dual),
(0x06BA, 0x06BA, Right),
(0x06BB, 0x06BF, Dual),
(0x06C0, 0x06C0, Right),
(0x06C1, 0x06C1, Dual),
(0x06C2, 0x06CB, Right),
(0x06CC, 0x06CC, Dual),
(0x06CD, 0x06CD, Right),
(0x06CE, 0x06D1, Dual),
(0x06D2, 0x06D3, Right),
(0x200D, 0x200D, Dual),
(0x206D, 0x206F, Mark),
(0xFEF5, 0xFEFC, Right),
];
/// Letters that have joined forms and the code point of the isolated one; the
/// final, initial and medial forms follow it (a right-joining letter has the
/// isolated and final only). The lam-alef ligatures are their own letters.
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
/// U+06D3) unchanged, so they are in `JOINS` but not here.
#[rustfmt::skip]
const FORMS: &[(u32, u32)] = &[
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
];
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
/// neighbouring characters that mirror each other.
const MIRRORED: &str = "\
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
fn join(c: char) -> Join {
let c = c as u32;
JOINS
.iter()
.find(|&&(from, to, _)| (from..=to).contains(&c))
.map_or(Join::None, |&(_, _, join)| join)
}
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
fn letter_of(c: char) -> char {
let c32 = c as u32;
if !matches!(c32, 0xFE70..=0xFEFC) {
return c;
}
if (0xFE70..0xFE80).contains(&c32) {
// The diacritics' forms pair up from fathatan on.
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
}
FORMS
.iter()
.find(|&&(letter, iso)| {
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
4
} else {
2
};
(iso..iso + forms).contains(&c32)
})
.and_then(|&(letter, _)| char::from_u32(letter))
.unwrap_or(c)
}
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
/// may sit between) becomes one ligature, then every letter takes the isolated,
/// final, initial or medial form its neighbours call for. Diacritics take the
/// tatweel form between two joined letters. The text can only get shorter.
fn shape(text: &[char]) -> Vec<char> {
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
let mut chars = Vec::with_capacity(letters.len());
let mut i = 0;
while i < letters.len() {
if letters[i] == '\u{644}' {
let mut at = i + 1;
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
at += 1;
}
let alef = letters.get(at).and_then(|&c| {
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
.iter()
.position(|&a| a == c)
});
if let Some(alef) = alef {
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
chars.extend_from_slice(&letters[i + 1..at]);
i = at + 1;
continue;
}
}
chars.push(letters[i]);
i += 1;
}
// Whether the nearest letter before joins the next one, and the joins of
// the nearest letter after.
let mut joins_next = false;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let next_joins = chars[i + 1..]
.iter()
.map(|&n| join(n))
.find(|&j| j != Mark)
.is_some_and(|j| j != Join::None);
let kind = join(c);
let shaped = match kind {
Join::None => {
joins_next = false;
c
}
Mark => match c as u32 {
m @ 0x064B..=0x0652 => {
let iso = 0xFE70 + 2 * (m - 0x064B);
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
}
_ => c,
},
Right | Dual => {
let to_next = kind == Dual && next_joins;
let form = match (joins_next, to_next) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
joins_next = kind == Dual;
FORMS
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
.ok()
.and_then(|at| char::from_u32(FORMS[at].1 + form))
.unwrap_or(c)
}
};
out.push(shaped);
}
out
}
/// `UCharacter.getMirror`.
fn mirror(c: char) -> char {
if bidi_class(c) != BidiClass::ON {
return c;
}
let mut pairs = MIRRORED.chars();
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
if c == a {
return b;
}
if c == b {
return a;
}
}
c
}
/// Whether a character can change anything: right-to-left letters and numbers,
/// the explicit direction controls and the diacritics that shaping turns into
/// letters. Without any, left-to-right text keeps its order.
fn directional(c: char) -> bool {
c as u32 >= 0x0590
&& (matches!(c as u32, 0x064B..=0x0652)
|| matches!(
bidi_class(c),
BidiClass::R
| BidiClass::AL
| BidiClass::AN
| BidiClass::LRE
| BidiClass::RLE
| BidiClass::LRO
| BidiClass::RLO
| BidiClass::PDF
| BidiClass::LRI
| BidiClass::RLI
| BidiClass::FSI
| BidiClass::PDI
))
}
/// Whether `chars` keep their order and shapes: left-to-right text without
/// anything directional, in a language that reads left to right by default.
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
!default_rtl && !chars.any(directional)
}
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
/// character's position in the shaped text. A character's style is the one at
/// its position in the shaped text, as `SubStringSource` takes it (the text
/// shrinks at a lam-alef ligature).
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
let shaped = shape(chars);
let plain: String = shaped.iter().collect();
// DEFAULT_RTL only changes text without a strong character.
let level =
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
let info = ParagraphBidiInfo::new(&plain, level);
let levels = info.reordered_levels_per_char(0..plain.len());
ParagraphBidiInfo::reorder_visual(&levels)
.into_iter()
.map(|i| {
let c = if levels[i].is_rtl() {
mirror(shaped[i])
} else {
shaped[i]
};
(c, i)
})
.collect()
}
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
/// the current language's direction.
pub fn visual(text: &Text) -> Cow<'_, Text> {
let default_rtl = crate::lang::bidirectional();
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
return Cow::Borrowed(text);
}
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
let mut out = Text::default();
for (c, at) in reorder(&chars, default_rtl) {
match out.spans.last_mut() {
Some(span) if span.style == *styles[at] => span.text.push(c),
_ => out.spans.push(Span {
text: c.to_string(),
style: styles[at].clone(),
}),
}
}
Cow::Owned(out)
}
/// The same for a plain string.
pub fn visual_str(s: &str) -> Cow<'_, str> {
let default_rtl = crate::lang::bidirectional();
if unchanged(s.chars(), default_rtl) {
return Cow::Borrowed(s);
}
let chars: Vec<char> = s.chars().collect();
Cow::Owned(
reorder(&chars, default_rtl)
.into_iter()
.map(|(c, _)| c)
.collect(),
)
}
@@ -610,7 +610,9 @@ fn sign_text_calls(
.iter()
.enumerate()
.map(|(i, line)| {
// The line that fits, in the order it is drawn (getVisualOrder).
let shown = first_line(line, data.max_line_width(), &advance);
let shown = crate::bidi::visual_str(&shown).into_owned();
TextCall {
matrix: m,
x: -((width(&shown) as i32) / 2) as f32,
@@ -734,7 +736,7 @@ pub struct Scene<'a> {
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
}
@@ -1286,11 +1288,12 @@ impl BlockEntityRenderer {
};
// SkullBlockRenderer.getRenderType: the profile's skin when it
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
let texture = if *kind == SkullType::Player {
(scene.skull)(pos).unwrap_or(texture)
let skin = if *kind == SkullType::Player {
(scene.skull)(pos)
} else {
texture
None
};
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
+24 -1
View File
@@ -7,7 +7,12 @@ use crate::{
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path, sync::Arc};
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
sync::{Arc, OnceLock},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
@@ -94,6 +99,24 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
+23 -8
View File
@@ -40,6 +40,8 @@ pub struct Interaction {
/// The cauldron's new state.
pub block: Option<BlockState>,
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -57,6 +59,7 @@ fn pass() -> Interaction {
outcome: Outcome::Pass,
block: None,
sound: None,
stat: None,
}
}
@@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState {
}
}
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
Interaction {
outcome: Outcome::Success,
block: Some(block),
sound,
stat: Some(stat),
}
}
@@ -179,18 +183,22 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None);
return changed(lowered(cauldron), None, "clean_armor");
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
return pass();
}
filled(player, ItemStack::new(new, 1), true);
changed(state("minecraft:cauldron", None), Some(sound))
changed(
state("minecraft:cauldron", None),
Some(sound),
"use_cauldron",
)
};
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
filled(player, ItemStack::new("minecraft:bucket", 1), true);
changed(block, Some(sound))
changed(block, Some(sound), "fill_cauldron")
};
match item {
"minecraft:lava_bucket" => {
@@ -199,6 +207,7 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -221,6 +230,7 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -241,6 +251,7 @@ pub fn interact(
changed(
state("minecraft:water_cauldron", Some(1)),
Some("minecraft:item.bottle.empty"),
"use_cauldron",
)
}
("water", "minecraft:bucket") => fill_bucket(
@@ -255,7 +266,11 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -266,7 +281,7 @@ pub fn interact(
// BlockState.cycle on a 1..=3 property
next.properties
.insert("level".into(), (level % 3 + 1).to_string());
changed(next, Some("minecraft:item.bottle.empty"))
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
}
("water", name)
if name
@@ -283,7 +298,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None)
changed(lowered(cauldron), None, "clean_banner")
}
("water", name)
if name
@@ -292,7 +307,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None)
changed(lowered(cauldron), None, "clean_shulker_box")
}
("lava", "minecraft:bucket") => fill_bucket(
player,
+56 -1
View File
@@ -2,6 +2,7 @@
//! threads can mesh and light immutable snapshots while the game thread edits
//! (copy-on-write through `Arc::make_mut`).
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::{Arc, OnceLock};
@@ -155,7 +156,7 @@ pub struct Chunk {
/// positions, applied once by LevelChunk.postProcessGeneration.
pub postprocess: Vec<[i32; 3]>,
/// Saved data Horizon keeps but does not simulate (save.rs).
pub saved: Option<Arc<crate::save::ChunkExtras>>,
pub saved: Option<Arc<ChunkExtras>>,
/// Entities read from a save (vanilla entity NBT), spawned on arrival.
pub entities: Vec<horizon_world::nbt::Tag>,
/// Structure starts reaching into this chunk (`structures.References`):
@@ -432,3 +433,57 @@ mod tests {
}
}
}
/// A container's loot table and what Horizon rolled from it. Vanilla keeps
/// `LootTable` and `LootTableSeed` until the container is first opened;
/// Horizon rolls when it generates or loads the chunk, and saves the table
/// again while the container still holds exactly the rolled items.
#[derive(Clone, Debug, PartialEq)]
pub struct RolledLoot {
pub pos: (i32, i32, i32),
pub table: String,
/// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable).
pub seed: Option<i64>,
/// The rolled `Items`.
pub items: Tag,
}
impl RolledLoot {
/// Puts the loot table back in place of `Items` while they are still the
/// roll; false once the container holds anything else.
pub fn restore(&self, map: &mut Compound) -> bool {
if map.get(&"Items".into()) != Some(&self.items) {
return false;
}
map.remove(&"Items".into());
map.insert("LootTable".into(), Tag::String(self.table.as_str().into()));
if let Some(seed) = self.seed.filter(|s| *s != 0) {
map.insert("LootTableSeed".into(), Tag::Long(seed));
}
true
}
}
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
/// Chunk root keys written back as loaded (structures, PostProcessing,
/// InhabitedTime, blending data, ...).
pub root: Compound,
/// Block entities Horizon does not model.
pub block_entities: Vec<Tag>,
/// The NBT of the modelled block entities as loaded, to restore the fields and
/// detail Horizon does not model when it saves them (`block_entity_nbt::overlay`).
pub block_entity_sources: Vec<((i32, i32, i32), Tag)>,
/// Every saved block entity's position in the order of the save's list,
/// the order `SerializableChunkData` puts them in the chunk's maps.
pub block_entity_order: Vec<(i32, i32, i32)>,
/// Scheduled ticks (`block_ticks`/`fluid_ticks`): delays relative to the
/// save time. Registered with the level's tick scheduler on arrival and
/// replaced with the scheduler's pending ticks when saving.
pub block_ticks: Vec<horizon_core::ticks::SavedTick>,
pub fluid_ticks: Vec<horizon_core::ticks::SavedTick>,
/// Game time written as `LastUpdate`.
pub last_update: i64,
/// Containers still holding their loot table's roll.
pub rolled_loot: Vec<RolledLoot>,
}
+141 -5
View File
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
}
// Vanilla scopes this direction to Entity.move while a piston pushes it.
thread_local! {
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
}
/// Suppress the moving part of pistons moving in this direction during a push.
pub fn with_piston_noclip<T>(
direction: horizon_core::geometry::Direction,
f: impl FnOnce() -> T,
) -> T {
struct Reset(Option<horizon_core::geometry::Direction>);
impl Drop for Reset {
fn drop(&mut self) {
PISTON_NOCLIP.with(|value| value.set(self.0));
}
}
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
let _reset = Reset(previous);
f()
}
pub struct SceneCollisions<'w, 'r> {
pub world: &'w World,
pub binding: &'w BlockCollisionRegistry<'r>,
@@ -140,8 +161,21 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
/// `SceneCollisions::resolve` that also stops at the entity boxes.
impl<'a, W> EntityCollisions<'a, W>
where
W: BlockCollisionWorld<Error = io::Error> + EntityMovementWorld<Error = io::Error>,
{
/// `EntityCollisions` for a body the world border also stops: the border
/// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`).
pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self {
Self {
blocks,
entities: &[],
border: Some((border, at)),
}
}
/// `SceneCollisions::resolve` that also stops at the entity boxes and,
/// with a border, at the world border's walls.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = self.blocks.context(bounds);
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context,
context: CollisionContext::position(bounds.min_position().y),
bounds,
movement,
max_step,
@@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
return Ok(None);
};
pistons
.collision_shape(self, p, piston, None)
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
.map(Some)
.map_err(io::Error::other)
}
@@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
}
}
/// A collision world without blocks: no chunk is resident, so only the
/// world border stops a body.
pub struct EmptyCollisions;
impl BlockCollisionWorld for EmptyCollisions {
type Error = io::Error;
type Chunk = ChunkPos;
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(None)
}
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
Err(io::Error::other("no chunk is resident"))
}
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
Ok(false)
}
fn context_shape(
&mut self,
_: CollisionBlock,
_: BlockPos,
_: &CollisionContext<'_>,
) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
}
impl EntityMovementWorld for EmptyCollisions {
type Error = io::Error;
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
Ok(Vec::new())
}
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
/// Collision queries over any block lookup (test worlds without chunks):
/// every chunk is present, nothing is suffocating, no entities collide.
pub struct LookupCollisions<'w, 'r> {
@@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
Ok(false)
}
}
#[cfg(test)]
mod piston_noclip_tests {
use super::*;
use horizon_core::geometry::Direction;
#[test]
fn piston_noclip_is_scoped_and_restored_after_unwind() {
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
with_piston_noclip(Direction::East, || {
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
let _ =
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
});
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
}
}
#[cfg(test)]
mod border_movement_tests {
use super::*;
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
fn border() -> BorderGeometry {
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
}
/// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`:
/// a body inside close to the border collides with the border's walls.
#[test]
fn a_body_next_to_the_border_stops_at_its_wall() {
// A body 0.6 wide, 0.4 blocks short of the wall, moved east into it.
let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]);
let moved = collisions
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
// The wall stops the body 0.4 blocks short of the whole move.
assert!(
(moved.x - 0.4).abs() < 1.0e-9,
"the body was not stopped by the border: {moved:?}"
);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]);
let moved = collisions
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.51).abs() < 1.0e-9,
"the body did not move the whole way: {moved:?}"
);
}
}
@@ -35,6 +35,23 @@ impl Mode {
Self::Redstone => "minecraft:command_block",
}
}
/// `Enum.ordinal`, which `ServerboundSetCommandBlockPacket` writes.
pub fn ordinal(self) -> i32 {
match self {
Self::Sequence => 0,
Self::Auto => 1,
Self::Redstone => 2,
}
}
/// The mode of a packet's ordinal; `handleSetCommandBlock` treats any
/// other value as a plain command block (its `default` branch).
pub fn from_ordinal(ordinal: i32) -> Self {
match ordinal {
0 => Self::Sequence,
1 => Self::Auto,
_ => Self::Redstone,
}
}
/// `CycleButton`: the next value, wrapping round.
pub fn next(self) -> Self {
match self {
+27 -1
View File
@@ -190,6 +190,9 @@ impl Skull {
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<crate::skins::NameOrId>,
pub fallback: &'static str,
}
impl SkullSkin {
@@ -198,10 +201,21 @@ impl SkullSkin {
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
Some((url, location))
});
// A `Dynamic` profile (a name or id only) resolves through the
// services when the skull is drawn.
let dynamic = crate::skins::dynamic_nbt(profile);
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
let fallback = crate::skins::profile_uuid_nbt(profile)
.or_else(|| {
crate::skins::nbt_text(profile.get("name"))
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
})
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self { download, fallback }
Self {
download,
dynamic,
fallback,
}
}
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
@@ -728,6 +742,18 @@ impl Furnace {
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
+12 -8
View File
@@ -179,13 +179,14 @@ impl PlayerEffects {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, visible).
/// duration, amplifier, ambient, particles visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, _) in saved {
for (id, duration, amplifier, ambient, particles) in saved {
if let Some(def) = brewing::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
effects.add(e);
}
}
@@ -213,8 +214,9 @@ impl PlayerEffects {
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick). Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -224,6 +226,7 @@ impl PlayerEffects {
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
let mut actions = Vec::new();
@@ -246,6 +249,7 @@ impl PlayerEffects {
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
match answer {
@@ -465,13 +469,13 @@ mod tests {
};
// The first tick adds Raid Omen and stops there (the iterator's
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
let (_, added) = fx.tick_with(&mut convert);
let (_, added) = fx.tick_with(&mut convert, false);
assert!(added);
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
assert_eq!(fx.get("speed").unwrap().duration, 50);
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
// The second refreshes Raid Omen and drops Bad Omen.
let (_, added) = fx.tick_with(&mut convert);
let (_, added) = fx.tick_with(&mut convert, false);
assert!(!added);
assert!(fx.get("bad_omen").is_none());
assert_eq!(fx.get("speed").unwrap().duration, 49);
@@ -486,9 +490,9 @@ mod tests {
seen.push(e.duration);
OmenTick::Remove
};
fx.tick_with(&mut watch);
fx.tick_with(&mut watch, false);
assert!(fx.get("raid_omen").is_some());
fx.tick_with(&mut watch);
fx.tick_with(&mut watch, false);
assert!(fx.get("raid_omen").is_none());
assert_eq!(seen, vec![1]);
}
+8 -4
View File
@@ -582,17 +582,21 @@ impl Experience {
7 + level * 2
}
}
/// Returns true when a level was gained.
pub fn give(&mut self, points: i32) -> bool {
let start = self.level;
/// Returns the first multiple of 5 the player reached, where each level
/// gained would play the level-up sound (`Player.giveExperienceLevels`).
pub fn give(&mut self, points: i32) -> Option<i32> {
let mut sound_level = None;
self.progress += points as f32 / Self::needed(self.level) as f32;
self.total = self.total.saturating_add(points).max(0);
while self.progress >= 1.0 {
self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32;
self.level += 1;
if self.level % 5 == 0 {
sound_level.get_or_insert(self.level);
}
self.progress /= Self::needed(self.level) as f32;
}
self.level > start
sound_level
}
}
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
+8 -1
View File
@@ -203,7 +203,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
color *= in.light_color;
if in.overlay == 7u || in.overlay == 8u {
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
// enchanted_glint sheen drifting diagonally over the item's shape.
let px = in.uv * vec2<f32>(textureDimensions(atlas));
@@ -219,6 +219,13 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
+106 -1
View File
@@ -187,6 +187,31 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
// LivingEntity.addAdditionalSaveData: `active_effects` only when there are
// any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one
// given with `hideParticles` shows neither particles nor its icon).
if m.extra.effects.is_empty() {
map.remove(&horizon_world::nbt::JavaString::from("active_effects"));
} else {
let effects = m
.extra
.effects
.iter()
.map(|&(effect, duration, amplifier)| {
let shown = !m.extra.hidden_effects.contains(&effect);
let mut e = vec![
("id", string(crate::brewing::namespaced(effect))),
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
("duration", Tag::Int(duration)),
];
if !shown {
e.push(("show_particles", Tag::Byte(0)));
}
e.push(("show_icon", Tag::Byte(i8::from(shown))));
Tag::compound(e)
});
map.insert("active_effects".into(), Tag::List(effects.collect()));
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
@@ -466,7 +491,32 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:axolotl" => write_axolotl_memories(m, map),
"minecraft:axolotl" => {
write_axolotl_memories(m, map);
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:parrot" => {
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:salmon" => {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
map.insert("type".into(), string(size));
}
// TropicalFish.addAdditionalSaveData: the packed variant.
"minecraft:tropical_fish" => {
let pattern = m.extra.variant.min(11);
let packed = i32::from(pattern >= 6)
| i32::from(pattern % 6) << 8
| i32::from(m.extra.variant2 & 15) << 16
| i32::from(m.extra.variant3 & 15) << 24;
map.insert("Variant".into(), Tag::Int(packed));
}
"minecraft:allay" => {
// InventoryCarrier: the fetched stack; the liked player memory.
let mut inv = Vec::new();
@@ -900,6 +950,25 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.no_gravity = flag(tag, "NoGravity");
m.glowing = flag(tag, "Glowing");
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
// LivingEntity.readAdditionalSaveData: the saved `active_effects`, the last
// entry of an effect winning.
if let Some(Tag::List(effects)) = tag.get("active_effects") {
for e in effects {
let Some(def) = text(e.get("id")).and_then(|id| crate::brewing::effect(&id)) else {
continue;
};
let effect = crate::brewing::namespaced(def.id);
let duration = number(e.get("duration")).unwrap_or(0) as i32;
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
m.extra.effects.retain(|x| x.0 != effect);
m.extra.effects.push((effect, duration, amplifier));
// Details.CODEC: `show_particles` defaults to true.
m.extra.hidden_effects.retain(|h| *h != effect);
if number(e.get("show_particles")) == Some(0) {
m.extra.hidden_effects.push(effect);
}
}
}
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
if m.species() == crate::mobs::Species::Bat {
@@ -1048,6 +1117,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if m.species() == crate::mobs::Species::Axolotl {
restore_axolotl_memories(&mut m, tag);
}
read_variant(&mut m, tag);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
if m.species() == crate::mobs::Species::Allay {
@@ -2229,6 +2299,41 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
}
}
}
/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and
/// `TropicalFish` read; a missing tag is the default variant (a medium
/// salmon, otherwise index 0).
fn read_variant(m: &mut Mob, tag: &Tag) {
use crate::mobs::Species as S;
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
match m.species() {
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
S::Rabbit => {
m.extra.variant = match int("RabbitType") {
v @ (0..=5 | 99) => v as u8,
_ => 0,
}
}
S::Salmon => {
m.extra.variant = text(tag.get("type"))
.and_then(|t| SALMON_SIZES.iter().position(|v| *v == t))
.unwrap_or(1) as u8;
}
S::TropicalFish => {
let packed = int("Variant");
let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF);
// TropicalFish.Pattern.byId: an unknown id is a kob.
m.extra.variant = if size <= 1 && index < 6 {
(size * 6 + index) as u8
} else {
0
};
m.extra.variant2 = ((packed >> 16) & 15) as u8;
m.extra.variant3 = ((packed >> 24) & 15) as u8;
}
_ => {}
}
}
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
let ttl = memories
+8 -3
View File
@@ -889,12 +889,17 @@ impl Font {
if s.contains('§') {
return self.width_text(&Text::legacy(s));
}
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
crate::bidi::visual_str(s)
.chars()
.map(|c| self.glyph(c).advance)
.sum::<f32>()
.ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs.
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
pub fn width_text(&self, t: &Text) -> f32 {
t.resolved()
let t = t.resolved();
crate::bidi::visual(&t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
+336 -48
View File
@@ -63,9 +63,9 @@ mod frames;
mod friends;
mod functions;
mod game_mode;
mod game_rules_screen;
mod gamemode_switcher;
mod ghasts;
mod glowing;
mod golems;
mod growth;
mod hand;
@@ -133,6 +133,7 @@ mod persist;
mod perspective;
mod pickups;
mod piglins;
mod piston_push;
mod player_equip;
mod postprocess;
mod potions;
@@ -571,6 +572,8 @@ struct GameLevel<'w> {
player_collision: Option<PlayerCollision>,
/// Minecart boxes (detector rails).
cart_boxes: Vec<([f64; 3], [f64; 3])>,
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
cart_signals: Vec<(minecarts::CartKind, i32)>,
/// Arrow and thrown trident boxes (wooden buttons).
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
/// The other entities that keep blocks from being placed into them.
@@ -649,6 +652,37 @@ impl EntityObstructionWorld for BuildingObstruction {
}
}
impl GameLevel<'_> {
/// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the
/// minecart on it, a command block cart first (its success count, which
/// is 0 here), else the first container cart's fullness.
fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 {
if !powered {
return 0;
}
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
let area = Aabb::new(
Vec3::new(x + 0.2, y, z + 0.2),
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
);
let mut container = None;
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
let cart = Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
);
if !area.intersects(cart) {
continue;
}
match kind {
minecarts::CartKind::CommandBlock => return 0,
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
container = container.or(Some(signal));
}
_ => {}
}
}
container.unwrap_or(0)
}
/// Every entity box that blocks building: the players who aren't
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
/// armor stand, whose box is empty, doesn't).
@@ -709,6 +743,14 @@ impl LevelAccess for GameLevel<'_> {
state: BlockStateId,
_direction: Direction,
) -> i32 {
if let Some(s) = self.registry.state(state)
&& s.name == "minecraft:detector_rail"
{
return self.detector_rail_signal(
pos,
s.properties.get("powered").is_some_and(|v| v == "true"),
);
}
if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) {
return signal;
}
@@ -1348,6 +1390,8 @@ struct Game<'a> {
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
/// while `pack_ids` is set (game/shader_packs.rs).
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// The glowing entities' silhouettes this frame (game/glowing.rs).
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
/// The shader pack's entity and block entity ids, while one draws entities.
pack_ids: Option<shader_packs::PackIds>,
started: Instant,
@@ -1569,6 +1613,9 @@ struct Game<'a> {
/// Player skin downloads, and which skin each atlas skin slot holds
/// (least recently shown first).
skin_downloads: RefCell<horizon_client::skins::Downloader>,
/// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads
/// and mannequins) resolve through the services in the background.
skin_profiles: RefCell<horizon_client::skins::Resolver>,
skin_slots: Vec<(String, usize)>,
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
trim_slots: Vec<(String, usize)>,
@@ -2050,9 +2097,11 @@ impl Game<'_> {
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
/// (decreaseAirSupply), the wings, leather boots on powder snow
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
/// (LivingEntity.canFreeze) and the dimension's FAST_LAVA.
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA and the sleeping
/// pose.
pub(crate) fn sync_player_body(&mut self) {
let glider = self.glider_worn();
let sleeping = self.sleeping.is_some();
let item_use_speed = self
.using
.and_then(|(slot, _)| {
@@ -2080,6 +2129,7 @@ impl Game<'_> {
player.item_use_speed = item_use_speed;
player.respiration = respiration;
player.glider = glider;
let _ = player.set_sleeping(sleeping);
player.leather_boots = leather_boots;
player.freeze_immune = freeze_immune;
player.fluids.fast_lava = self.dim == Dim::Nether;
@@ -2467,6 +2517,15 @@ impl Game<'_> {
no_physics,
&self.res.motion().fluids,
);
// Entity.applyEffectsFromBlocks: bubble columns lift or drag the item.
horizon_client::motion::bubble_column_push(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
item.bounds(),
&mut item.velocity,
);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
// blocks, every 40 otherwise, before the lifetime check.
@@ -2575,8 +2634,14 @@ impl Game<'_> {
pickups::Taken::Item(Box::new(item.clone())),
pickups::Collector::Player,
);
// handleTakeItemEntity: the random pitch of a full pickup.
let pitch = ((self.world.random.next_float()
- self.world.random.next_float())
* 0.7
+ 1.0)
* 2.0;
self.audio
.play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0);
.play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch);
}
item.stack = rest;
}
@@ -2633,6 +2698,7 @@ impl Game<'_> {
)
})
.collect();
let cart_signals = self.cart_signals();
let arrow_boxes = self
.creatures
.projectiles
@@ -2675,6 +2741,7 @@ impl Game<'_> {
};
let mut level = GameLevel {
cart_boxes,
cart_signals,
arrow_boxes,
blockers,
world: &mut self.world,
@@ -2927,20 +2994,29 @@ impl Game<'_> {
b.run_block_events(level, ticks);
b.tick_block_entities(level, ticks);
});
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
// with the progress it is about to take.
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.process_level_events()?;
self.sync_moving_pistons();
self.process_level_events()
Ok(())
}
/// Copies the moving-piston block entities into the world, where the
/// collision queries and the renderer read them.
fn sync_moving_pistons(&mut self) {
let pistons = &mut self.world.moving_pistons;
pistons.clear();
pistons.extend(
self.block_ticks
.moving_pistons
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())),
);
let previous = std::mem::take(&mut self.world.moving_pistons);
let current: Vec<_> = self
.block_ticks
.moving_pistons
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
.collect();
self.world.moving_piston_fallback = previous
.into_iter()
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
.collect();
self.world.moving_pistons = current;
}
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
/// the side effects of each (a shrieker summoning a warden, drops, game
@@ -3071,6 +3147,34 @@ impl Game<'_> {
pos,
data,
} => self.composter_fill_event(pos, data == 1),
// LevelEventHandler 1504: the stalactite's drip particle.
LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos),
// LevelEventHandler 2006: the dragon fireball's burst.
LevelEvent::LevelEvent {
id: 2006,
pos,
data,
} => self.dragon_fireball_burst(pos, data),
// LevelEventHandler 2009: a wet sponge drying.
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
}
// LevelEventHandler 2012: a turtle egg laid.
LevelEvent::LevelEvent {
id: 2012,
pos,
data,
} => self.turtle_egg_particles(pos, data),
// LevelEventHandler 2014: a straw bed destroyed, with no sound.
LevelEvent::LevelEvent {
id: 2014,
pos,
data,
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
// LevelEventHandler 3009: a sniffer egg cracking.
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
self.sniffer_egg_crack_particles(pos)
}
LevelEvent::LevelEvent {
id: id @ 3011..=3021,
pos,
@@ -3086,6 +3190,16 @@ impl Game<'_> {
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)),
1034 => Some(("minecraft:block.chorus_flower.death", 1.0)),
1046 => Some((
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
2.0,
)),
1047 => Some((
"minecraft:block.pointed_dripstone.drip_water_into_cauldron",
2.0,
)),
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
@@ -3107,6 +3221,13 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
// LevelEventHandler draws these from the client
// level's random, before 1501's and 1502's smoke.
1501 | 1502 => {
let r = self.particles.level_random();
2.6 + (r.next_float() - r.next_float()) * 0.8
}
1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9,
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
@@ -3125,6 +3246,15 @@ impl Game<'_> {
self.audio.play(name, c, volume, pitch);
}
}
// LevelEventHandler 1501 and 1502 draw their smoke after the sound.
match id {
1501 => self.puffs_above_block(
pos,
horizon_client::particles::ParticleType::LargeSmoke,
),
1502 => self.torch_burnout_smoke(pos),
_ => {}
}
}
LevelEvent::Sound {
pos,
@@ -3191,6 +3321,8 @@ impl Game<'_> {
// Popped as the behaviour emits it (GameLevel::emit).
PlayerEffect::DropItem(_) => {}
PlayerEffect::EatCake => {
// CakeBlock.eat
self.stats.custom("eat_cake_slice", 1);
if let Some(p) = self.player.as_mut() {
let food = &mut p.health.food;
food.food = (food.food + 2).min(20);
@@ -3324,11 +3456,18 @@ impl Game<'_> {
self.open_command_block(key);
return;
}
// SignBlock.useWithoutItem: the text editor, not a container.
if horizon_client::signs::sign_kind(&name).is_some() {
self.use_sign_empty_hand(pos);
return;
}
if &*name == "minecraft:crafting_table" {
self.craft_size = 3;
self.craft_table = Some(pos);
} else if &*name == "minecraft:ender_chest" {
self.open_ender_chest(key);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
self.container_opener_event(key, true);
let at = [
f64::from(key.0) + 0.5,
@@ -3720,6 +3859,21 @@ impl Game<'_> {
{
self.hive_broken_by_player(pos, hive, honey, facing);
}
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
if facts.name.starts_with("minecraft:infested_")
&& !silk
&& self.ui.rules.bool("block_drops")
{
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
}
// SpawnerBlock.spawnAfterBreak pops its experience whatever the tool.
let xp = if silk && &*facts.name != "minecraft:spawner" {
0
@@ -3841,6 +3995,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_sign_applicator(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
}
if self.use_jukebox(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
@@ -3900,6 +4058,20 @@ impl Game<'_> {
)
});
self.process_level_events()?;
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
if result == horizon_world::level::InteractionResult::Success {
let name = self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name
.clone();
match &*name {
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
"minecraft:bell" => self.stats.custom("bell_ring", 1),
_ => {}
}
}
if result.consumes_action() {
self.swing_arm();
return Ok(true);
@@ -4688,7 +4860,6 @@ impl Game<'_> {
player.dimensions.eye_height
};
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
// `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the
// glide's wear): both draw from the level random.
if let Some((at, velocity)) = self.tick_soul_speed() {
@@ -4724,10 +4895,12 @@ impl Game<'_> {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
MotionEvent::Emission {
state, in_water, ..
} if !crouching => {
} => {
let sounds = self.res.facts().get(state).sounds.clone();
// Entity.playStepSound: the block's step sound at 0.15 volume,
// muffled under the swim sound in water.
// muffled under the swim sound in water. Whether it plays is
// Player.getMovementEmission's call (shift on the ground),
// not the pose: a forced crouch still steps.
let (volume, pitch) = if in_water {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
@@ -4739,6 +4912,14 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
MotionEvent::Drown => {
// The guests see the entity event 67 too.
if let Some(host) = &self.lan {
@@ -4781,6 +4962,10 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
// Player.causeFallDamage: a fall of 2 blocks or more counts.
MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => {
self.stats.custom("fall_one_cm", cm)
}
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
@@ -4848,6 +5033,7 @@ impl Game<'_> {
}
}
self.powder_snow_fire()?;
self.rain_extinguish_in_frame();
self.tick_totem();
let Some(player) = self.player.as_mut() else {
return Ok(());
@@ -7922,13 +8108,15 @@ impl Game<'_> {
}
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
self.particles.add(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
),
at,
[0.0; 3],
let sweep = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
);
self.particles.add(&sweep, at, [0.0; 3]);
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
// the offset is the one from the player to the particle.
let origin = from.unwrap_or(at);
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
self.lan_send_particles(&sweep, at, spread, 0.0, 0);
self.audio
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
}
@@ -8083,11 +8271,6 @@ impl Game<'_> {
let sweep = self.sweep_attack_position(scale, crit);
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
let player = self.player.as_ref();
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
if knockback_attack {
@@ -8167,6 +8350,14 @@ impl Game<'_> {
0.2,
(actual * 0.5) as u32,
);
// The players a server serves are sent them too.
self.lan_send_particles(
&indicator,
[feet[0], feet[1] + height * 0.5, feet[2]],
[0.1, 0.0, 0.1],
0.2,
(actual * 0.5) as u32,
);
}
self.last_hurt_by_player
.insert(self.mobs[index].id, self.lan_acting);
@@ -8197,6 +8388,13 @@ impl Game<'_> {
}
}
self.weapon_hit(index);
// Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt
// (inside `if (wasHurt)`), after damageStatsAndHearts.
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
} else {
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
@@ -8760,6 +8958,10 @@ impl Game<'_> {
}
self.lan_close(false);
self.save_game()?;
if let Some(mut remote) = self.remote.take() {
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
remote.session.disconnect();
}
self.saved_for_exit = true;
Ok(())
}
@@ -9041,6 +9243,9 @@ impl Game<'_> {
if result.outcome == Outcome::Pass {
return Ok(false);
}
if let Some(stat) = result.stat {
self.stats.custom(stat, 1);
}
if let Some(block) = result.block
&& let Ok(new) = registry.resolve(&block)
{
@@ -9150,6 +9355,8 @@ impl Game<'_> {
else {
return Ok(false);
};
// BlockTransformer.transformBlock: ITEM_USED_ON_BLOCK before the swap.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
self.level_set(pos, new)?;
// BlockTransformer.TransformParticle.send.
self.transform_particle(&particle, pos);
@@ -9422,6 +9629,8 @@ impl Game<'_> {
if let Some(waxed) = old.and_then(|old| self.res.transformers().waxed(registry, &old))
&& let Ok(state) = registry.resolve(&waxed)
{
// HoneycombItem.useOn: ITEM_USED_ON_BLOCK before the swap.
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
self.level_set(hit.pos, state)?;
self.level_event_at(3003, hit.pos, 0);
self.audio.play(
@@ -9743,16 +9952,13 @@ impl Game<'_> {
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let at = self.eye_position();
if self.experience.give(gained as i32)
&& self.experience.level % 5 == 0
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = self.experience.give(gained as i32)
&& self.ticks > self.last_level_up + 100
{
self.last_level_up = self.ticks;
let volume = if self.experience.level > 30 {
1.0
} else {
self.experience.level as f32 / 30.0
};
let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 };
self.audio
.play("minecraft:entity.player.levelup", at, volume * 0.75, 1.0);
}
@@ -10122,6 +10328,14 @@ impl Game<'_> {
};
Some(t < 0.15)
}
/// `Level.isRainingAt`: it rains, the sky light is 15, nothing that stops
/// motion is above, and the biome's precipitation there is rain.
fn raining_at(&self, p: BlockPos) -> bool {
self.world.weather.levels(1.0).0 > 0.2
&& self.world.light(p.x, p.y, p.z) >> 4 == 15
&& self.rain_height(p.x, p.z) <= p.y
&& self.precipitation(p.x, p.y, p.z) == Some(false)
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block in `blocks_motion_in_heightmap` or holding a fluid.
fn rain_height(&self, x: i32, z: i32) -> i32 {
@@ -10158,12 +10372,15 @@ impl Game<'_> {
} else {
("minecraft:weather.rain", 0.2, 1.0)
};
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
// The Horizon Weather option off silences the rain too.
if self.settings.horizon.weather {
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
}
}
}
}
@@ -10706,6 +10923,9 @@ impl Game<'_> {
let level = &mut self.level;
level.creative = self.creative;
level.game_mode = mode;
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
level.difficulty =
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
level.time = self.world.game_time;
level.weather = self.world.weather;
level.day_time = self.world.day_time;
@@ -10861,6 +11081,21 @@ impl Game<'_> {
}
return Ok(false);
}
// A server pack's reload covers the frame with its progress
// (`LoadingOverlay`) until `tick_server_packs` applies it.
if let Some(reload) = &self.server_packs.reload {
if let Some(renderer) = &mut self.renderer {
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
horizon_client::level_loading::draw_progress(
&mut hud,
&self.gui.atlas,
reload.progress.line(),
reload.progress.fraction(),
);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
}
let Some(size) = self
.renderer
.as_ref()
@@ -10981,7 +11216,11 @@ impl Game<'_> {
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
let outline = if self.screen == Screen::Playing && !self.spectator() {
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
// player who may build. Adventure players only get one with a CAN_BREAK or
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
// components nor opens menus for spectators, so those never show it.
let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
self.pick()
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
@@ -11066,6 +11305,7 @@ impl Game<'_> {
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
self.see_through_vertices.clear();
self.glowing_vertices.get_mut().clear();
if !title {
entities = horizon_client::spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, entities)
@@ -11133,6 +11373,7 @@ impl Game<'_> {
// map_patches any earlier leaves them a frame behind.
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -11177,6 +11418,7 @@ impl Game<'_> {
dark_disc: self.dark_disc(camera),
outline,
outline_after_terrain,
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -11198,6 +11440,7 @@ impl Game<'_> {
}),
entities: &entities,
entity_spans: &entity_spans,
glowing: &glowing,
dynamic_lights: &self.dynamic_lights,
lightning: horizon_client::gfx::lightning::Input {
bolts: &bolts,
@@ -11236,7 +11479,7 @@ impl Game<'_> {
}),
ticks: self.ticks,
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
night_vision: self.night_vision_intensity(alpha as f32),
block_flicker: self.fog.block_light_flicker,
boss_darken: self
.boss_overlay
@@ -11253,6 +11496,7 @@ impl Game<'_> {
// Restore storage even when rendering fails (for example, a lost surface).
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
*self.glowing_vertices.get_mut() = glowing;
self.see_through_vertices = see_through;
result
}
@@ -11943,6 +12187,19 @@ impl ApplicationHandler for Game<'_> {
{
return;
}
// The pack reload's progress overlay takes no input
// (`LoadingOverlay`): `start_pack_reload` released what was held.
if self.server_packs.reload.is_some()
&& !matches!(
&event,
WindowEvent::CloseRequested
| WindowEvent::Focused(_)
| WindowEvent::Resized(_)
| WindowEvent::RedrawRequested
)
{
return;
}
if self.pending_save.is_some() {
match event {
WindowEvent::CloseRequested => {
@@ -12166,7 +12423,8 @@ impl ApplicationHandler for Game<'_> {
let (action, sound) = self.title.as_mut().map_or((Action::None, false), |t| {
let clicked = t.click(self.cursor.0, self.cursor.1, (b.width, b.height));
t.press_field(atlas, self.cursor, (b.width, b.height), shift);
clicked
let rules = t.press_rules(atlas, self.cursor, (b.width, b.height));
(clicked.0, clicked.1 || rules)
});
self.save_title_options();
if sound {
@@ -12744,6 +13002,16 @@ impl ApplicationHandler for Game<'_> {
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0
&& self.pending_save.as_ref().is_none_or(|save| !save.quit)
{
// SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close.
if let Err(error) = self.begin_closing_save(true) {
self.error = Some(error);
event_loop.exit();
return;
}
}
if self.pending_save.is_some() {
match self.poll_closing_save() {
Ok(true) => event_loop.exit(),
@@ -12944,6 +13212,7 @@ impl<'a> Game<'a> {
luminance: None,
see_through_vertices: Vec::new(),
entity_spans: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
started: now,
@@ -13094,6 +13363,7 @@ impl<'a> Game<'a> {
.unwrap_or_default()
.join("assets/skins"),
)),
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
skin_slots: Vec::new(),
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
@@ -13439,7 +13709,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
KeyCode::Numpad9,
];
const STAGES: u32 = 10;
const STAGE_LINES: [&str; 10] = [
"Loading menu",
"Loading assets",
@@ -13455,19 +13724,28 @@ const STAGE_LINES: [&str; 10] = [
/// The background loader's checkpoint, read by the loading screen each frame.
#[derive(Clone)]
struct Progress(Arc<AtomicU32>);
struct Progress {
value: Arc<AtomicU32>,
lines: &'static [&'static str],
}
impl Progress {
fn new() -> Self {
Self(Arc::new(AtomicU32::new(0)))
Self::with(&STAGE_LINES)
}
fn with(lines: &'static [&'static str]) -> Self {
Self {
value: Arc::new(AtomicU32::new(0)),
lines,
}
}
fn set(&self, stage: u32) {
self.0.store(stage, Ordering::Relaxed);
self.value.store(stage, Ordering::Relaxed);
}
fn fraction(&self) -> f32 {
self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
}
fn line(&self) -> &'static str {
STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)]
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
}
}
@@ -13921,6 +14199,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -13928,6 +14207,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
nether_lighting: false,
entities: &[],
entity_spans: &[],
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
@@ -14233,6 +14513,11 @@ impl Loading {
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0 {
// No world is open yet: nothing to save.
event_loop.exit();
return;
}
if let Err(error) = self.poll_menu_resources() {
self.error = Some(error);
event_loop.exit();
@@ -14510,6 +14795,9 @@ pub fn run() -> Result<()> {
{
horizon_client::heartbeat::start();
}
if options.execution_role.is_client() {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
@@ -708,6 +708,27 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
let villager = villager.clone();
let it = item_view(stack);
self.trigger("minecraft:villager_trade", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&villager),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["villager"], &vctx, w)
&& adv::item(&c["item"], &it, w)
});
}
/// `EnterBlockTrigger`: the block, its state properties and the player.
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
+2 -1
View File
@@ -131,7 +131,8 @@ impl Game<'_> {
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
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,
@@ -547,7 +547,7 @@ impl Game<'_> {
});
}
/// `Entity.isInRain`: rain at the block position or at the top of the box.
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
pub(super) fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
let p = BlockPos::new(
at.x.floor() as i32,
at.y.floor() as i32,
@@ -725,21 +725,38 @@ impl Game<'_> {
/// the player head at `pos`. The skin download is started here; while it
/// is in flight (or when it cannot be got) the profile's default skin
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<String> {
let Some(BlockEntity::Skull(skull)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
return None;
};
let skin = skull.skin.as_ref()?;
if let Some((url, location)) = &skin.download {
self.skin_downloads
.borrow_mut()
.state(skins::Kind::Skin, url);
if self.gui.atlas.sprites.contains_key(location) {
// The texture a packed or resolved `textures` property fetches: the
// download of a static profile, or the one the services answer for a
// `Dynamic` (name- or id-only) profile.
let download = skin.download.clone().or_else(|| {
let key = skin.dynamic.as_ref()?;
let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key)
else {
return None;
};
let (value, signature) = profile.textures()?;
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
let location = skins::skin_location(&skins::texture_hash(&url)?);
Some((url, location))
});
if let Some((url, location)) = download {
let mut downloads = self.skin_downloads.borrow_mut();
if self.gui.atlas.sprites.contains_key(&location) {
let _ = downloads.state(skins::Kind::Skin, &url);
return Some(location);
}
// Its slot was evicted: offer the image again.
if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready {
downloads.again(&location);
}
}
Some(skin.fallback)
Some(skin.fallback.to_owned())
}
}
+13 -35
View File
@@ -789,6 +789,11 @@ impl Game<'_> {
pub(super) fn sync_rules(&mut self) {
self.world.health_rules.natural_regeneration =
self.ui.rules.bool("natural_health_regeneration");
// Player.isInvulnerableTo: the damage-type rules.
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
use horizon_world::food::Difficulty;
self.world.health_rules.difficulty = match self.ui.difficulty {
0 => Difficulty::Peaceful,
@@ -801,7 +806,11 @@ impl Game<'_> {
pub(super) fn load_world_state(&mut self) {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.clock = WorldClock::default();
self.ui.difficulty = read_difficulty(&self.world_root).unwrap_or(2);
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = horizon_client::title::DIFFICULTIES
.iter()
.position(|d| *d == self.level.difficulty)
.map_or(2, |i| i as u8);
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
&& let Ok((root, _)) =
@@ -826,9 +835,9 @@ impl Game<'_> {
self.build_registries();
self.sync_rules();
}
/// Save the clock and the difficulty beside level.dat (Level.write saves
/// the game rules: a second writer of game_rules.dat could leave it with
/// the rules loaded when the world opened).
/// Save the clock beside level.dat (Level.write saves the game rules: a
/// second writer of game_rules.dat could leave it with the rules loaded
/// when the world opened).
pub(super) fn save_world_state(&mut self) {
if self.world_root.as_os_str().is_empty() {
return;
@@ -876,9 +885,6 @@ impl Game<'_> {
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
@@ -988,34 +994,6 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
n += 1;
}
}
fn read_difficulty(world: &std::path::Path) -> Option<u8> {
let bytes = std::fs::read(world.join("level.dat")).ok()?;
let raw = horizon_world::compression::Compression::Gzip
.decode(&bytes, 64 << 20)
.ok()?;
let (root, _) =
horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?;
match root.tag.get("Data")?.get("Difficulty")? {
horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8),
_ => None,
}
}
/// Patch `Data.Difficulty` in level.dat (Level::write stores the default).
fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> {
use horizon_world::{compression::Compression, nbt};
let path = world.join("level.dat");
let bytes = std::fs::read(&path)?;
let raw = Compression::Gzip.decode(&bytes, 64 << 20)?;
let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?;
if let Some(data) = root.tag.get_mut("Data")
&& let Some(d) = data.get_mut("Difficulty")
{
*d = nbt::Tag::Byte(difficulty as i8);
}
let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?;
std::fs::write(&path, Compression::Gzip.encode(&out)?)?;
Ok(())
}
/// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when
/// commands are allowed, else `ALL` (0).
+2 -2
View File
@@ -123,8 +123,8 @@ impl Game<'_> {
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
let pitch = self.audio.hurt_pitch();
self.audio.play(sound, Some(at), 1.0, pitch);
// Player.getVoicePitch: always 1.0.
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// `CombatTracker.recordDamage` for the motion events of this tick.
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
@@ -2,7 +2,7 @@
//! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand`
//! through the command system with the block's output, and
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
use super::{Game, Screen, commands::execute::Source};
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
use horizon_client::{
command_block::{self, CommandBlock, Mode},
containers::BlockEntity,
@@ -12,6 +12,7 @@ use horizon_client::{
world::World,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Serverbound as S};
use horizon_world::{
chunk::BlockState,
level::flags,
@@ -35,6 +36,16 @@ pub(super) struct CommandBlockScreen {
auto: bool,
}
/// What `ServerboundSetCommandBlockPacket` carries.
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum Button {
Output,
@@ -186,6 +197,18 @@ impl Game<'_> {
pub(super) fn can_use_game_master_blocks(&self) -> bool {
self.creative && self.ui.registries.permission >= 2
}
/// `CommandBlock.useWithoutItem` on a server's client, which opens the
/// screen itself (`Player.openCommandBlock`) while the server only
/// answers the click: the block at `pos`, when it is a command block the
/// player may edit.
pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) {
let is_command_block = self
.block_state_at(pos)
.is_some_and(|state| Mode::of_block(&state.name).is_some());
if is_command_block && self.can_use_game_master_blocks() {
self.open_command_block(pos);
}
}
fn command_block(&self, pos: Pos) -> Option<&CommandBlock> {
match self.world.block_entities.get(&pos)? {
BlockEntity::CommandBlock(c) => Some(c),
@@ -572,12 +595,66 @@ impl Game<'_> {
s.command.insert_text(&allowed);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock`.
/// The edit screen's Done: a server's client sends the packet, the
/// window's own world applies it (`handleSetCommandBlock`).
fn set_command_block(&mut self, s: &CommandBlockScreen) {
if !self.can_use_game_master_blocks() {
self.system_message(Text::translate("advMode.notAllowed", &[]));
return;
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value.clone(),
track_output: s.track_output,
mode: s.mode,
conditional: s.conditional,
auto: s.auto,
};
if self.remote.is_some() {
self.send(S::SetCommandBlock(play::SetCommandBlock {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
command: edit.command,
mode: edit.mode.ordinal(),
flags: i8::from(edit.track_output)
| (i8::from(edit.conditional) << 1)
| (i8::from(edit.auto) << 2),
}));
} else {
self.apply_command_block(&edit);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the
/// block is edited only by a creative player with game master commands.
pub(super) fn lan_set_command_block(
&mut self,
host: &mut LanHost,
conn: u32,
p: play::SetCommandBlock,
) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let allowed = g.creative() && g.permission >= 2;
let edit = CommandBlockEdit {
pos: (p.pos[0], p.pos[1], p.pos[2]),
command: p.command,
track_output: p.flags & 1 != 0,
mode: Mode::from_ordinal(p.mode),
conditional: p.flags & 2 != 0,
auto: p.flags & 4 != 0,
};
self.as_guest(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
});
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -609,7 +686,7 @@ impl Game<'_> {
}
let _ = self.process_level_events();
}
let command = s.command.value.clone();
let command = s.command.clone();
let Some(c) = command_block_mut(&mut self.world, pos) else {
return;
};
@@ -1,11 +1,10 @@
//! `/effect` (EffectCommands) and `/enchant` (EnchantCommand).
use super::{Arg, Context, Game, R, Target, err, texts};
use horizon_client::{
brewing,
brewing::{self, namespaced},
effects::EffectInstance,
text::{self, Style, Text},
};
use std::{collections::HashMap, sync::OnceLock};
/// `CommandResponseTracker` over targets: elements with a non-zero result.
#[derive(Default)]
@@ -21,24 +20,6 @@ impl Tracker {
}
}
}
/// Namespaced effect ids for mob effect storage.
fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
brewing::EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
fn effect_name(id: &str) -> Text {
Text::translate(&format!("effect.minecraft.{id}"), &[])
}
+11
View File
@@ -67,6 +67,17 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
}
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
if let Some(data) = stack.extra.as_deref() {
for (id, duration) in &data.stew_effects {
if let Some(def) = horizon_client::brewing::effect(id) {
self.add_player_effect(horizon_client::effects::EffectInstance::new(
def.id, *duration, 0,
));
}
}
}
for effect in &consumable.effects {
self.apply_consume_effect(effect);
}
@@ -44,13 +44,13 @@ impl Game<'_> {
slot: usize,
stack: Option<ItemStack>,
) {
if let Some(e) = self
.world
.block_entities
.get_mut(&(chest[0], chest[1], chest[2]))
let key = (chest[0], chest[1], chest[2]);
if let Some(e) = self.world.block_entities.get_mut(&key)
&& let Some(s) = e.slots_mut().get_mut(slot)
{
*s = stack;
// TransportItemsBetweenContainers calls Container.setChanged.
self.container_changed(key);
}
}
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
+193 -7
View File
@@ -46,10 +46,22 @@ pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
horizon_client::particles::ParticleOptions,
[f64; 3],
[f64; 3],
f64,
u32,
)>,
}
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
@@ -198,6 +210,7 @@ impl Game<'_> {
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
passenger: self.riding.is_some(),
})
}
/// Biome facts at a position for finalizeSpawn and variant rules.
@@ -402,6 +415,7 @@ impl Game<'_> {
world: &self.world,
registry: self.pack.assets.registry,
facts: self.res.facts(),
fluid_tables: &self.motion.fluids,
movement: &self.motion.blocks,
collision: self.collision,
data: self.res.mob_data(),
@@ -679,6 +693,7 @@ impl Game<'_> {
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
self.guardian_bubbles();
self.villager_reputation_tick();
self.merchant_tick();
self.villager_pickups();
@@ -1956,6 +1971,7 @@ impl Game<'_> {
let mut hits = Vec::new();
let mut deflections: Vec<[f64; 3]> = Vec::new();
let mut arrow_events = Vec::new();
let mut trails = Vec::new();
{
let mut targets: Vec<(Who, Aabb)> = self
.mobs
@@ -2047,6 +2063,25 @@ impl Game<'_> {
if shooter.is_some() {
p.owner = Some(Who::Player);
}
// AbstractHurtingProjectile.tick ends with createParticleTrail.
if super::remote_projectiles::has_smoke_trail(&p.kind) {
trails.push(p.position);
}
// ThrownEnderpearl.onAboveBubbleColumn / onInsideBubbleColumn.
if keep && hit == Hit::None && matches!(p.kind, ProjectileKind::EnderPearl) {
let (at, h) = (p.position, 0.125);
horizon_client::motion::bubble_column_push(
w.world,
w.registry,
w.facts,
w.collision,
Aabb::new(
Vec3::new(at.x - h, at.y, at.z - h),
Vec3::new(at.x + h, at.y + 2.0 * h, at.z + h),
),
&mut p.velocity,
);
}
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
@@ -2089,6 +2124,7 @@ impl Game<'_> {
keep
});
}
self.smoke_trails(trails);
for at in deflections {
self.audio
.play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0);
@@ -2121,6 +2157,7 @@ impl Game<'_> {
_ => self.projectile_hit(&p, hit)?,
}
}
self.flush_lan_explosions();
self.flush_wind_bursts()
}
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
@@ -2253,11 +2290,9 @@ impl Game<'_> {
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
}
}
/// `Level.isRainingAt` for arrows: raining, open to the sky (the biome's
/// precipitation is not checked here).
/// `Level.isRainingAt` for arrows.
fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
let raining = self.world.weather.levels(1.0).0 > 0.2;
move |p: BlockPos| raining && self.world.light(p.x, p.y, p.z) >> 4 == 15
move |p: BlockPos| self.raining_at(p)
}
/// The level as arrows see it.
fn arrow_scene<'s>(
@@ -2889,6 +2924,14 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.interact_armadillo(index, &item) {
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
self.swing_arm();
return Ok(true);
}
if self.interact_copper_golem(index, &item) {
self.swing_arm();
return Ok(true);
@@ -2944,6 +2987,16 @@ impl Game<'_> {
consume = true;
sound = Some("entity.cow.milk");
}
// Goat.mobInteract: a bucket on an adult gives milk.
Species::Goat if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
consume = true;
sound = Some(if m.extra.screaming {
"entity.goat.screaming.milk"
} else {
"entity.goat.milk"
});
}
Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => {
give = Some("minecraft:mushroom_stew");
consume = true;
@@ -3204,6 +3257,19 @@ impl Game<'_> {
self.trigger_entity("minecraft:tame_animal", "entity", &view);
}
}
// Tadpole.feed: frog food ages it by a tenth of the time left to
// grow into a frog (it grows on its next tick once past 24000).
Species::Tadpole if is_food && !m.extra.age_locked => {
consume = true;
let left = 24_000 - m.extra.age;
m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20;
// One happy villager particle at rest about the body.
self.particles_around_mob(
self.mobs[index].id,
"happy_villager",
horizon_client::particles::Burst::HORSE_EATING,
);
}
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
_ if is_food
&& m.kind.flags.animal
@@ -3291,6 +3357,50 @@ impl Game<'_> {
}
Ok(true)
}
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
/// (`brushOffScute`) and loses 16 durability.
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
if item != "minecraft:brush"
|| m.species() != Species::Armadillo
|| !m.alive()
|| m.is_baby()
{
return false;
}
let (id, at) = (m.id, m.position);
let feet = [at.x, at.y, at.z];
let tool = self.held().cloned();
let table = "minecraft:brush/armadillo";
for d in self.sequence_loot(table, table, tool.as_ref()) {
self.spawn_at_location(at, 0.0, d);
}
self.audio
.play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0);
let ctx = self.who_context(Some(Who::Mob(id)), None);
self.post_game_event("minecraft:entity_interact", feet, ctx);
// ItemStack.hurtAndBreak(16).
if !self.creative
&& let Some(stack) = tool
{
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage);
if max > 0 {
let loss = self.durability_loss(&stack, 16);
let slot = self.held_slot();
if let Some(held) = self.stack_in_mut(slot).as_mut() {
held.damage += loss;
if held.damage >= max {
*self.stack_in_mut(slot) = None;
}
}
}
}
true
}
pub(super) fn damage_held_tool(&mut self) {
if self.creative {
return;
@@ -3438,6 +3548,60 @@ impl Game<'_> {
}
}
}
/// `Guardian.aiStep` on a client: two bubbles a tick trailing a guardian
/// that swims along, and bubbles up the beam to its target. The server
/// never draws them, so the mob's own random stays untouched.
pub(super) fn guardian_bubbles(&mut self) {
use horizon_client::mobs::guardian;
use horizon_client::particles::{ParticleOptions, ParticleType};
let mut swimming = Vec::new();
let mut beams = Vec::new();
for m in &self.mobs {
if !m.alive() || !matches!(m.species(), Species::Guardian | Species::ElderGuardian) {
continue;
}
let at = [m.position.x, m.position.y, m.position.z];
if m.extra.flag2 && m.fluids.in_water {
let (width, height, _) = m.dims();
swimming.push((at, width, height, m.view_vector()));
}
if let Some((_, scale)) = guardian::beam(m, 0.0)
&& let Some(t) = self.beam_target(m.ai.target, 1.0)
{
beams.push(([at[0], m.eye_y(), at[2]], [t.x, t.y, t.z], f64::from(scale)));
}
}
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for (at, width, height, view) in swimming {
for _ in 0..2 {
let (x, y, z) = {
let r = self.particles.random();
(r.next_double(), r.next_double(), r.next_double())
};
let p = [
at[0] + width * (2.0 * x - 1.0) * 0.5 - view[0] * 1.5,
at[1] + height * y - view[1] * 1.5,
at[2] + width * (2.0 * z - 1.0) * 0.5 - view[2] * 1.5,
];
self.particles.add(&bubble, p, [0.0; 3]);
}
}
for (eye, target, scale) in beams {
let d = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]];
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if length <= 0.0 {
continue;
}
let mut dist = self.particles.random().next_double();
while dist < length {
let r = self.particles.random();
dist += 1.8 - scale + r.next_double() * (1.7 - scale);
let f = dist / length;
let p = [eye[0] + d[0] * f, eye[1] + d[1] * f, eye[2] + d[2] * f];
self.particles.add(&bubble, p, [0.0; 3]);
}
}
}
/// Mob models and projectiles for the entity pass.
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
@@ -3445,8 +3609,10 @@ impl Game<'_> {
let scale = self.view_scale();
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
if mob.invisible {
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
if mob.invisible && outline.is_none() {
continue;
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
@@ -3515,9 +3681,18 @@ impl Game<'_> {
out,
);
self.mob_block_geometry(mob, camera, alpha, packed, out);
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if !mob.glowing && horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none() {
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
self.cull_entity_vertices(out, entity_start);
}
// GuardianRenderer: the laser to the locked target.
@@ -3565,7 +3740,15 @@ impl Game<'_> {
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0 && self.entity_glowing(a.id) {
glowing = Some((out.len(), super::glowing::team_color(self.mob_team(a.id))));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
let light = self.world.light(
@@ -3697,6 +3880,9 @@ impl Game<'_> {
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
+165 -2
View File
@@ -13,7 +13,7 @@ use horizon_client::{
save::VERSION_NAME,
text::Text,
};
use horizon_core::geometry::Axis;
use horizon_core::geometry::{Axis, Vec3};
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -766,7 +766,8 @@ impl Game<'_> {
}
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
for m in &self.mobs {
let guests = self.lan.iter().flat_map(|h| h.models.values());
for m in self.mobs.iter().chain(guests) {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
@@ -796,6 +797,159 @@ impl Game<'_> {
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
// unless the type sets one). `at` is the interpolated position.
let at = |previous: Vec3, now: Vec3| {
[
lerp(previous.x, now.x),
lerp(previous.y, now.y),
lerp(previous.z, now.z),
]
};
for p in &self.paintings {
let (min, max) = p.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
}
for f in self.item_frames.iter().filter(|f| !f.invisible) {
let (min, max) = f.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
}
for c in &self.cushions {
let (min, max) = c.bounds();
let p = c.position;
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
}
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
// ArmorStand.getDimensions: a marker has none, a small one is
// half scale with its eyes at 0.9875.
let (size, eye) = match (a.marker, a.small) {
(true, _) => ((0.0, 0.0), 0.0),
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
g.hitbox(
at(b.previous, b.position),
(super::boats::WIDTH as f32, h),
h,
false,
(0.0, b.yaw),
);
}
for c in &self.carts {
let (w, h) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
g.hitbox(
at(c.previous, c.position),
(w, h),
h * 0.85,
false,
(0.0, c.yaw),
);
}
for o in &self.orbs {
g.hitbox(
at(o.previous, o.position),
(0.5, 0.5),
0.425,
false,
(0.0, 0.0),
);
}
for f in &self.falling {
g.hitbox(
at(f.previous, f.position),
(0.98, 0.98),
0.833,
false,
(0.0, 0.0),
);
}
for t in &self.tnts {
g.hitbox(
at(t.previous, t.position),
(0.98, 0.98),
0.15,
false,
(0.0, 0.0),
);
}
for c in &self.crystals {
let p = c.position;
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
}
for p in &self.creatures.projectiles {
use horizon_client::projectiles::ProjectileKind as K;
let (size, eye) = match p.kind {
K::Arrow { .. } | K::Trident => (0.5, 0.13),
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
(0.25, 0.2125)
}
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
}
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
K::LargeFireball { .. } => (1.0, 0.85),
};
g.hitbox(
at(p.previous, p.position),
(size, size),
eye,
false,
(p.pitch, p.yaw),
);
}
for t in &self.tridents {
g.hitbox(
at(t.previous, t.position),
(0.5, 0.5),
0.13,
false,
(t.pitch, t.yaw),
);
}
for r in &self.rockets {
// Projectile.updateRotation: the look follows the motion.
let v = r.velocity;
let yaw = v.x.atan2(v.z).to_degrees() as f32;
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
g.hitbox(
at(r.previous, r.position),
(0.25, 0.25),
0.2125,
false,
(pitch, yaw),
);
}
for h in self
.hook
.iter()
.map(|h| (h.previous, h.position))
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
{
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
for b in &self.bolts {
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
}
// The dragon: 16 by 8, a living entity; its parts follow.
if let Some(d) = &self.dragon {
g.hitbox(
at(d.previous, d.position),
(16.0, 8.0),
6.8,
true,
(0.0, d.yaw),
);
}
// The dragon's parts.
for (id, b) in self.special_targets() {
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
@@ -828,3 +982,12 @@ impl Game<'_> {
g
}
}
/// The (x, y) rotations whose view vector is the step of a direction.
fn step_view(step: [i32; 3]) -> (f32, f32) {
let [x, y, z] = step.map(f64::from);
(
(-y).asin().to_degrees() as f32,
(-x.atan2(z)).to_degrees() as f32,
)
}
@@ -120,6 +120,11 @@ impl Game<'_> {
p.guest = guest;
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
+64 -14
View File
@@ -27,6 +27,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096;
const CRYSTAL_IDS: u32 = SPECIAL + 16;
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
const MAX_HEALTH: f32 = 200.0;
/// `Mob.getAmbientSoundInterval`.
const AMBIENT_INTERVAL: i32 = 80;
#[derive(Default)]
pub(super) struct Fight {
@@ -270,6 +272,8 @@ pub(super) struct Dragon {
flame_count: i32,
sitting_damage: f32,
growl: i32,
/// `Mob.ambientSoundTime`.
ambient_time: i32,
ticks: i32,
/// DragonFlightHistory: (y, yRot) samples, newest at `head`.
history: [(f64, f32); 64],
@@ -291,11 +295,11 @@ pub(super) enum FightWho {
}
/// A player in the End as the dragon sees it.
#[derive(Clone, Copy, Debug)]
struct FightPlayer {
who: FightWho,
position: Vec3,
bounds: Aabb,
alive: bool,
pub(super) struct FightPlayer {
pub(super) who: FightWho,
pub(super) position: Vec3,
pub(super) bounds: Aabb,
pub(super) alive: bool,
creative: bool,
spectator: bool,
}
@@ -397,6 +401,7 @@ impl Dragon {
flame_count: 0,
sitting_damage: 0.0,
growl: 100,
ambient_time: 0,
ticks: 0,
history: [(position.y, yaw); 64],
head: 0,
@@ -693,7 +698,7 @@ impl Game<'_> {
}
/// The players in the level the dragon sees (`level.players()`): the
/// window's own, then every guest in the level.
fn fight_players(&self) -> Vec<FightPlayer> {
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref() {
out.push(FightPlayer {
@@ -722,7 +727,7 @@ impl Game<'_> {
}
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
/// sent to its client).
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
match who {
FightWho::Host => f(self),
FightWho::Guest(conn) => {
@@ -989,6 +994,8 @@ impl Game<'_> {
};
let hurt = d.hurt(part, damage, counts, arrow);
if hurt == (Hurt::Landed { full: true }) {
// Mob.playHurtSound: the ambient sound clock starts again.
d.ambient_time = -AMBIENT_INTERVAL;
let at = [d.position.x, d.position.y, d.position.z];
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
@@ -1568,6 +1575,7 @@ impl Game<'_> {
age: 0,
duration: 200,
from_dragon: true,
potion: None,
});
}
}
@@ -1660,6 +1668,24 @@ impl Game<'_> {
self.tick_dragon_death();
return;
}
// Mob.baseTick: the ambient sound, rarer right after one.
let roll = d.random.next_int_bound(1000).unwrap_or(999);
let waited = d.ambient_time;
d.ambient_time += 1;
if roll < waited {
d.ambient_time = -AMBIENT_INTERVAL;
let at = [d.position.x, d.position.y, d.position.z];
// LivingEntity.getVoicePitch, from the dragon's own random.
let r = &mut d.random;
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
self.audio.play(
"minecraft:entity.ender_dragon.ambient",
Some(at),
5.0,
pitch,
);
}
let d = self.dragon.as_mut().expect("dragon");
// Flapping, growls and the flap sound.
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
let v = d.velocity;
@@ -2080,12 +2106,18 @@ impl Game<'_> {
age: 0,
duration: 600,
from_dragon: false,
potion: None,
});
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some([next.x, next.y, next.z]),
1.0,
1.0,
// DragonFireball.onHit: level event 2006 (the burst of dragon
// breath and the explosion sound) at its block position.
self.level_event_at(
2006,
[
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
],
1,
);
return false;
}
@@ -2112,6 +2144,10 @@ impl Game<'_> {
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -2259,8 +2295,22 @@ pub(super) struct Breath {
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
duration: i32,
from_dragon: bool,
pub(super) duration: i32,
pub(super) from_dragon: bool,
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
pub(super) potion: Option<super::potions::PotionCloud>,
}
impl Breath {
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
/// lingering potion's).
pub(super) fn waiting(&self) -> bool {
self.age
< if self.potion.is_some() {
super::potions::LINGERING_WAIT
} else {
20
}
}
}
impl Game<'_> {
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
+10 -3
View File
@@ -253,10 +253,17 @@ impl Game<'_> {
out
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor.
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
if self.dim == Dim::End {
sky.sky_light_factor += self.end.flash.intensity(partial as f32);
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
let intensity = self.end.flash.intensity(partial as f32);
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
intensity / 3.0
} else {
intensity
};
}
}
+58 -40
View File
@@ -23,7 +23,7 @@ pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
pub(super) position: Vec3,
previous: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
life: i32,
lifetime: i32,
@@ -62,11 +62,56 @@ impl Rocket {
item,
}
}
/// One tick of a server's client: the previous position, then `step`.
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
/// One tick of a server's client: the rocket flies on by itself between
/// the server's updates (a rocket that follows an entity is placed by the
/// caller).
pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) {
self.previous = self.position;
if self.attached_to.is_none() {
self.free_flight(scene);
}
self.life += 1;
self.position = step(self.position);
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move. True when a
/// block stopped it.
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 2.0 * h,
self.position.z + h,
),
);
let wanted = if self.angled {
self.velocity
} else {
Vec3::new(
self.velocity.x * 1.15,
self.velocity.y + 0.04,
self.velocity.z * 1.15,
)
};
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
return false;
};
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
self.velocity = if horizontal {
Vec3::new(self.velocity.x, wanted.y, self.velocity.z)
} else {
wanted
};
blocked
}
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
@@ -191,46 +236,14 @@ impl Game<'_> {
r.velocity = p.velocity;
}
} else {
// Not shot at an angle: accelerate (unless blocked sideways) and rise.
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
);
let wanted = if r.angled {
r.velocity
} else {
Vec3::new(
r.velocity.x * 1.15,
r.velocity.y + 0.04,
r.velocity.z * 1.15,
)
};
if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) {
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
if r.angled && blocked {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
let horizontal =
(moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
r.position = Vec3::new(
r.position.x + moved.x,
r.position.y + moved.y,
r.position.z + moved.z,
);
r.velocity = if horizontal {
Vec3::new(r.velocity.x, wanted.y, r.velocity.z)
} else {
wanted
};
if r.free_flight(&mut scene) && r.angled {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
}
let at = [r.position.x, r.position.y, r.position.z];
@@ -383,7 +396,12 @@ impl Game<'_> {
/// lit at the rocket's light probe.
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let orientation = glam::Mat4::from_quat(self.camera_orientation());
for r in self.rockets.iter().filter(|r| !r.attached) {
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
for r in self
.rockets
.iter()
.filter(|r| !r.attached && r.attached_to.is_none())
{
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
// EntityRenderer.shouldRender: its 0.25 box in the view.
if !self.standing_entity_in_view(
+6 -4
View File
@@ -30,8 +30,8 @@ enum State {
pub(super) struct Hook {
/// Its entity id, which a server's players track it under.
id: u32,
position: Vec3,
previous: Vec3,
pub(super) position: Vec3,
pub(super) previous: Vec3,
velocity: Vec3,
state: State,
life: i32,
@@ -433,6 +433,8 @@ impl Game<'_> {
let options = ParticleOptions::simple(kind);
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(&options, at, spread, speed, count);
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
@@ -770,8 +772,8 @@ impl Game<'_> {
pub(super) struct RemoteHook {
id: u32,
owner: u32,
position: Vec3,
previous: Vec3,
pub(super) position: Vec3,
pub(super) previous: Vec3,
}
impl Hook {
+1 -1
View File
@@ -457,7 +457,7 @@ impl Game<'_> {
}
for (id, m, light) in maps {
let entity_start = out.len();
self.map_vertices(id, m, light, out);
self.map_vertices(id, true, m, light, out);
self.cull_entity_vertices(out, entity_start);
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Glowing entities (`EntityRenderer.extractRenderState`'s outlineColor): which entities
//! appear glowing, in which team colour, and their silhouettes for the renderer's entity
//! outline (`OutlineBufferSource`).
use super::{Game, Vertex};
use horizon_client::{mobs::Mob, scoreboard as sb};
/// `Entity.getTeamColor`: the team's colour, else white.
pub(super) fn team_color(team: Option<&sb::Team>) -> u32 {
team.and_then(|t| t.color.as_deref())
.and_then(sb::team_color_rgb)
.unwrap_or(0xFF_FFFF)
}
impl Game<'_> {
/// `Entity.isCurrentlyGlowing` of an entity that is not living (an item, a projectile):
/// its `Glowing` tag, or shared flag 6 as a server sent it.
pub(super) fn entity_glowing(&self, id: u32) -> bool {
self.remote_glowing(id).unwrap_or_else(|| {
self.persist_raw(id)
.and_then(|t| horizon_client::save::number(t.get("Glowing")))
.is_some_and(|b| b != 0)
})
}
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
/// colour (`getTeamColor`; another player's team goes by name).
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
if !m.is_currently_glowing() {
return None;
}
let team = if &*m.kind.id == "minecraft:player" {
let names = self.remote_player_names();
let name = names.into_iter().find(|(id, _)| *id == m.id);
name.and_then(|(_, name)| self.score.board.players_team(&name))
} else {
self.mob_team(m.id)
};
Some(team_color(team))
}
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
/// alpha kept (`OutlineBufferSource`'s team-coloured copy of each draw).
pub(super) fn mark_outline(&self, vertices: &[Vertex], rgb: u32) {
let color = horizon_client::mobs::render::abgr(rgb);
self.glowing_vertices
.borrow_mut()
.extend(vertices.iter().map(|v| Vertex {
color,
normal: [0; 4],
..*v
}));
}
}
+16 -9
View File
@@ -129,6 +129,7 @@ pub(super) struct Host<'a> {
banners: &'a horizon_client::banner::Banners,
sprites: &'a HashMap<String, Rect>,
skins: &'a RefCell<horizon_client::skins::Downloader>,
profiles: &'a RefCell<horizon_client::skins::Resolver>,
}
impl SpecialHost for Host<'_> {
fn banner_texture(&self, pattern: &str) -> Option<String> {
@@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> {
fn player_skin(&self, profile: &Value) -> Option<String> {
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
// accepts a texture property without a signature, unlike the tab list.
let url = horizon_client::skins::profile_skin_url(profile)?;
let location =
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
let _ = self
.skins
.borrow_mut()
.state(horizon_client::skins::Kind::Skin, &url);
self.sprites.contains_key(&location).then_some(location)
// A `Dynamic` profile (a name or id only) resolves through the
// services (`PlayerSkinRenderCache.getOrDefault`).
Some(horizon_client::skins::head_skin(
profile,
&mut self.profiles.borrow_mut(),
&mut self.skins.borrow_mut(),
|location| self.sprites.contains_key(location),
))
}
}
@@ -194,6 +195,7 @@ impl Game<'_> {
banners: &self.res.stations().banners,
sprites: &self.gui.atlas.sprites,
skins: &self.skin_downloads,
profiles: &self.skin_profiles,
}
}
/// The client level as item models read it: worked out once a frame (a
@@ -405,7 +407,12 @@ impl Game<'_> {
) {
self.submit_item_world(&state, copy, vertex_light(packed), out);
}
self.cull_entity_vertices(out, entity_start);
if self.entity_glowing(item.id) {
let rgb = super::glowing::team_color(self.mob_team(item.id));
self.mark_outline(&out[entity_start..], rgb);
} else {
self.cull_entity_vertices(out, entity_start);
}
}
}
}
@@ -79,6 +79,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.stats.custom("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
@@ -90,6 +91,8 @@ impl Game<'_> {
self.set_has_record(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
+27 -5
View File
@@ -167,8 +167,11 @@ fn mob_meta(
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
if m.fire_ticks > 0 {
meta.push((0, serializer::BYTE, Value::Byte(1)));
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40
// (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6;
if shared != 0 {
meta.push((0, serializer::BYTE, Value::Byte(shared)));
}
// Entity.DATA_POSE: the pose the species keeps in its own fields (a
// pose back at standing goes out by the entity's own default).
@@ -547,13 +550,16 @@ fn mob_equipment(
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
let e = &m.extra;
// A horse's armour, a llama's carpet, a harness: Horizon keeps the body
// slot's item (6) in `head`.
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slots = [
(0u8, &e.main_hand),
(1, &e.off_hand),
(2, &e.feet),
(3, &e.legs),
(4, &e.chest),
(5, &e.head),
(if body { 6 } else { 5 }, &e.head),
];
for (slot, item) in slots {
if let Some(item) = item {
@@ -1075,6 +1081,10 @@ impl Game<'_> {
e.position = [d.position.x, d.position.y, d.position.z];
e.on_ground = d.on_ground;
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
// Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag).
if self.entity_glowing(d.id) {
e.meta.push((0, serializer::BYTE, Value::Byte(0x40)));
}
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
}
for o in &self.orbs {
@@ -1141,6 +1151,9 @@ impl Game<'_> {
for m in &mut self.mobs {
m.needs_sync = false;
}
for p in &mut self.creatures.projectiles {
p.needs_sync = false;
}
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
@@ -1301,8 +1314,11 @@ impl Game<'_> {
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A mace's wind burst from a guest's attack.
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
let _ = self.flush_wind_bursts();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
}
fn lan_guest_joined(
@@ -1656,6 +1672,7 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
m.extra.avatar_pose = g.pose();
m.extra.sprinting = g.sprinting;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
@@ -1724,7 +1741,12 @@ impl Game<'_> {
out,
);
self.held_item_geometry(m, camera, alpha, packed, true, out);
if !m.glowing {
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
let guest = host.hub.guest(*conn);
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
} else {
self.cull_entity_vertices(out, entity_start);
}
}
+28 -10
View File
@@ -267,8 +267,10 @@ impl Game<'_> {
}
let mut play = host.play.remove(&conn).unwrap_or_default();
if fresh {
// A new body (joined, respawned, changed level): nothing on it yet.
// A new body (joined, respawned, changed level): nothing on it yet,
// its effects' modifiers and armor protection included.
play.equipped = None;
play.potions = Default::default();
play.sculk = Default::default();
}
let g = &mut host.hub.guests[i];
@@ -405,6 +407,12 @@ impl Game<'_> {
self.apply_equipment();
play.equipped = Some(equipped);
}
// The player's effects are on the new body before anything it does
// (the movement that brought it, a landing): vanilla keeps the same
// entity through a level change, with its attributes and effects.
if fresh {
self.apply_effect_status();
}
self.update_totem_ready();
Some(GuestSwap {
conn,
@@ -1216,6 +1224,7 @@ impl Game<'_> {
}
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
S::SetCommandBlock(p) => self.lan_set_command_block(host, conn, p),
S::EditBook(p) => self.lan_edit_book(host, conn, p),
S::ContainerSlotStateChanged(p) => {
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
@@ -1540,9 +1549,13 @@ impl Game<'_> {
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
&& strike.stops_sprint()
{
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
self.as_guest(host, conn, |g| {
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
}
});
}
return;
}
@@ -1847,10 +1860,6 @@ impl Game<'_> {
};
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
// A new body: nothing given to the old one carries over.
if let Some(play) = host.play.get_mut(&conn) {
play.potions = Default::default();
}
let clock_rate = self.network_clock_rate();
let ctx = TickCtx {
world: &mut self.world,
@@ -2209,7 +2218,7 @@ impl Game<'_> {
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let mut soul = None;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
@@ -2278,6 +2287,7 @@ impl Game<'_> {
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
let _ = w.player.set_sleeping(sleeping);
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
@@ -2294,6 +2304,8 @@ impl Game<'_> {
game.cauldron_extinguish(*pos);
}
}
// Entity.baseTick: rain puts out a burning player.
game.rain_extinguish_player();
// WitherRoseBlock.entityInside along those moves.
for event in &events {
if matches!(event, MotionEvent::InsideWitherRose) {
@@ -2332,6 +2344,13 @@ impl Game<'_> {
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let velocity = marked.then_some(player.velocity);
// Player.causeFallDamage: a fall of 2 blocks or more counts.
for event in &events {
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
{
game.stats.custom("fall_one_cm", *cm);
}
}
game.record_combat(&events);
game.wear_armor(&events);
(events, velocity)
@@ -2698,8 +2717,7 @@ impl Game<'_> {
/// with Mending and gives the rest as points. Everyone near hears it.
fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) {
let g = &host.hub.guests[gi];
// Player's standing and crouching boxes: 1.8 and 1.5 high.
let height = if g.sneaking { 1.5 } else { 1.8 };
let height = f64::from(g.dimensions().height);
let (conn, id, at) = (g.conn, g.entity_id, g.position);
let unable = g.spectator() || g.dead || g.health <= 0.0;
let play = host.play.entry(conn).or_default();
@@ -342,6 +342,7 @@ impl Game<'_> {
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
e.needs_sync = p.needs_sync;
// Projectile.getAddEntityPacket: the owner's id.
e.data = match p.guest.and_then(|conn| hub.guest(conn)) {
Some(g) => g.entity_id,
@@ -466,7 +467,7 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
@@ -484,10 +485,14 @@ impl Game<'_> {
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.age < 20 {
if b.waiting() {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
if let Some(p) = &breath {
let particle = match &b.potion {
Some(p) => self.network_particle(&p.particle(), None),
None => breath.clone(),
};
if let Some(p) = &particle {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
+7 -11
View File
@@ -175,9 +175,9 @@ impl Game<'_> {
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.map(|g| {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
([x - 0.3, y, z - 0.3], [x + 0.3, y + height, z + 0.3])
let b = g.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
})
.collect()
}
@@ -319,7 +319,7 @@ impl Game<'_> {
uuid: horizon_client::lan::playerdata::uuid_ints(g.uuid),
position: g.position,
eye: [x, y + g.eye_height(), z],
height: if g.sneaking { 1.5 } else { 1.8 },
height: g.dimensions().height,
creative: g.creative(),
spectator: g.spectator(),
alive: !g.dead,
@@ -336,14 +336,9 @@ impl Game<'_> {
}
}
/// A guest's bounding box (0.6 wide, 1.8 tall, 1.5 crouching).
/// A guest's bounding box, by its pose (`Avatar.POSES`).
pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
)
g.bounds()
}
#[cfg(test)]
@@ -374,6 +369,7 @@ mod tests {
uuid: [0; 4],
allies: Default::default(),
remote: false,
passenger: false,
}
}
@@ -318,16 +318,86 @@ impl Game<'_> {
}
}
/// `ServerLevel.sendParticles` for the players a server serves: one
/// `ClientboundLevelParticlesPacket` to each player in the level whose
/// block is within 32 blocks of `at`. The window's own player is not
/// sent it (the caller draws it in this window). While the host holds
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
pub(super) fn lan_send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if self.lan.is_none() {
// The server is running (its sounds are captured for the guests).
if self.audio.capture.is_some() {
self.creatures
.lan_particles
.push((options.clone(), at, spread, speed, count));
}
return;
}
let Some(particle) = self.network_particle(options, None) else {
return;
};
let Some(host) = self.lan.as_ref() else {
return;
};
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
})
.encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
let d: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if d < 32.0 * 32.0 {
g.sender.send(body.clone());
}
}
}
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(&options, at, spread, speed, count);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(
&self,
&mut self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
// In a guest's turn (their hit on an end crystal or a minecart) the
// host holds the players (`as_guest`): the packet waits for the turn
// to end so that it reaches every player, the guest too.
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures
.lan_explosions
.push((center, small, radius, block_count));
return;
}
let emitter = if small {
"minecraft:explosion"
} else {
@@ -341,6 +411,15 @@ impl Game<'_> {
}
}
/// The explosions guests' turns put off.
pub(super) fn flush_lan_explosions(&mut self) {
for (center, small, radius, block_count) in
std::mem::take(&mut self.creatures.lan_explosions)
{
self.lan_explode(center, small, radius, block_count);
}
}
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
/// no block particles, and for each player it pushed the push
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
+15 -8
View File
@@ -86,9 +86,11 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.stops_sprint()
{
self.stop_sprint_after_extra_knockback();
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
}
}
self.lan = Some(host);
true
@@ -106,8 +108,18 @@ impl Game<'_> {
.map(|g| Victim::Guest(g.conn))
}
/// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker
/// (the host, or a guest swapped in) only once the hit hurt the victim.
pub(super) fn pvp_exhaustion(&mut self) {
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
}
/// The attacker's side of `Player.attack` (the host, or a guest swapped
/// in): the damage, the charge reset, the exhaustion and the swing.
/// in): the damage, the charge reset and the swing.
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
@@ -116,11 +128,6 @@ impl Game<'_> {
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
self.swing_arm();
Some(Strike {
attacker: self.lan_acting,
+2 -6
View File
@@ -5,14 +5,10 @@
//! forcing the game mode. Its difficulty lock asks first (PopupScreen), and
//! Restrictions opens RestrictionsScreen.
//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen
use super::{
Game, Screen,
game_rules_screen::{Exit, RulesScreen},
integrated::ServerOptions,
lan::LanSettings,
};
use super::{Game, Screen, integrated::ServerOptions, lan::LanSettings};
use horizon_client::{
font::Font,
game_rules_screen::{Exit, RulesScreen},
hud::{HudBatch, rgba},
text::{Text, named_color},
ui::{self, EditBox},
@@ -277,7 +277,12 @@ impl Game<'_> {
g.carried = menu.carried.take();
}
self.register_exploration_maps(made);
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
let (session, traded) = self.merchant_apply(session, gm.merchant.mob);
gm.merchant.session = session;
if !traded.is_empty() {
// The guest's own statistic and advancements.
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
}
self.drop_guest_items(host, gi, dropped);
self.lan_send_offers(host, conn, &mut gm, false);
self.lan_send_merchant_content(host, conn, &mut gm);
+190 -15
View File
@@ -1,12 +1,14 @@
//! Level events whose client handler (`LevelEventHandler`) spawns particles
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
@@ -72,24 +74,42 @@ impl Game<'_> {
} else {
// ParticleUtils.spawnParticleInBlock: up to the
// block's shape (a full block when air).
let inside = self.world.block(at[0], at[1], at[2]);
let height = if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
};
(at, count, 0.5, height, true)
(at, count, 0.5, self.block_shape_top(at), true)
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
None => return,
};
let options = ParticleOptions::simple(ParticleType::HappyVillager);
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
}
/// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air.
fn block_shape_top(&self, at: [i32; 3]) -> f64 {
let facts = self.res.facts();
let inside = self.world.block(at[0], at[1], at[2]);
if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
}
}
/// `ParticleUtils.spawnParticles`: `count` slow particles spread over
/// `xz` blocks around `at` and `height` up, only above solid ground
/// unless floating ones are allowed.
fn spawn_particles(
&mut self,
at: [i32; 3],
count: i32,
xz: f64,
height: f64,
floating: bool,
ty: ParticleType,
) {
let options = ParticleOptions::simple(ty);
for _ in 0..count.max(0) {
let r = self.particles.level_random();
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
@@ -108,6 +128,161 @@ impl Game<'_> {
}
}
}
/// `LevelEventHandler` 2012 (a turtle egg laid on sand):
/// `ParticleUtils.spawnParticleInBlock` with happy villager sparks.
pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) {
let at = [pos.x, pos.y, pos.z];
let height = self.block_shape_top(at);
self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager);
}
/// `LevelEventHandler` 3009 (a sniffer egg cracking on moss):
/// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`.
pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) {
let mut spawned = Vec::new();
particles::on_block_faces_between(
self.particles.level_random(),
[pos.x, pos.y, pos.z],
3,
6,
|p, v| spawned.push((p, v)),
);
let options = ParticleOptions::simple(ParticleType::EggCrack);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried):
/// eight puffs just above the block, drawn after the sound.
pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) {
let options = ParticleOptions::simple(ty);
for _ in 0..8 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double();
let z = f64::from(pos.z) + r.next_double();
self.particles
.add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke
/// puffs inside the block, drawn after the sound.
pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) {
let options = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..5 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2;
let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2;
let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
/// particles thrown outwards, then the explosion sound when `data` is 1.
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
for _ in 0..200 {
let r = self.particles.level_random();
let dist = r.next_float() * 4.0;
let angle = r.next_float() * std::f32::consts::TAU;
let vx = f64::from(trig::cos(f64::from(angle)) * dist);
let vy = 0.01 + r.next_double() * 0.5;
let vz = f64::from(trig::sin(f64::from(angle)) * dist);
let options = ParticleOptions {
scale: dist,
..ParticleOptions::simple(ParticleType::DragonBreath)
};
self.particles.add(
&options,
[
f64::from(pos.x) + vx * 0.1,
f64::from(pos.y) + 0.3,
f64::from(pos.z) + vz * 0.1,
],
[vx, vy, vz],
);
}
if data == 1 {
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some(at),
1.0,
pitch,
);
}
}
/// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down.
fn is_stalactite(&self, state: BlockStateId) -> bool {
self.res.facts().get(state).has_class("SpeleothemBlock")
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("vertical_direction"))
.is_some_and(|d| d == "down")
}
/// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at
/// the tip `pos`. The fluid above the stalactite's root picks the drip;
/// with none, the dimension's default (lava in the Nether).
pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) {
let tip_state = self.world.block(pos.x, pos.y, pos.z);
if !self.is_stalactite(tip_state) {
return;
}
// findRootBlock: up through the same stalactite, at most 11 steps.
let tip_name = self.res.facts().get(tip_state).name.clone();
let mut root = None;
for step in 1..11 {
let y = pos.y + step;
if !(horizon_client::chunk::MIN_Y
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
.contains(&y)
{
break;
}
let state = self.world.block(pos.x, y, pos.z);
if self.res.facts().get(state).name != tip_name {
root = Some(y);
break;
}
if !self.is_stalactite(state) {
break;
}
}
let Some(root_y) = root else {
return;
};
let above = self.world.block(pos.x, root_y + 1, pos.z);
let above_facts = self.res.facts().get(above);
let nether = self.dim == super::Dim::Nether;
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
horizon_client::blocks::Fluid::Water
} else {
above_facts.fluid
};
let ty = match fluid {
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
};
let offset = self
.pack
.context
.tables
.offset(tip_state, pos.x, pos.z)
.unwrap_or([0.0; 3]);
// The tip shape starts 5/16 up; the particle sits a pixel below that.
self.particles.add(
&ParticleOptions::simple(ty),
[
f64::from(pos.x) + 0.5 + offset[0],
f64::from(pos.y) + 5.0 / 16.0 - 0.0625,
f64::from(pos.z) + 0.5 + offset[2],
],
[0.0; 3],
);
}
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
+12 -1
View File
@@ -16,7 +16,7 @@ pub(super) struct Bolt {
flashes: i32,
seed: i64,
visual_only: bool,
/// Mooshrooms switch colour once per bolt.
/// Mooshrooms and copper golems change once per bolt.
converted: Vec<u32>,
}
impl Game<'_> {
@@ -393,6 +393,17 @@ impl Game<'_> {
continue;
}
Species::Creeper => m.extra.charged = true,
// CopperGolem.thunderHit (after Entity.thunderHit's fire and
// damage): once per bolt, an oxidized golem goes back one step
// and its weathering timer (or wax, -2) is reset to -1.
Species::CopperGolem if !b.converted.contains(&m.id) => {
b.converted.push(m.id);
if m.extra.variant > 0 {
m.extra.vector[0] = -1.0;
m.extra.variant =
horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
}
}
_ => {}
}
// Entity.thunderHit: burning (8 seconds) and 5 lightning damage.
+18 -3
View File
@@ -324,6 +324,13 @@ const DECORATION_TYPES: [(&str, &str); 24] = [
("mineshaft", "map/decorations/mineshaft"),
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
];
/// `MapDecorationType.showOnItemFrame` is false for the player arrows and the two markers.
fn shown_on_frame(sprite: &str) -> bool {
!matches!(
decoration_type(sprite),
Some("player" | "red_marker" | "blue_marker" | "player_off_map" | "player_off_limits")
)
}
/// The sprite of a decoration type that a `minecraft:map_decorations`
/// entry or a server's packet names.
fn decoration_sprite(id: &str) -> Option<&'static str> {
@@ -1114,9 +1121,12 @@ impl Game<'_> {
}
/// `MapRenderer.render`: the map texture and its decorations under `m`
/// (map pixels 0..128, y down), drawn at depth offsets toward the viewer.
/// `show_only_frame` (a map in an item frame) leaves out the decorations that
/// do not show on frames.
pub(super) fn map_vertices(
&mut self,
id: i32,
show_only_frame: bool,
m: glam::Mat4,
light: u32,
out: &mut Vec<Vertex>,
@@ -1158,14 +1168,18 @@ impl Game<'_> {
.get(&id)
.map(|m| m.decorations.clone())
.unwrap_or_default();
for (i, d) in decorations.iter().enumerate() {
let mut count = 0;
for d in &decorations {
if show_only_frame && !shown_on_frame(d.sprite) {
continue;
}
let Some(s) = self.gui.atlas.sprites.get(d.sprite).copied() else {
continue;
};
let dm = glam::Mat4::from_translation(glam::Vec3::new(
f32::from(d.x) / 2.0 + 64.0,
f32::from(d.y) / 2.0 + 64.0,
-0.02 - i as f32 * 0.001,
-0.02 - count as f32 * 0.001,
)) * glam::Mat4::from_rotation_z(
(f32::from(d.rot) * 360.0 / 16.0).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(4.0))
@@ -1187,6 +1201,7 @@ impl Game<'_> {
[p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)],
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
);
count += 1;
}
}
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
@@ -1255,7 +1270,7 @@ impl Game<'_> {
}
}
if has_data {
self.map_vertices(id, m, light, out);
self.map_vertices(id, false, m, light, out);
}
}
}
+50 -22
View File
@@ -9,7 +9,7 @@ use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
containers::BlockEntity,
items::ItemStack,
items::{ItemStack, Items},
mesher::Vertex,
mobs::{Part, anim::Poses},
particles::{ParticleOptions, ParticleType},
@@ -73,7 +73,7 @@ pub(super) struct Minecart {
pub yaw: f32,
pub previous_yaw: f32,
pub flipped: bool,
on_ground: bool,
pub(super) on_ground: bool,
horizontal_collision: bool,
fall_distance: f64,
pub hurt_time: i32,
@@ -201,6 +201,17 @@ impl Minecart {
fn horizontal(&self) -> f64 {
self.velocity.x.hypot(self.velocity.z)
}
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
let mut total = 0.0f32;
for s in slots.iter().flatten() {
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
total += s.count as f32 / max as f32;
}
total /= slots.len().max(1) as f32;
// Mth.lerpDiscrete(total, 0, 15)
(total * 14.0).floor() as i32 + i32::from(total > 0.0)
}
}
pub(super) struct CartModels {
pub models: HashMap<CartKind, Part>,
@@ -717,12 +728,7 @@ impl Game<'_> {
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
}) {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
let bb = Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
);
pushers.push((Vec3::new(x, y, z), bb));
pushers.push((Vec3::new(x, y, z), g.bounds()));
}
}
for (feet, bb) in pushers {
@@ -950,7 +956,7 @@ impl Game<'_> {
}
true
}
/// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top.
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
pub(super) fn dismount_cart(&mut self, i: usize) {
self.riding = None;
let c = &self.carts[i];
@@ -960,17 +966,25 @@ impl Game<'_> {
c.position.z.floor() as i32,
);
let mut target = None;
// DismountHelper.offsetsForDirection of the cart's facing
// (Direction.fromYRot of its yaw): right, left, back right, back left,
// forward right, forward left, back, forward.
let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize];
let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32;
let (fx, fz) = step(facing);
let (rx, rz) = (-fz, fx);
let offsets = [
(rx, rz),
(-rx, -rz),
(rx - fx, rz - fz),
(-rx - fx, -rz - fz),
(fx + rx, fz + rz),
(fx - rx, fz - rz),
(-fx, -fz),
(fx, fz),
];
'search: for dy in [0, 1, -1] {
for (dx, dz) in [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
(1, 1),
(-1, -1),
(1, -1),
(-1, 1),
] {
for (dx, dz) in offsets {
let (x, y, z) = (bx + dx, by + dy, bz + dz);
let below = self.res.facts().get(self.world.block(x, y - 1, z));
let here = self.res.facts().get(self.world.block(x, y, z));
@@ -1054,6 +1068,22 @@ impl Game<'_> {
}
best
}
/// Each cart's kind and comparator signal, for the detector rail
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
/// counts the items in that menu.
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
self.carts
.iter()
.map(|c| {
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
let slots = match self.world.block_entities.get(&key) {
Some(BlockEntity::Chest(open)) => open,
_ => &c.items,
};
(c.kind, Minecart::container_signal(slots, self.res.items()))
})
.collect()
}
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
pub(super) fn interact_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
@@ -1071,10 +1101,8 @@ impl Game<'_> {
}
self.riding = Some(self.carts[i].id);
}
// AbstractMinecartContainer.interact: no sneak check.
CartKind::Chest | CartKind::Hopper => {
if sneaking {
return false;
}
let c = self.carts[i].clone();
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
self.world
@@ -14,7 +14,9 @@ impl Game<'_> {
out: &mut Vec<Vertex>,
) {
let partial = alpha as f32;
for &(p, piston) in &self.world.moving_pistons {
let mut states = self.world.moving_pistons.clone();
states.extend(self.world.moving_piston_fallback.iter().copied());
for (p, piston) in states {
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
continue;
};
+3 -1
View File
@@ -338,7 +338,9 @@ impl Game<'_> {
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
// Text space: x right, y down (scale 0.025, -0.025).
let at = |x: f32, y: f32| o + r * x - u * y;
let chars: Vec<(char, u32)> = text
// Component.getVisualOrderText.
let shown = horizon_client::bidi::visual(text);
let chars: Vec<(char, u32)> = shown
.spans
.iter()
.flat_map(|s| {
+117 -35
View File
@@ -1,14 +1,23 @@
//! Resource pack selection bound to the game: the pack screen lists, and a
//! full resource reload after a change, while playing.
use super::Game;
use super::{
Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures,
};
use horizon_client::{
assets::Assets,
gfx::PreparedTextures,
hud::HudAtlas,
lang,
pack_screen::{self, Compat, PackInfo, Source as PackSource},
packs::{self, Pack, Position},
streaming::WorkerContext,
text::{Style, Text},
};
use std::path::{Path, PathBuf};
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
sync::Arc,
};
/// Where overlay roots are built (beside options.txt).
pub fn cache_dir() -> PathBuf {
@@ -98,7 +107,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
infos
}
impl Game<'_> {
fn vanilla_root(&self) -> PathBuf {
pub(super) fn vanilla_root(&self) -> PathBuf {
if self.ui.vanilla_root.as_os_str().is_empty() {
self.options.resources.clone()
} else {
@@ -138,35 +147,36 @@ impl Game<'_> {
self.ui.options.resource_error = Some(e.to_string());
}
}
/// Reload from the selected packs and the server's.
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
/// The CPU half of the reload, safe on a worker (`PackReload` runs it):
/// `apply_staged_pack` finishes the GPU half and the swap.
pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result<StagedPack> {
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let server = &self.server_packs.active;
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
let root =
super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods);
self.reload_resources(&root)
stage_pack(
&vanilla,
&self.settings.resource_packs,
&self.server_packs.active,
&self.settings.mods.enabled,
&self.options.dev_mods,
&self.pack.assets,
&self.res,
&self.pack.context,
self.settings.force_unicode,
self.settings.japanese_glyphs,
&super::Progress::new(),
)
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
atlas.font.uniform = self.settings.force_unicode;
atlas.font.japanese = self.settings.japanese_glyphs;
let gui = super::UiResources {
atlas,
panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
};
let pack = super::PackResources::load(
/// The render-thread half: the renderer on the new pack's textures, then
/// the swap (`ReloadInstance`'s application listeners).
pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> {
let StagedPack {
root,
self.pack.assets.clone(),
render,
self.res.facts(),
self.pack.context.biomes.clone(),
self.pack.context.light.clone(),
)?;
gui,
pack,
textures,
} = staged;
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
renderer.release_surface();
@@ -174,7 +184,17 @@ impl Game<'_> {
let renderer = match self
.window
.clone()
.map(|window| self.renderer_for(window, &pack, &gui))
.map(|window| {
build_renderer(
window,
self.options.offscreen,
&pack,
&gui,
self.renderer.as_ref(),
Some(textures),
self.settings.horizon.api,
)
})
.transpose()
{
Ok(renderer) => renderer,
@@ -190,18 +210,18 @@ impl Game<'_> {
}
};
// Everything loaded: switch over.
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
self.armor_stand_assets.reload_trims(root);
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root);
self.armor_stand_assets.reload_trims(&root);
self.random_tick_sections.clear();
self.portals.sections.clear();
self.options.resources = root.to_path_buf();
self.options.resources = root.clone();
let context = pack.context.clone();
self.pack = pack;
self.gui = gui;
// The atlas's sprites moved: the particle engine re-reads every rect
// and the packs' sprite sets, or every particle draws a stale region.
self.particles
.reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
.reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
horizon_client::ui_theme::reset_atlas();
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
*self
@@ -210,11 +230,11 @@ impl Game<'_> {
.villager_hats
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
horizon_client::mobs::villager_hats::VillagerHats::load(root);
horizon_client::ui::load_metadata(root);
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
horizon_client::mobs::villager_hats::VillagerHats::load(&root);
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
self.refresh_remote_language();
self.audio.reload(root);
self.audio.reload(&root);
self.clear_lod();
self.streaming.reload_resources(&mut self.world, context);
self.pending_uploads.clear();
@@ -229,4 +249,66 @@ impl Game<'_> {
self.creative_screen.forget_items();
Ok(())
}
/// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then
/// applied (`PackReload` runs the same two phases across frames).
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let staged = self.stage_selected_packs()?;
self.apply_staged_pack(staged)
}
}
/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a
/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay
/// root, the models and sprites, the menu bundle, the pack's tables and its
/// CPU mip chains.
pub(super) fn stage_pack(
vanilla_root: &Path,
selected: &[String],
server: &[Pack],
mods_enabled: &BTreeSet<String>,
dev_mods: &[PathBuf],
assets: &Arc<Assets>,
res: &Resources,
context: &WorkerContext,
force_unicode: bool,
japanese: bool,
progress: &super::Progress,
) -> horizon_client::Result<StagedPack> {
let root = startup_root(vanilla_root, selected, server)?;
let root = super::mods::content_root(&root, mods_enabled, dev_mods);
progress.set(1);
let render = Arc::new(assets.reload(&root)?);
let mut atlas = HudAtlas::build_over(&root, vanilla_root)?;
atlas.font.uniform = force_unicode;
atlas.font.japanese = japanese;
let gui = UiResources {
atlas,
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)),
};
progress.set(2);
let pack = PackResources::load(
&root,
assets.clone(),
render,
res.facts(),
context.biomes.clone(),
context.light.clone(),
)?;
let textures = prepare_renderer_textures(&pack);
progress.set(3);
Ok(StagedPack {
root,
gui,
pack,
textures,
})
}
/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes
/// it on the render thread).
pub(super) struct StagedPack {
pub root: PathBuf,
pub gui: UiResources,
pub pack: PackResources,
pub textures: PreparedTextures,
}
+21 -2
View File
@@ -218,11 +218,20 @@ impl Game<'_> {
_ => None,
})
});
// A living mount at the default scale and camera distance.
// Camera.setup: the living mount's scale and camera distance
// attributes (the happy ghast's is 8, the default 4).
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|_| (1.0, 4.0));
.map(|m| {
let attribute = |name: &str, default: f64| {
m.kind.attributes.get(name).copied().unwrap_or(default)
};
(
attribute("minecraft:scale", 1.0) as f32,
attribute("minecraft:camera_distance", 4.0),
)
});
// AbstractBoat.positionRider turns a passenger by the boat's
// deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot
// interpolates a passenger's yaw: the boat's turn is spread over the
@@ -443,6 +452,7 @@ impl Game<'_> {
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let start = out.len();
horizon_client::mobs::render::render_with(
m,
alpha as f32,
@@ -456,6 +466,15 @@ impl Game<'_> {
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
// in its team by its profile name.
if self.potions.effects.get("glowing").is_some() {
let team = match &self.remote {
Some(r) => self.score.board.players_team(&r.profile_name),
None => self.player_team(),
};
self.mark_outline(&out[start..], super::glowing::team_color(team));
}
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
@@ -0,0 +1,573 @@
//! `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` for
//! the entities the game moves itself: the players and the mobs.
//!
//! The geometry is in [`horizon_world::piston_push`]; this file supplies the
//! bodies and applies the movement the way `Entity.move(MoverType.PISTON, …)`
//! does: limited to 0.51 blocks per tick per axis (`Entity.limitPistonMovement`),
//! then run through the block collision scan, so a pushed entity stops at a
//! wall instead of passing through it.
//!
use super::Game;
use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip};
use horizon_core::geometry::{Aabb, Direction, Vec3, border::BorderGeometry};
use horizon_core::position::BlockPos;
use horizon_world::{
block_collision_scan::BlockCollisionWorld, level::pistons::MovingPiston,
piston_movement::PistonMovement, piston_push, world_movement::EntityMovementWorld,
};
/// What one piston asks for this tick: where it is, its state, and the progress
/// `moveCollidedEntities` runs at (before the tick stores it).
pub(super) type Push = (BlockPos, MovingPiston, f32);
/// Which entity was pushed, so its storage can be reached after the world
/// borrow ends.
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
enum Target {
Player,
Guest(u32),
Mob(usize),
Item(usize),
Boat(usize),
Cart(usize),
ArmorStand(usize),
}
/// `Entity.getStepHeight` of each body, read before the push.
struct StepHeights {
player: f32,
guests: std::collections::HashMap<u32, f32>,
}
/// What the push needs from one entity.
#[derive(Clone, Copy)]
struct Body {
bounds: Aabb,
position: Vec3,
on_ground: bool,
step_height: f32,
/// Standing on the piston's own block (`Entity.isSupportedBy`).
supported: bool,
}
impl Game<'_> {
/// Push whatever this tick's moving pistons ran into, in the order the
/// pistons ticked: vanilla pushes from each block entity's own tick.
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
let mut accumulators = std::collections::HashMap::new();
for push in pushes {
self.push_for_piston(push, &mut accumulators);
}
}
fn push_for_piston(
&mut self,
(pos, piston, new_progress): Push,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) {
let delta_progress = f64::from(new_progress - piston.progress);
if delta_progress <= 0.0 {
return;
}
let Some(bindings) = self.collision.pistons() else {
return;
};
let Ok(state) = bindings.collision_related_state(piston.shape_state()) else {
return;
};
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let Ok(shape) = self
.collision
.collision_shape(&mut scene, state, [pos.x, pos.y, pos.z])
else {
return;
};
if shape.is_empty() {
return;
}
let movement = piston.movement_direction();
let boxes: Vec<Aabb> = shape.boxes().to_vec();
let facts = self.res.facts();
let slime = &*facts.get(piston.moved_state).name == "minecraft:slime_block";
let honey = &*facts.get(piston.moved_state).name == "minecraft:honey_block";
// `moveStuckEntities` asks the moved state's own top for its box.
let sticky_top = if honey {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
self.collision
.collision_shape(&mut scene, piston.moved_state, [pos.x, pos.y, pos.z])
.ok()
.and_then(|s| s.bounds())
.map_or(0.0, |b| b.max_position().y)
} else {
0.0
};
let steps = self.push_step_heights();
let search = shape.bounds().map(|bounds| {
piston_push::movement_search_area(
piston_push::move_by_position_and_progress(pos, bounds, piston.shape_state()),
movement,
delta_progress,
)
});
let targets = self.push_targets();
for &target in &targets {
let Some(body) = self.push_body(target, pos, &steps) else {
continue;
};
let amount = piston_push::collided_amount(
&boxes,
pos,
piston.shape_state(),
new_progress,
movement,
body.bounds,
);
if slime && search.is_some_and(|area| area.intersects(body.bounds)) {
self.bounce(target, movement);
}
if amount > 0.0 {
let step = piston_push::step_vector(movement);
let before = body.bounds;
self.move_pushed(
target,
before,
[body.position.x, body.position.z],
body.step_height,
body.on_ground,
step.scale(amount),
movement,
accumulators,
);
// `fixEntityWithinPistonBase`: a retracting source piston drags
// an entity that is still inside its own block.
if !piston.extending
&& piston.source
&& let Some(after) = self.push_body(target, pos, &steps)
&& let Some(fix) = piston_push::fix_within_piston_base(
pos,
after.bounds,
movement,
delta_progress,
)
{
let back = piston_push::step_vector(movement.opposite());
self.move_pushed(
target,
after.bounds,
[after.position.x, after.position.z],
after.step_height,
after.on_ground,
back.scale(fix),
movement,
accumulators,
);
}
}
}
// Vanilla selects honey passengers after all collided entities have moved.
if honey && movement.normal()[1] == 0 {
let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top);
for target in targets {
let Some(body) = self.push_body(target, pos, &steps) else {
continue;
};
if piston_push::sticky_entity_selected(
area,
body.bounds,
horizon_world::level::data::PushReaction::PushPull,
body.on_ground,
body.supported,
body.position,
) {
self.move_pushed(
target,
body.bounds,
[body.position.x, body.position.z],
body.step_height,
body.on_ground,
piston_push::step_vector(movement).scale(delta_progress),
movement,
accumulators,
);
}
}
}
}
/// `Entity.getStepHeight` for each body, read once: the attribute lookup
/// needs the mutable body, and the push only needs the number.
fn push_step_heights(&mut self) -> StepHeights {
let player = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:step_height").ok())
.map_or(0.6, |v| v as f32);
let mut guests = std::collections::HashMap::new();
if let Some(host) = self.lan.as_mut() {
for g in &mut host.hub.guests {
let step = g
.motion
.as_mut()
.and_then(|m| m.attribute("minecraft:step_height").ok())
.map_or(0.6, |v| v as f32);
guests.insert(g.conn, step);
}
}
StepHeights { player, guests }
}
/// The entities a moving block can run into: the window's player, the LAN
/// guests in the level, then the mobs.
fn push_targets(&self) -> Vec<Target> {
let mut targets = Vec::new();
if self.player.is_some() {
targets.push(Target::Player);
}
if let Some(host) = self.lan.as_ref() {
targets.extend(
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| Target::Guest(g.conn)),
);
}
targets.extend((0..self.mobs.len()).map(Target::Mob));
targets.extend((0..self.dropped.len()).map(Target::Item));
targets.extend((0..self.boats.len()).map(Target::Boat));
targets.extend((0..self.carts.len()).map(Target::Cart));
targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand));
targets
}
/// What the push needs from one entity, or `None` when it is not pushed:
/// removed, dead, a spectator, or an armor-stand marker.
fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option<Body> {
let at = [pos.x, pos.y, pos.z];
match target {
Target::Player => {
let p = self.player.as_ref()?;
if p.spectator {
return None;
}
Some(Body {
bounds: p.bounds(),
position: p.position,
on_ground: p.ground.on_ground,
step_height: steps.player,
supported: p.ground.supporting_block == Some(pos),
})
}
Target::Guest(conn) => {
let host = self.lan.as_ref()?;
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
if g.dead || g.game_mode == 3 {
return None;
}
let p = g.position;
Some(Body {
bounds: Aabb::new(
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
),
position: Vec3::new(p[0], p[1], p[2]),
on_ground: g.on_ground,
step_height: steps.guests.get(&conn).copied().unwrap_or(0.6),
supported: g
.motion
.as_ref()
.is_some_and(|m| m.ground.supporting_block == Some(pos)),
})
}
Target::Mob(i) => {
let m = &self.mobs[i];
if m.removed || !m.alive() {
return None;
}
Some(Body {
bounds: m.bounds(),
position: m.position,
on_ground: m.on_ground,
step_height: m.kind.step_height as f32,
supported: m.supporting == Some(at),
})
}
Target::Item(i) => {
let entity = &self.dropped[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::Boat(i) => {
let entity = &self.boats[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::Cart(i) => {
let entity = &self.carts[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::ArmorStand(i) => {
let entity = &self.armor_stands[i];
if entity.marker {
return None;
}
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
}
}
/// `Entity.move(MoverType.PISTON, movement)` for one pushed entity: the
/// per-tick piston limit, then the block and world border collision scan.
fn move_pushed(
&mut self,
target: Target,
before: Aabb,
at: [f64; 2],
step_height: f32,
on_ground: bool,
movement: Vec3,
piston_direction: Direction,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) {
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
return;
};
// `limitPistonMovement` returns zero once the tick's budget is spent.
if limited == Vec3::ZERO {
return;
}
let resolved = with_piston_noclip(piston_direction, || {
pushed_movement(
SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
},
self.border.geometry(),
at,
before,
limited,
step_height,
on_ground,
)
.unwrap_or(Vec3::ZERO)
});
if resolved.length_squared() <= 1.0e-7
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
{
return;
}
self.apply_push(target, resolved);
}
/// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks
/// per game tick, and a move touches only its first non-zero axis.
fn limit_piston(
&mut self,
target: Target,
movement: Vec3,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) -> Option<Vec3> {
let time = self.world.game_time;
let limit = |state: &mut PistonMovement| state.limit(movement, || Ok::<i64, ()>(time)).ok();
match target {
Target::Player => {
let p = self.player.as_mut()?;
limit(&mut p.piston)
}
Target::Guest(conn) => {
let host = self.lan.as_mut()?;
let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?;
limit(&mut g.motion.as_deref_mut()?.piston)
}
Target::Mob(i) => limit(&mut self.mobs[i].piston),
_ => limit(accumulators.entry(target).or_default()),
}
}
fn apply_push(&mut self, target: Target, movement: Vec3) {
match target {
Target::Player => {
if let Some(p) = self.player.as_mut() {
p.position = p.position.add_vector(movement);
}
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_mut()
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
// The body and the hub's copy of its position move together:
// `leave_guest` writes the body back over the hub's.
for (slot, delta) in g.position.iter_mut().zip(movement.components()) {
*slot += delta;
}
if let Some(m) = g.motion.as_deref_mut() {
m.position = m.position.add_vector(movement);
}
}
}
Target::Mob(i) => {
let m = &mut self.mobs[i];
m.position = m.position.add_vector(movement);
}
Target::Item(i) => {
self.dropped[i].position = self.dropped[i].position.add_vector(movement)
}
Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement),
Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement),
Target::ArmorStand(i) => {
self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement)
}
}
}
/// `causeBounce`: a slime block sets the velocity along the movement axis.
fn bounce(&mut self, target: Target, movement: Direction) {
match target {
Target::Player => {
if let Some(p) = self.player.as_mut() {
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
}
}
Target::Mob(i) => {
let m = &mut self.mobs[i];
m.velocity = piston_push::bounce_velocity(movement, m.velocity);
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_mut()
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
&& let Some(motion) = guest.motion.as_deref_mut()
{
motion.velocity = piston_push::bounce_velocity(movement, motion.velocity);
}
}
Target::Item(i) => {
self.dropped[i].velocity =
piston_push::bounce_velocity(movement, self.dropped[i].velocity)
}
Target::Boat(i) => {
self.boats[i].velocity =
piston_push::bounce_velocity(movement, self.boats[i].velocity)
}
Target::Cart(i) => {
self.carts[i].velocity =
piston_push::bounce_velocity(movement, self.carts[i].velocity)
}
Target::ArmorStand(i) => {
self.armor_stands[i].velocity =
piston_push::bounce_velocity(movement, self.armor_stands[i].velocity)
}
}
}
}
/// `Entity.move(MoverType.PISTON, movement)` for a pushed body: the movement
/// it makes. The world's blocks stop it, and so does the world border:
/// `Entity.collectCollidersIgnoringWorldBorder` adds the border's walls when
/// the body is inside close to the border, as ordinary movement does.
fn pushed_movement<W>(
blocks: W,
border: &BorderGeometry,
at: [f64; 2],
before: Aabb,
movement: Vec3,
step_height: f32,
on_ground: bool,
) -> horizon_client::Result<Vec3>
where
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
{
EntityCollisions::with_border(blocks, border, at).resolve(
before,
movement,
step_height,
on_ground,
)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::collisions::EmptyCollisions;
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
fn border() -> BorderGeometry {
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
}
/// `Entity.collectCollidersIgnoringWorldBorder`: the border's walls stop a
/// pushed body that is inside close to the border.
#[test]
fn a_pushed_body_stops_at_the_world_border() {
// A body 0.6 wide, 0.4 blocks short of the wall, pushed east into it.
let before = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
let border = border();
let moved = pushed_movement(
EmptyCollisions,
&border,
[4.3, 0.3],
before,
Vec3::new(0.51, 0.0, 0.0),
0.0,
false,
)
.expect("the push resolves");
// The wall stops the body 0.4 blocks short of the whole push.
assert!(
(moved.x - 0.4).abs() < 1.0e-9,
"the body was not stopped by the border: {moved:?}"
);
}
/// Away from the border, the same push moves the whole way.
#[test]
fn a_pushed_body_moves_whole_away_from_the_border() {
let before = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
let border = border();
let moved = pushed_movement(
EmptyCollisions,
&border,
[0.3, 0.3],
before,
Vec3::new(0.51, 0.0, 0.0),
0.0,
false,
)
.expect("the push resolves");
assert!(
(moved.x - 0.51).abs() < 1.0e-9,
"the body did not move the whole push: {moved:?}"
);
}
}
+286 -47
View File
@@ -2,7 +2,12 @@
//! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`),
//! filling glass bottles (`BottleItem`), the per-tick effect application,
//! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`).
use super::{Game, Screen, item_draw::owner_at};
use super::{
Game, Screen,
dragon::{Breath, FightPlayer, FightWho},
item_draw::owner_at,
remote_projectiles::cloud_client_tick,
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
@@ -12,9 +17,13 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
};
/// A thrown splash or lingering potion in flight.
#[derive(Clone, Debug)]
@@ -31,6 +40,35 @@ pub(super) struct ThrownPotion {
/// for a dispenser's).
owner: Option<Text>,
}
/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`.
pub(super) const LINGERING_WAIT: i32 = 10;
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
const REAPPLICATION_DELAY: i32 = 20;
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
#[derive(Clone, Copy, PartialEq)]
enum Victim {
Mob(u32),
Player(FightWho),
}
/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects
/// (at the lingering item's 0.25 duration scale) and who it has affected.
pub(super) struct PotionCloud {
effects: &'static [(&'static str, i32, i32)],
/// The potion's colour (`PotionContents.getColor`).
color: u32,
/// Each victim and the tick it can be affected again.
victims: Vec<(Victim, i32)>,
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
/// thrower, else the cloud.
source: Text,
}
impl PotionCloud {
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
pub(super) fn particle(&self) -> ParticleOptions {
let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0;
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
}
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
use std::{collections::HashMap, sync::OnceLock};
@@ -48,6 +86,39 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
.get(id)
.copied()
}
/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and
/// `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(
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
kind: &str,
effect: &str,
) -> bool {
let effect = effect.trim_start_matches("minecraft:");
let kind = kind.trim_start_matches("minecraft:");
let overridden = match kind {
"wither" | "wither_skeleton" => effect == "wither",
"spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison",
"parched" => effect == "weakness",
_ => false,
};
if overridden {
return false;
}
let in_tag = |tag: &str| {
tags.get(tag)
.is_some_and(|t| t.contains(&format!("minecraft:{kind}")))
};
if in_tag("minecraft:immune_to_infested") {
effect != "infested"
} else if in_tag("minecraft:immune_to_oozing") {
effect != "oozing"
} else {
!in_tag("minecraft:ignores_poison_and_regen")
|| (effect != "regeneration" && effect != "poison")
}
}
impl Game<'_> {
/// A splash potion, lingering potion or experience bottle in flight (the
/// player's throws and dispensers both end up here).
@@ -391,6 +462,7 @@ impl Game<'_> {
self.tick_mob_effects();
if running {
self.tick_thrown_potions();
self.tick_potion_clouds();
}
}
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
@@ -406,8 +478,10 @@ impl Game<'_> {
}
// The omens act from inside the effect tick (BadOmenMobEffect,
// RaidOmenMobEffect).
let absorption_empty = player.health.absorption() <= 0.0;
let mut effects = std::mem::take(&mut self.potions.effects);
let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e));
let (actions, raid_omen_added) =
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
self.potions.effects = effects;
if raid_omen_added {
self.raid_omen_added();
@@ -548,29 +622,176 @@ impl Game<'_> {
let state =
self.world
.block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if self.res.facts().get(state).full_collision
|| self.mobs.iter().any(|m| {
m.alive()
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& p.y <= m.position.y + m.kind.height
})
{
hit = Some(p);
let mob = self.mobs.iter().find(|m| {
m.alive()
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& 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)));
break;
}
}
if let Some(p) = hit {
landed.push((t.stack.clone(), p, t.owner.clone()));
if let Some((p, mob)) = hit {
landed.push((t.stack.clone(), p, mob, t.owner.clone()));
return false;
}
t.position = next;
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
for (stack, at, owner) in landed {
self.splash(&stack, at, owner);
for (stack, at, mob, owner) in landed {
self.splash(&stack, at, mob, owner);
}
}
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
/// tick's particles, then the server tick.
fn tick_potion_clouds(&mut self) {
if self.breath_clouds.iter().all(|c| c.potion.is_none()) {
return;
}
let players: Vec<FightPlayer> = self
.fight_players()
.into_iter()
.filter(|p| p.alive)
.collect();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players));
self.breath_clouds.append(&mut clouds);
}
/// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its
/// wait it shrinks, and every 5 ticks it affects the living entities in
/// its box and radius that were not affected in the last 20 ticks, each
/// taking some of its radius. False once it is gone.
fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool {
c.age += 1;
let Some(potion) = c.potion.as_mut() else {
return true;
};
if c.age - LINGERING_WAIT >= c.duration {
return false;
}
let waiting = c.age < LINGERING_WAIT;
cloud_client_tick(
&mut self.particles,
c.at,
c.radius,
waiting,
&potion.particle(),
);
if waiting {
return true;
}
// setRadiusPerTick(-radius / duration).
c.radius += -3.0 / 600.0;
if c.radius < 0.5 {
return false;
}
if c.age % 5 != 0 {
return true;
}
potion.victims.retain(|(_, until)| c.age < *until);
let r = f64::from(c.radius);
let area = Aabb::new(
Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r),
Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r),
);
let mut inside = Vec::new();
for p in players.iter().filter(|p| p.bounds.intersects(area)) {
inside.push((Victim::Player(p.who), p.position.x, p.position.z));
}
let effects = potion.effects;
let tags = &self.res.stations().enchantments.entity_tags;
// Only mobs that at least one of the cloud's effects can affect are
// taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so
// the others neither become victims nor use up its radius.
for m in self.mobs.iter().filter(|m| {
m.alive()
&& m.bounds().intersects(area)
&& effects
.iter()
.any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id))
}) {
inside.push((Victim::Mob(m.id), m.position.x, m.position.z));
}
for (victim, x, z) in inside {
let (dx, dz) = (x - c.at[0], z - c.at[2]);
if potion.victims.iter().any(|(v, _)| *v == victim)
|| dx * dx + dz * dz > f64::from(c.radius * c.radius)
{
continue;
}
potion.victims.push((victim, c.age + REAPPLICATION_DELAY));
self.cloud_affect(victim, effects, c.at, &potion.source);
// setRadiusOnUse(-0.5).
c.radius -= 0.5;
if c.radius < 0.5 {
return false;
}
}
true
}
/// What a cloud puts on one entity: instant effects at half strength
/// (`applyInstantaneousEffect` with 0.5), the others for their duration
/// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`).
fn cloud_affect(
&mut self,
victim: Victim,
effects: &'static [(&'static str, i32, i32)],
at: [f64; 3],
source: &Text,
) {
let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1);
match victim {
Victim::Player(who) => self.as_fight_player(who, |g| {
for (id, duration, amp) in effects {
let Some(e) = brewing::effect(id) else {
continue;
};
if e.instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) {
g.apply_indirect_effect_action(a, source);
}
} else {
g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp));
}
}
}),
Victim::Mob(mob) => {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
// Undead invert healing and harming.
let tags = &self.res.stations().enchantments.entity_tags;
let inverted = tags
.get("minecraft:inverted_healing_and_harm")
.is_some_and(|t| t.contains(&self.mobs[i].kind.id));
for (id, duration, amp) in effects {
let m = &mut self.mobs[i];
match *id {
"instant_damage" | "instant_health" => {
let amount =
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
if (*id == "instant_damage") != inverted {
m.hurt(amount(6), Some(at), &mut self.world.random);
} else {
m.health = (m.health + amount(4)).min(m.kind.max_health);
}
}
_ => {
// `LivingEntity.addEffect` refuses what the mob
// cannot be affected by.
if mob_can_be_affected(tags, &m.kind.id, id)
&& let Some(name) = mob_effect_id(id)
{
m.add_effect(name, scaled(*duration), *amp);
}
}
}
}
}
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
@@ -672,12 +893,23 @@ impl Game<'_> {
self.submit_item_world(&state, pose, vertex_light(light), out);
}
}
/// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a
/// potion or a cloud): indirectMagic, not plain magic.
fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) {
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity("minecraft:indirect_magic", source.clone());
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
}
/// A splash potion's effects on the player in place, by their distance.
fn splash_on_player(
&mut self,
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
scale: f64,
source: &Text,
) {
if let Some(p) = self.player.as_ref() {
@@ -694,22 +926,10 @@ impl Game<'_> {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
// HealOrHarmMobEffect.applyInstantaneousEffect with
// a source: indirectMagic, not plain magic.
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity(
"minecraft:indirect_magic",
source.clone(),
);
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
self.apply_indirect_effect_action(a, source);
}
} else {
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
let d = (intensity * f64::from(*duration) + 0.5) as i32;
if d > 20
&& let Some(e) = brewing::effect(id)
{
@@ -720,8 +940,9 @@ impl Game<'_> {
}
}
}
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
/// for a lingering potion; `mob` is where it hit a mob.
fn splash(&mut self, stack: &ItemStack, at: Vec3, mob: Option<Vec3>, owner: Option<Text>) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
// ThrownExperienceBottle.onHit: the water-coloured splash (level
@@ -745,23 +966,41 @@ 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);
let scale = if lingering { 0.25 } else { 1.0 };
let Some(def) = potion else {
return;
};
if lingering {
// The cloud sits at the mob it hit (its feet), else the impact;
// radius 3, wait 10, 600 ticks, shrinking to nothing, and a
// potion with no effects leaves none.
if !def.effects.is_empty() {
let at = mob.unwrap_or(at);
self.breath_clouds.push(Breath {
id: 0,
at: [at.x, at.y, at.z],
radius: 3.0,
age: 0,
duration: 600,
from_dragon: false,
potion: Some(PotionCloud {
effects: def.effects,
color,
victims: Vec::new(),
source: owner.unwrap_or_else(|| {
Text::translate("entity.minecraft.area_effect_cloud", &[])
}),
}),
});
}
return;
}
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
// Instant damage is indirectMagic(potion or cloud, owner), which the
// death message names by the owner, else by the potion or cloud.
let source = owner.unwrap_or_else(|| {
let entity = if lingering {
"entity.minecraft.area_effect_cloud"
} else {
"entity.minecraft.splash_potion"
};
Text::translate(entity, &[])
});
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale, &source));
// Instant damage is indirectMagic(potion, owner), which the death
// message names by the owner, else by the potion.
let source =
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
self.for_each_player(|g| g.splash_on_player(def.effects, at, &source));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations().enchantments.entity_tags;
let mut hits = Vec::new();
@@ -784,7 +1023,7 @@ impl Game<'_> {
_ => {
// `Potion.applySplash`: timed effects last in
// proportion to how close the mob was.
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
let d = (intensity * f64::from(*duration) + 0.5) as i32;
if d > 20
&& let Some(name) = mob_effect_id(id)
{
+28 -2
View File
@@ -53,7 +53,11 @@ impl Game<'_> {
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
// Entity.playEntityOnFireExtinguishedSound
self.extinguish_sound(at);
Ok(())
}
/// Entity.playEntityOnFireExtinguishedSound
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(
@@ -62,7 +66,29 @@ impl Game<'_> {
0.7,
pitch,
);
Ok(())
}
/// `Entity.applyEffectsFromBlocks`: a burning player in the rain
/// (`isInRain`) has the fire put out (`clearFire`), with the extinguish
/// sound that everyone near hears.
pub(super) fn rain_extinguish_player(&mut self) {
let Some(player) = self.player.as_ref() else {
return;
};
let (at, top) = (player.position, player.bounds().max_position().y);
if player.fire_ticks <= 0 || !self.in_rain(at, top) {
return;
}
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
self.extinguish_sound([at.x, at.y, at.z]);
}
/// The in-window player's rain extinguish (a server's player has its
/// fire put out by the server).
pub(super) fn rain_extinguish_in_frame(&mut self) {
if self.remote.is_none() {
self.rain_extinguish_player();
}
}
/// Whether the block at `pos` is powder snow.
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
+21
View File
@@ -1036,6 +1036,7 @@ impl Game<'_> {
/// read it).
fn sync_villager_raids(&mut self, infos: &HashMap<i32, Vec<RaiderInfo>>) {
let raids = &self.raiding.raids;
let mut ongoing = Vec::new();
for m in &mut self.mobs {
if m.species() != Species::Villager {
continue;
@@ -1061,7 +1062,27 @@ impl Game<'_> {
let ext = mobs_villager::ext_mut(m);
ext.raid = status;
ext.raid_wave = wave;
if !m.no_ai && matches!(status, mobs_villager::PRE_RAID | mobs_villager::RAID) {
ongoing.push(m.id);
}
}
// Villager.customServerAiStep: a 1 in 100 chance each tick, in an
// ongoing raid, of entity event 42 (`handleEntityEvent` splashes).
for id in ongoing {
if self.particles.random().next_int_bound(100) == Ok(0) {
self.mob_raid_splash(id);
}
}
}
/// Entity event 42 for villager `id`: the guests get the byte, and this
/// game draws `AbstractVillager.addParticlesAroundSelf(SPLASH)` itself.
fn mob_raid_splash(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
self.lan_entity_event(id as i32, 42, at);
self.particles_around_mob(id, "splash", horizon_client::particles::Burst::VILLAGER);
}
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
+117 -16
View File
@@ -186,6 +186,9 @@ pub(super) struct Remote {
online_mode: bool,
/// Player entities in the level: entity id to profile UUID.
level_players: HashMap<i32, u128>,
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
/// theirs in `Mob::glowing`.
glowing: std::collections::HashSet<i32>,
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
@@ -483,6 +486,7 @@ impl Game<'_> {
listed: Default::default(),
online_mode: false,
level_players: HashMap::new(),
glowing: Default::default(),
profile: 0,
profile_name: String::new(),
destroying: None,
@@ -1430,6 +1434,13 @@ impl Game<'_> {
{
return;
}
// So does a firework rocket: its own tick flies it on.
if path.is_some()
&& let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32)
{
f.position = Vec3::new(position[0], position[1], position[2]);
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
@@ -1488,7 +1499,9 @@ impl Game<'_> {
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.lerps.remove(&id);
r.stepped.remove(&id);
@@ -1805,6 +1818,11 @@ impl Game<'_> {
r.level_players.remove(&p.id);
}
}
// Entity.getTeam: a mob is on a team by its UUID.
self.persist.uuids.insert(
p.id as u32,
horizon_client::lan::playerdata::uuid_ints(p.uuid),
);
self.spawn_entity(p.id, &kind, p.position, rotation);
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
self.remote_motion(p.id, p.velocity);
@@ -1923,7 +1941,10 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => m.invisible = flags & 0x20 != 0,
(0, Value::Byte(flags)) => {
m.invisible = flags & 0x20 != 0;
m.glowing = flags & 0x40 != 0;
}
(2, Value::OptText(name)) => {
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
m.custom_name =
@@ -1940,6 +1961,15 @@ impl Game<'_> {
}
}
}
if let Some((_, _, Value::Byte(flags))) = p.values.iter().find(|v| v.0 == 0)
&& let Some(r) = self.remote.as_mut()
{
if flags & 0x40 != 0 {
r.glowing.insert(p.id);
} else {
r.glowing.remove(&p.id);
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
@@ -2068,13 +2098,22 @@ impl Game<'_> {
// player holds and wears (EquipmentSlot ordinals).
C::SetEquipment(p) => {
self.remote_object_equipment(p.id, &p.slots);
let items: Vec<(u8, Option<Arc<str>>)> = p
let items: Vec<(u8, Option<Arc<str>>, Option<ItemStack>)> = p
.slots
.iter()
.map(|(slot, stack)| (*slot, stack.as_ref().map(|s| self.item_name(s.item))))
.map(|(slot, stack)| {
(
*slot,
stack.as_ref().map(|s| self.item_name(s.item)),
self.to_stack(stack),
)
})
.collect();
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (slot, item) in items {
// The body slot (6: horse armour, llama carpet, harness)
// is kept in `head`, as Horizon's own mobs do.
let body = horizon_client::mobs::drops::head_is_body(m.species());
for (slot, item, stack) in items {
let e = &mut m.extra;
match slot {
0 => e.main_hand = item,
@@ -2082,7 +2121,9 @@ impl Game<'_> {
2 => e.feet = item,
3 => e.legs = item,
4 => e.chest = item,
5 => e.head = item,
5 if !body => e.head = item,
6 if body => e.head = item,
7 => m.set_saddle(stack),
_ => {}
}
}
@@ -2892,7 +2933,7 @@ impl Game<'_> {
resolved.push((uuid, skin));
}
}
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.show_skin(skin));
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
r.skins = resolved.into_iter().collect();
(
@@ -2931,11 +2972,9 @@ impl Game<'_> {
.iter()
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
.map(|m| {
let t = m
.profile
.as_ref()
.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
let textures = t.map_or_else(Default::default, |t| {
let profile = m.profile.as_ref();
let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
let textures = if let Some(t) = t {
let signature = if t.signature.is_some() {
horizon_client::skins::Signature::Signed
} else {
@@ -2946,7 +2985,30 @@ impl Game<'_> {
signature,
&horizon_client::skins::TEXTURE_URIS,
)
});
} else {
// `ResolvableProfile.resolveProfile`: no `textures` and
// only a name or an id resolves through the services.
let key = profile.and_then(|p| match (&p.name, p.uuid) {
(Some(name), None) => {
Some(horizon_client::skins::NameOrId::Name(name.clone()))
}
(None, Some(id)) => Some(horizon_client::skins::NameOrId::Id(id)),
_ => None,
});
key.and_then(|key| match self.skin_profiles.borrow_mut().state(&key) {
horizon_client::skins::Resolve::Done(Some(profile)) => {
profile.textures().map(|(value, signature)| {
horizon_client::skins::unpack(
&value,
signature,
&horizon_client::skins::TEXTURE_URIS,
)
})
}
_ => None,
})
.unwrap_or_default()
};
(m.id, textures)
})
.collect();
@@ -2961,7 +3023,7 @@ impl Game<'_> {
resolved.push((id, skin));
}
for (id, skin) in resolved {
let skin = skin.filter(|s| !s.starts_with("skins/") || self.show_skin(s));
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
if let Some(mob) = self
.mobs
.iter_mut()
@@ -3011,6 +3073,10 @@ impl Game<'_> {
self.skin_slots.push(e);
true
}
/// `show_skin`, or — its slot evicted — the image queued for upload again.
fn again_skin(&mut self, location: &str) -> bool {
self.show_skin(location) || self.skin_downloads.borrow_mut().again(location)
}
/// ChatType decoration: `chat.translation_key` with sender/content parameters.
/// ChatType.Bound.decorate: the chat type's translation, with the
/// sender, the target and the message as its parameters, in its style.
@@ -3114,6 +3180,8 @@ impl Game<'_> {
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
r.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
@@ -3283,6 +3351,8 @@ impl Game<'_> {
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.sync_moving_pistons();
self.tick_hand();
self.tick_particles();
@@ -3408,13 +3478,12 @@ impl Game<'_> {
let events = std::mem::take(&mut player.events);
self.previous_eye = self.eye;
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
horizon_client::motion::MotionEvent::Emission {
state, in_water, ..
} if !crouching => {
} => {
let sounds = self.res.facts().get(state).sounds.clone();
// Player.playStepSound: muffled under the swim sound in water.
let (volume, pitch) = if in_water {
@@ -3428,6 +3497,14 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
horizon_client::motion::MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
// NetherPortalBlock.entityInside on the client's own level:
// the portal overlay and its trigger sound (the server moves
// the player).
@@ -3640,11 +3717,15 @@ impl Game<'_> {
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let mut using = Vec::new();
let mut wither_heads = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
continue;
}
if m.species() == horizon_client::mobs::Species::Wither {
wither_heads.extend((0..3).map(|i| horizon_client::mobs::wither::head_pos(m, i)));
}
m.previous = m.position;
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
@@ -3717,7 +3798,7 @@ impl Game<'_> {
m.extra.main_hand.clone()
};
if let Some(item) = item {
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
let eye = m.eye_height();
let at = [m.position.x, m.position.y, m.position.z];
using.push((
item,
@@ -3777,10 +3858,24 @@ impl Game<'_> {
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
// WitherBoss.aiStep runs on a client too: smoke around each head.
let smoke = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Smoke,
);
for h in wither_heads {
let r = self.particles.random();
let at = [
h[0] + r.next_gaussian() * 0.3,
h[1] + r.next_gaussian() * 0.3,
h[2] + r.next_gaussian() * 0.3,
];
self.particles.add(&smoke, at, [0.0; 3]);
}
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
self.guardian_bubbles();
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
@@ -4217,6 +4312,7 @@ impl Game<'_> {
}
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
} else if let Some(stack) = held.as_ref()
&& self.predict_item_on_block(stack, &hit)
{
@@ -5402,6 +5498,11 @@ impl Game<'_> {
batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true);
}
}
/// Shared flag 6 of an entity on a server's client (`None` without a server).
pub(super) fn remote_glowing(&self, id: u32) -> Option<bool> {
let r = self.remote.as_ref()?;
Some(r.glowing.contains(&(id as i32)))
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (from the
/// player list).
@@ -17,6 +17,12 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
match (*index, value) {
// Entity.DATA_POSE: a player's or mannequin's box follows it.
(6, Value::Id(pose))
if matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") =>
{
m.extra.avatar_pose = horizon_client::pose::Pose::from_id(*pose);
}
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
@@ -66,8 +66,11 @@ impl Game<'_> {
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Axolotl
| Species::Tadpole => item == "minecraft:water_bucket",
| Species::Axolotl => item == "minecraft:water_bucket",
// Tadpole.mobInteract: frog food, else the bucket.
Species::Tadpole => {
item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item)
}
Species::Armadillo => item == "minecraft:brush",
// ZombieVillager.mobInteract: CONSUME for a golden apple.
Species::ZombieVillager => item == "minecraft:golden_apple",
@@ -3,8 +3,11 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_client::{
collisions::SceneCollisions,
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
@@ -23,6 +26,58 @@ fn default_cloud_particle() -> ParticleOptions {
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles
/// near the middle; then about one per square block of the disc, drifting
/// (an entity effect stays still).
pub(super) fn cloud_client_tick(
particles: &mut horizon_client::particles::ParticleEngine,
at: [f64; 3],
radius: f32,
waiting: bool,
particle: &ParticleOptions,
) {
if waiting && particles.random().next_boolean() {
return;
}
let (count, spread) = if waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * radius * radius).ceil() as i32,
radius,
)
};
for _ in 0..count {
let r = particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
at[0] + f64::from(angle.cos() * distance),
at[1],
at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if particle.ty == ParticleType::EntityEffect {
let default = waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
particle.clone()
};
(options, [0.0; 3])
} else if waiting {
(particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(particle.clone(), v)
};
particles.add(&options, at, velocity);
}
}
/// The projectile a type id names, as Horizon models it.
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
Some(match kind {
@@ -86,7 +141,27 @@ pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorl
}
}
/// `AbstractHurtingProjectile.getTrailParticle`: smoke for every one of them
/// but the wind charges (no trail) and the dragon's fireball (its own).
pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool {
matches!(
kind,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. }
)
}
impl Game<'_> {
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
/// block above each position.
pub(super) fn smoke_trails(&mut self, at: impl IntoIterator<Item = Vec3>) {
let smoke = ParticleOptions::simple(ParticleType::Smoke);
for p in at {
self.particles.add(&smoke, [p.x, p.y + 0.5, p.z], [0.0; 3]);
}
}
/// `handleAddEntity` for these types; false when `kind` isn't one.
pub(super) fn spawn_remote_projectile(
&mut self,
@@ -360,35 +435,70 @@ impl Game<'_> {
}
}
self.creatures.projectiles = projectiles;
let trails: Vec<Vec3> = self
.creatures
.projectiles
.iter()
.filter(|p| has_smoke_trail(&p.kind))
.map(|p| p.position)
.collect();
self.smoke_trails(trails);
for t in &mut self.tridents {
let id = t.id;
t.remote_step(|at| step(id, at));
}
// FireworkRocketEntity.tick: a rocket attached to the gliding local
// player boosts it client-side too (attachedToEntity.isFallFlying).
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
// and boosts it client-side too (attachedToEntity.isFallFlying), and
// every rocket leaves its spark trail on a client.
let me = self
.remote
.as_ref()
.and_then(|r| u32::try_from(r.entity_id).ok());
for r in &mut self.rockets {
let id = r.id;
r.remote_step(|at| step(id, at));
if me.is_some()
&& r.attached_to == me
&& let Some(p) = self.player.as_mut()
&& p.fall_flying
{
let look = view_vector(self.yaw, self.pitch);
let m = p.velocity;
p.velocity = Vec3::new(
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
);
r.position = p.position;
r.velocity = p.velocity;
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
r.remote_step(&mut SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
});
if let Some(target) = r.attached_to {
if me == Some(target) {
if let Some(p) = self.player.as_mut() {
if p.fall_flying {
let look = view_vector(self.yaw, self.pitch);
let m = p.velocity;
p.velocity = Vec3::new(
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
);
}
r.position = p.position;
r.velocity = p.velocity;
}
} else if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
// Another player's motion is not known here: its step.
r.velocity = Vec3::new(
m.position.x - r.previous.x,
m.position.y - r.previous.y,
m.position.z - r.previous.z,
);
r.position = m.position;
}
}
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
let v = [
g(&mut self.world.random),
-r.velocity.y * 0.5,
g(&mut self.world.random),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::Firework),
[r.position.x, r.position.y, r.position.z],
v,
);
}
self.rockets = rockets;
for c in &mut self.crystals {
c.time += 1;
c.position = step(c.id, c.position);
@@ -418,52 +528,10 @@ impl Game<'_> {
}
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
/// particles near the middle; then about one per square block of the
/// disc, drifting (an entity effect stays still).
/// `AreaEffectCloud.clientTick` for each cloud a server tracks.
fn tick_remote_clouds(&mut self) {
for c in &self.potions.clouds {
let r = self.particles.random();
if c.waiting && r.next_boolean() {
continue;
}
let (count, spread) = if c.waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
c.radius,
)
};
for _ in 0..count {
let r = self.particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
c.at[0] + f64::from(angle.cos() * distance),
c.at[1],
c.at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
let default = c.waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
c.particle.clone()
};
(options, [0.0; 3])
} else if c.waiting {
(c.particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(c.particle.clone(), v)
};
self.particles.add(&options, at, velocity);
}
cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle);
}
}
@@ -288,10 +288,11 @@ impl Game<'_> {
e[2].floor() as i32,
];
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
if rain > 0.0 {
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10).
// The Horizon Weather option off means no rain visuals at all.
if rain > 0.0 && self.settings.horizon.weather {
// ClientLevel.tickWeatherEffects: rain splashes within the weather radius.
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
let radius = 10;
let radius = self.settings.weather_radius;
let diameter = 2 * radius + 1;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
for _ in 0..count {
+2 -2
View File
@@ -1194,14 +1194,14 @@ impl Game<'_> {
/// `LightmapRenderStateExtractor`: the night vision effect's intensity
/// (`GameRenderer.nightVisionScale`), or conduit power's water vision.
pub(super) fn night_vision_intensity(&self) -> f32 {
pub(super) fn night_vision_intensity(&self, partial: f32) -> f32 {
use horizon_client::fog::FogType;
if let Some(e) = self.potions.effects.get("night_vision") {
// MobEffectInstance.endsWithin(200); -1 is infinite.
return if e.duration < 0 || e.duration > 200 {
1.0
} else {
0.7 + (e.duration as f32 * std::f32::consts::PI * 0.2).sin() * 0.3
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
};
}
if self.potions.effects.get("conduit_power").is_some()
+96 -16
View File
@@ -3,7 +3,7 @@
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
//! server list's setting, downloads into `downloads/`, and a resource reload
//! with the server's packs on top of the player's own.
use super::Game;
use super::{Game, Progress};
use horizon_client::{
Result,
confirm_screen::Confirm,
@@ -82,6 +82,9 @@ pub(super) struct ServerPackState {
pub(super) active: Vec<packs::Pack>,
/// The download batches' notifiers, oldest first.
notifiers: Vec<PackNotifier>,
/// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the
/// frame draws its progress until `tick_server_packs` applies it.
pub(super) reload: Option<PackReload>,
}
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
/// own `SystemToast`.
@@ -191,6 +194,21 @@ fn saves() -> PathBuf {
std::env::current_dir().unwrap_or_default().join("saves")
}
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
const RELOAD_LINES: [&str; 4] = [
"Preparing resources",
"Loading assets",
"Loading pack tables",
"Applying resources",
];
/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a
/// worker while `progress` feeds the loading screen, then the apply lands
/// on the render thread.
pub(super) struct PackReload {
pub(super) progress: Progress,
handle: std::thread::JoinHandle<std::result::Result<super::packs::StagedPack, String>>,
}
impl Game<'_> {
/// `ConnectScreen`: the server's setting decides what its packs do
/// (`configureForServerControl`); single player's own server takes them
@@ -314,6 +332,7 @@ impl Game<'_> {
if self.rest_loader.is_some() || self.world_loader.is_some() {
return;
}
self.drive_pack_reload();
let finished = self
.server_packs
.queue
@@ -328,13 +347,8 @@ impl Game<'_> {
if !tick.download.is_empty() {
self.start_pack_downloads(tick.download);
}
if let Some(mut load) = tick.reload {
while let Some(recovery) = {
let ok = self.reload_server_packs(&load);
self.server_packs.packs.reloaded(ok)
} {
load = recovery;
}
if let Some(load) = tick.reload {
self.start_pack_reload(&load);
}
}
for (id, status) in self.server_packs.packs.take_replies() {
@@ -379,24 +393,90 @@ impl Game<'_> {
}
}
/// `DownloadedPackSource.startReload`: the packs on top of the player's
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
/// readable `pack.mcmeta` or the reload failed.
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
let packs: Option<Vec<_>> = load
/// own, staged on a worker (`Minecraft.reloadResourcePacks` under a
/// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no
/// readable `pack.mcmeta` fails the reload at once, as `onFailure`.
fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) {
let server: Option<Vec<_>> = load
.iter()
.map(|(id, path)| packs::server_pack(*id, path))
.collect();
let Some(packs) = packs else {
let Some(server) = server else {
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
return false;
self.finish_pack_reload(false);
return;
};
self.server_packs.active = packs;
match self.reload_selected_packs() {
self.server_packs.active = server.clone();
// The overlay swallows input until the apply (`LoadingOverlay`):
// release what is held so nothing keeps acting while it covers the
// frame (`KeyMapping.releaseAll` when a screen opens).
self.keys.clear();
self.physical_keys.clear();
self.left = false;
self.right = false;
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let (selected, mods, dev_mods) = (
self.settings.resource_packs.clone(),
self.settings.mods.enabled.clone(),
self.options.dev_mods.clone(),
);
let (assets, res, context) = (
self.pack.assets.clone(),
self.res.clone(),
self.pack.context.clone(),
);
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
let progress = Progress::with(&RELOAD_LINES);
let worker = progress.clone();
let handle = std::thread::Builder::new()
.name("server-pack-reload".into())
.spawn(move || {
super::packs::stage_pack(
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context,
unicode, japanese, &worker,
)
.map_err(|e| e.to_string())
});
match handle {
Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }),
Err(e) => {
log::error!("Horizon: could not start the pack reload worker: {e}");
self.finish_pack_reload(false);
}
}
}
/// `PackReloadConfig.Callbacks`: applies the staged reload once its
/// worker is done; on failure the packs active before reload again.
fn drive_pack_reload(&mut self) {
let Some(reload) = &self.server_packs.reload else {
return;
};
if !reload.handle.is_finished() {
return;
}
let reload = self.server_packs.reload.take().unwrap();
let staged = reload
.handle
.join()
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
let ok = match staged
.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()))
{
Ok(()) => true,
Err(e) => {
log::warn!("Horizon: server resource pack reload failed: {e}");
false
}
};
self.finish_pack_reload(ok);
}
/// `ServerPacks::reloaded` and the recovery reload it can ask for.
fn finish_pack_reload(&mut self, ok: bool) {
if let Some(recovery) = self.server_packs.packs.reloaded(ok) {
self.start_pack_reload(&recovery);
}
}
+153 -1
View File
@@ -5,7 +5,8 @@ use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
signs::wood_type,
items::ItemStack,
signs::{DYE_TEXT, wood_type},
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
@@ -31,6 +32,157 @@ fn sign_box() -> EditBox {
}
impl Game<'_> {
/// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in
/// front of the sign's text (within 90 degrees of its facing).
fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool {
let Some(player) = self.player.as_ref() else {
return true;
};
let state = self.world.block(pos.0, pos.1, pos.2);
let Some(state) = self.pack.assets.registry.state(state) else {
return true;
};
let prop = |key: &str| state.properties.get(key).map(String::as_str);
// getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or
// ceiling sign's rotation segment.
let y_rot = if state.name.contains("_wall_") {
match prop("facing") {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
}
} else {
prop("rotation")
.and_then(|r| r.parse::<f32>().ok())
.unwrap_or(0.0)
* 22.5
};
// getSignHitboxCenterPosition: only a wall sign's board is off-centre,
// against the wall it hangs on.
let centre = if state.name.ends_with("_wall_sign") {
match y_rot as i32 {
90 => (0.9375, 0.5),
180 => (0.5, 0.9375),
270 => (0.0625, 0.5),
_ => (0.5, 0.0625),
}
} else {
(0.5, 0.5)
};
let xd = player.position.x - (f64::from(pos.0) + centre.0);
let zd = player.position.z - (f64::from(pos.2) + centre.1);
let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32;
// Mth.degreesDifferenceAbs.
let diff = (player_y_rot - y_rot).rem_euclid(360.0);
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.
pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return;
};
if sign.waxed {
// `getSignInteractionFailedSoundEvent`: a hanging sign has its own.
let sound = if sign.hanging {
"minecraft:block.hanging_sign.waxed_interact_fail"
} else {
"minecraft:block.sign.waxed_interact_fail"
};
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);
self.open_sign_editor(key, front);
}
}
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
/// honeycomb): the item changes the side the player faces and is used up.
/// Whether it did; a sign it does not apply to goes on to the empty hand.
pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let Some(stack) = held else {
return false;
};
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return false;
};
if sign.waxed || !self.may_build() {
return false;
}
let item = &*stack.item;
let dye = if item.ends_with("_dye") {
self.res
.items()
.get(&stack.item)
.and_then(|i| i.dye.clone())
} else {
None
};
let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d));
if !matches!(
item,
"minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac"
) && colour.is_none()
{
return false;
}
let front = self.facing_sign_front(key);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
return false;
};
let centre = pos.map(|v| f64::from(v) + 0.5);
let sound = if item == "minecraft:honeycomb" {
// HoneycombItem.tryApplyToSign: waxes any sign, empty or not.
sign.waxed = true;
"minecraft:item.honeycomb.wax_on"
} else {
let side = if front {
&mut sign.front
} else {
&mut sign.back
};
// SignApplicator.canApplyToSign: the side must have text.
if side.is_empty() {
return false;
}
match colour {
Some(c) if side.color != c as u8 => {
side.color = c as u8;
"minecraft:item.dye.use"
}
None if item == "minecraft:glow_ink_sac" && !side.glowing => {
side.glowing = true;
"minecraft:item.glow_ink_sac.use"
}
None if item == "minecraft:ink_sac" && side.glowing => {
side.glowing = false;
"minecraft:item.ink_sac.use"
}
_ => return false,
}
};
if item == "minecraft:honeycomb" {
self.level_event_at(3003, pos, 0);
}
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.mark_block_entity_updated(key);
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event(
"minecraft:block_change",
pos,
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
self.consume_held();
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// `SignBlock.openTextEdit` for the front side after placement.
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
self.sign_edit = Some(SignEditor {
+1 -1
View File
@@ -253,7 +253,7 @@ impl Game<'_> {
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
/// `LivingEntity.makePoofParticles` (entity event 20) for a mob.
fn mob_poof(&mut self, id: u32) {
pub(super) fn mob_poof(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
@@ -546,6 +546,11 @@ impl Loading {
(batch.width, batch.height),
self.menu.modifiers.shift_key(),
);
title.press_rules(
&gui.atlas,
self.menu.cursor,
(batch.width, batch.height),
);
self.menu_action(event_loop, action);
}
}
+22 -10
View File
@@ -256,9 +256,8 @@ impl Game<'_> {
.fluid
== horizon_client::blocks::Fluid::Water;
let on_ground = p.ground.on_ground;
let (sprinting, flying, gliding, swimming) = (
let (sprinting, gliding, swimming) = (
p.sprinting,
p.flying,
p.fall_flying,
p.pose == horizon_client::pose::Pose::Swimming,
);
@@ -272,15 +271,27 @@ impl Game<'_> {
});
let s = &mut self.stats;
match riding {
Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total),
Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total),
Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", horizontal)
Some(Ok(sp)) => {
use horizon_client::mobs::Species;
// `checkRidingStatistics`: only these mounts count, the horse
// family by the full 3D distance; any other mount nothing.
match sp {
Species::Pig => s.custom("pig_one_cm", total),
Species::Strider => s.custom("strider_one_cm", total),
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
Species::Nautilus | Species::ZombieNautilus => {
s.custom("nautilus_one_cm", total)
}
sp if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", total)
}
_ => {}
}
}
Some(_) => {
Some(Err(())) => {
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
s.custom("boat_one_cm", total)
} else {
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
s.custom("minecart_one_cm", total)
}
}
@@ -300,8 +311,9 @@ impl Game<'_> {
}
}
None if gliding => s.custom("aviate_one_cm", total),
None if flying => s.custom("fly_one_cm", horizontal),
None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32),
// Airborne and not gliding: more than 25 cm sideways per move
// (creative flight, or a long jump), not only flying.
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
None => {}
}
}
+24 -6
View File
@@ -245,7 +245,7 @@ impl Game<'_> {
self.handle_mob_events(events)?;
return Ok(true);
}
self.stats.custom("talk_to_villager", 1);
self.stats.custom("talked_to_villager", 1);
self.ensure_offers(index);
let m = &self.mobs[index];
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
@@ -398,18 +398,21 @@ impl Game<'_> {
.as_ref()
.and_then(|s| s.merchant.as_ref())
.and_then(|m| m.mob);
let session = self.merchant_apply(session, mob);
let (session, traded) = self.merchant_apply(session, mob);
self.merchant_traded(mob, &traded);
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
m.session = session;
}
}
/// What a menu click's session did, applied to the world and its mob.
/// What a menu click's session did, applied to the world and its mob;
/// with the result of each trade made, for `merchant_traded`.
pub(super) fn merchant_apply(
&mut self,
session: Box<merchant::Session>,
mob: Option<u32>,
) -> Box<merchant::Session> {
) -> (Box<merchant::Session>, Vec<ItemStack>) {
let mut session = session;
let mut traded = Vec::new();
let events = std::mem::take(&mut session.events);
let (villager, origin) = (session.villager, session.origin);
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
@@ -438,11 +441,26 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1),
merchant::Event::Traded(index) => {
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
}
merchant::Event::TradeSound => {}
}
}
session
(session, traded)
}
/// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade`
/// for each trade made: the statistic and the `villager_trade` trigger.
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, results: &[ItemStack]) {
for result in results {
self.stats.custom("traded_with_villager", 1);
let villager = mob
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| self.mob_entity(m));
if let Some(villager) = villager {
self.trigger_trade(&villager, result);
}
}
}
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
/// trade, a tick later; the prices follow the new reputation.
+4 -4
View File
@@ -21,7 +21,7 @@ pub(super) struct ThrownTrident {
pub id: u32,
pub(super) stack: ItemStack,
pub(super) position: Vec3,
previous: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
pub(super) yaw: f32,
pub(super) pitch: f32,
@@ -112,7 +112,7 @@ impl Game<'_> {
}
true
}
fn in_water_or_rain(&self) -> bool {
fn in_water_or_rain(&mut self) -> bool {
let Some(p) = &self.player else { return false };
let (x, y, z) = (
p.position.x.floor() as i32,
@@ -121,8 +121,8 @@ impl Game<'_> {
);
let water = self.res.facts().get(self.world.block(x, y, z)).fluid
== horizon_client::blocks::Fluid::Water;
let sky = self.world.light(x, y + 1, z) >> 4 == 15;
water || (self.world.weather.raining && sky)
let (at, top) = (p.position, p.bounds().max_position().y);
water || self.in_rain(at, top)
}
/// The living entities a spinning player can touch this tick, for
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
+2
View File
@@ -169,6 +169,8 @@ impl Game<'_> {
self.consume_held();
}
}
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &stack);
Ok(true)
}
fn vault_events(&mut self, key: Pos, events: Vec<VaultEvent>) -> Result<()> {
@@ -94,6 +94,8 @@ impl WorldRequest {
level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into();
level.allow_commands = Some(o.commands(creative));
horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?;
// WorldCreationUiState.getGameRules, written with the level.
level.game_rules = o.rules.by_name();
}
// Worlds made without the Create New World screen (--world, the
// control socket) take its default: commands on for Creative only.
@@ -1,8 +1,9 @@
//! InWorldGameRulesScreen (AbstractGameRulesScreen), opened from World
//! Options: the rules by category under a search box, a switch or a number
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
//! AbstractGameRulesScreen: the rules by category under a search box, a
//! switch or a number box each. InWorldGameRulesScreen, opened from World
//! Options, sends the changed ones on Done (ServerboundSetGameRulePacket) and
//! asks before leaving with changes (ConfirmScreen); WorldCreationGameRulesScreen,
//! opened from Create New World, hands them back and leaves without asking.
use crate::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
@@ -32,14 +33,14 @@ enum Row {
/// What a click or Escape asks of World Options.
#[derive(Debug, PartialEq)]
pub(super) enum Exit {
pub enum Exit {
/// Done: the rules that changed.
Apply(Vec<(&'static str, Value)>),
/// Cancel, or discarding the changes.
Discard,
}
pub(super) struct RulesScreen {
pub struct RulesScreen {
search: EditBox,
values: GameRules,
initial: GameRules,
@@ -49,6 +50,8 @@ pub(super) struct RulesScreen {
scroll: ui::Scroll,
/// ConfirmScreen "Game Rule Changes" in its place.
confirm: bool,
/// InWorldGameRulesScreen rather than WorldCreationGameRulesScreen.
in_world: bool,
}
fn name_key(r: &Rule) -> String {
@@ -102,7 +105,15 @@ fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
}
impl RulesScreen {
pub(super) fn new(rules: &GameRules) -> Self {
/// InWorldGameRulesScreen over the world's rules.
pub fn new(rules: &GameRules) -> Self {
Self::build(rules, true)
}
/// WorldCreationGameRulesScreen over the new world's rules.
pub fn for_creation(rules: &GameRules) -> Self {
Self::build(rules, false)
}
fn build(rules: &GameRules, in_world: bool) -> Self {
// setInitialFocus: the search box.
let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0);
let numbers = RULES
@@ -123,6 +134,7 @@ impl RulesScreen {
numbers,
scroll: ui::Scroll::default(),
confirm: false,
in_world,
}
}
@@ -197,9 +209,10 @@ impl RulesScreen {
})
.collect()
}
/// InWorldGameRulesScreen.onClose: pending changes ask first.
/// InWorldGameRulesScreen.onClose: pending changes ask first;
/// WorldCreationGameRulesScreen.onClose discards them.
fn close(&mut self) -> Option<Exit> {
if self.changes().is_empty() {
if !self.in_world || self.changes().is_empty() {
Some(Exit::Discard)
} else {
self.confirm = true;
@@ -207,7 +220,7 @@ impl RulesScreen {
}
}
/// Escape: ConfirmScreen answers no; the rules close.
pub(super) fn escape(&mut self) -> Option<Exit> {
pub fn escape(&mut self) -> Option<Exit> {
if self.confirm {
self.confirm = false;
None
@@ -216,7 +229,7 @@ impl RulesScreen {
}
}
/// The mouse wheel (AbstractSelectionList: half an entry a step).
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
pub fn wheel(&mut self, delta: f32, h: f32) {
if !self.confirm {
let content = self.content();
self.scroll
@@ -224,20 +237,20 @@ impl RulesScreen {
}
}
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
pub fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
let content = self.content();
self.scroll
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
}
/// The pointer moved while the scrollbar is held.
pub(super) fn drag(&mut self, y: f32, h: f32) {
pub fn drag(&mut self, y: f32, h: f32) {
if self.scroll.held() {
let content = self.content();
self.scroll.drag(y, HEADER, h - FOOTER, content);
}
}
/// The button came up: the scrollbar lets go.
pub(super) fn release(&mut self) {
pub fn release(&mut self) {
self.scroll.release();
}
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
@@ -272,7 +285,7 @@ impl RulesScreen {
}
}
/// Run an edit on the focused box, then its responder.
pub(super) fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
pub fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
let Some((rule, b)) = self.focused() else {
return;
};
@@ -282,12 +295,12 @@ impl RulesScreen {
self.edited(rule);
}
}
pub(super) fn typing(&self) -> bool {
pub fn typing(&self) -> bool {
!self.confirm && (self.search.focused || self.numbers.iter().any(|(_, b)| b.focused))
}
/// A left click; Some when the screen closes.
pub(super) fn click(
pub fn click(
&mut self,
atlas: &HudAtlas,
cursor: (f32, f32),
@@ -355,12 +368,7 @@ impl RulesScreen {
}
}
/// Whether a click lands on a button (for its sound).
pub(super) fn clicks_button(
&self,
atlas: &HudAtlas,
cursor: (f32, f32),
size: (f32, f32),
) -> bool {
pub fn clicks_button(&self, atlas: &HudAtlas, cursor: (f32, f32), size: (f32, f32)) -> bool {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
@@ -377,9 +385,9 @@ impl RulesScreen {
})
}
pub(super) fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
if self.confirm {
let labels = [
Text::translate("gui.yes", &[]),
@@ -393,7 +401,7 @@ impl RulesScreen {
self.scroll.clamp(HEADER, h - FOOTER, content);
// RuleList: the in-world list background, its entries clipped to it,
// then the separators and scrollbar.
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
ui::list_background(batch, atlas, HEADER, h - FOOTER, self.in_world);
let first = batch.vertices.len();
let hovered = self.hovered(rows.len(), cursor, (w, h));
let (left, x) = (row_left(w), control_x(w));
@@ -450,7 +458,7 @@ impl RulesScreen {
}
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
ui::separators(batch, atlas, HEADER, h - FOOTER, self.in_world);
ui::scrollbar(
batch,
atlas,
@@ -526,8 +534,8 @@ mod tests {
#[test]
fn box_colours_are_vanillas() {
assert_eq!(VALID, horizon_client::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, horizon_client::hud::rgba(255, 0, 0, 255));
assert_eq!(VALID, crate::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, crate::hud::rgba(255, 0, 0, 255));
}
#[test]
+122 -34
View File
@@ -35,9 +35,13 @@ fn entity_shader() -> String {
#[cfg(test)]
mod crumbling_tests;
mod entity_batch;
mod entity_outline;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
pub use entity_batch::{
SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -247,6 +251,8 @@ pub struct Frame<'a> {
pub outline: Vec<[f64; 6]>,
/// `BlockOutlineRenderState.isTranslucent`: draw after translucent terrain.
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -260,6 +266,9 @@ pub struct Frame<'a> {
pub nether_lighting: bool,
/// Camera-relative entity geometry textured from the shared atlas.
pub entities: &'a [Vertex],
/// The glowing entities' silhouettes in their team colours (`OutlineBufferSource`),
/// outlined through walls after the hand.
pub glowing: &'a [Vertex],
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
pub entity_spans: &'a [pack_draw::EntitySpan],
pub weather: &'a [crate::weather::Vertex],
@@ -377,6 +386,7 @@ pub struct Renderer {
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
lines_secondary: wgpu::RenderPipeline,
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
/// always-on-top ones.
debug_lines: wgpu::RenderPipeline,
@@ -391,6 +401,7 @@ pub struct Renderer {
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
shadow_blob_pipeline: wgpu::RenderPipeline,
translucent_item_pipeline: wgpu::RenderPipeline,
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
water_mask_pipeline: wgpu::RenderPipeline,
cloud_pipeline: wgpu::RenderPipeline,
@@ -407,6 +418,8 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
entity_outline: entity_outline::EntityOutline,
panorama: panorama::Panorama,
/// `RenderPipelines.WORLD_BORDER` and its buffers.
border_pipeline: wgpu::RenderPipeline,
@@ -1213,12 +1226,6 @@ impl Renderer {
attributes: &instance_attributes,
}),
];
let line_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3];
let line_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 24,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &line_attributes,
})];
let alpha_blend = wgpu::BlendState::ALPHA_BLENDING;
let debug_line_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
@@ -1351,13 +1358,25 @@ impl Renderer {
"Block outline",
"vs_line",
"fs_line",
&line_buffers,
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
true,
true,
Some(alpha_blend),
);
// RenderTypes.secondaryBlockOutline: the black stroke under a high contrast outline.
let lines_secondary = pipeline(
"Block outline secondary",
"vs_line",
"fs_line",
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
false,
true,
Some(alpha_blend),
);
let debug_lines = pipeline(
"Debug lines",
"vs_debug_line",
@@ -1531,7 +1550,7 @@ impl Renderer {
});
let outline = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Outline"),
size: 24 * MAX_OUTLINE_EDGES,
size: 32 * 2 * MAX_OUTLINE_EDGES,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -1839,6 +1858,19 @@ impl Renderer {
alpha_blend,
false,
);
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
// translucent terrain, it cannot write depth: the water behind a
// glass item would fail the test and vanish (vanilla sorts these in
// its order independent transparency pass instead).
let translucent_item_pipeline = entity_pipeline(
"Translucent items",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
alpha_blend,
false,
);
let cloud_pipeline = entity_pipeline(
"Clouds",
Some(wgpu::Face::Back),
@@ -2319,6 +2351,11 @@ impl Renderer {
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
let stars = stars::Stars::new(&device, config.format.remove_srgb_suffix());
let entity_outline = entity_outline::EntityOutline::new(
&device,
(&entity_shader, &entity_layout, &entity_vertex),
config.format.remove_srgb_suffix(),
);
let panorama = panorama::Panorama::new(
&device,
&queue,
@@ -2364,6 +2401,7 @@ impl Renderer {
sky,
dark_disc,
lines,
lines_secondary,
debug_lines,
debug_lines_on_top,
debug_line_buffer,
@@ -2374,6 +2412,7 @@ impl Renderer {
weather,
crumbling_pipeline,
shadow_blob_pipeline,
translucent_item_pipeline,
water_mask_pipeline,
cloud_pipeline,
cloud_mesh: Default::default(),
@@ -2389,6 +2428,7 @@ impl Renderer {
celestial_buffer,
end_sky: end_sky::EndSky::default(),
stars,
entity_outline,
panorama,
border_pipeline,
border_group,
@@ -2485,6 +2525,18 @@ impl Renderer {
present_modes,
})
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
pass.set_vertex_buffer(0, self.outline.slice(..));
if high_contrast {
pass.set_pipeline(&self.lines_secondary);
pass.draw(0..4, 0..edges);
}
pass.set_pipeline(&self.lines);
let first = if high_contrast { edges } else { 0 };
pass.draw(0..4, first..first + edges);
}
fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
@@ -3197,7 +3249,7 @@ impl Renderer {
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
let levels = sprite_mipmap_limit(
let levels = crate::sprites::sprite_mipmap_limit(
levels,
self.sprite_textures[0].width(),
self.sprite_minimum_size,
@@ -3957,7 +4009,26 @@ impl Renderer {
);
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
let lines = outline_vertices(&frame.outline[..outline_edges], camera);
// The same per-line colour and pixel width layout as the gizmo lines. A high
// contrast outline is two strokes: the black one first, the bright one after it.
let edges = outline_vertices(&frame.outline[..outline_edges], camera);
let width = outline_width(self.config.width);
let stroke = |color: u32, width: f32| {
edges.iter().map(move |e| {
let mut line = [0.0; 8];
line[..6].copy_from_slice(e);
line[6] = f32::from_bits(color);
line[7] = width;
line
})
};
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0)
.chain(stroke(0xff57_ffe1, width))
.collect()
} else {
stroke(0x6600_0000, width).collect()
};
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
}
@@ -3984,6 +4055,7 @@ impl Renderer {
water_mask_quads,
shadow_quads,
translucent_particle_quads,
translucent_item_quads,
] = self
.entity_batch
.update(entities, frame.additive, frame.hand, frame.see_through);
@@ -3993,7 +4065,8 @@ impl Renderer {
+ translucent_crumbling_quads
+ water_mask_quads
+ shadow_quads
+ translucent_particle_quads;
+ translucent_particle_quads
+ translucent_item_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -4054,6 +4127,13 @@ impl Renderer {
bytemuck::cast_slice(all_entities),
);
}
self.entity_outline.prepare(
&self.device,
&mut self.uploads,
&mut encoder,
frame.glowing,
[self.config.width, self.config.height],
);
if let Some((rotation, brightness)) = frame.stars {
self.stars.prepare(
&mut self.uploads,
@@ -4081,7 +4161,8 @@ impl Renderer {
} else {
lightning::QUADS
})
.max(weather_quads);
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
if max_quads > self.index_quads {
self.index_quads = max_quads.next_power_of_two();
self.indices = Self::quad_indices(&self.device, self.index_quads);
@@ -4516,6 +4597,17 @@ impl Renderer {
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_item_quads * 6,
((world_quads - translucent_item_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if !pack_draws_weather {
self.weather.draw(&mut pass, &self.entity_group);
}
@@ -4533,7 +4625,11 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
((world_quads
- translucent_item_quads
- translucent_particle_quads
- shadow_quads)
* 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4565,9 +4661,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if outline_edges > 0 && !frame.outline_after_terrain {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if debug_depth > 0 {
pass.set_pipeline(&self.debug_lines);
@@ -4614,7 +4708,8 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_particle_quads * 6,
((world_quads - translucent_particle_quads) * 4) as i32,
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4622,9 +4717,7 @@ impl Renderer {
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// LevelRenderer: the world border after translucent terrain and weather.
@@ -4811,6 +4904,13 @@ impl Renderer {
0..1,
);
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
self.entity_outline.draw(
&mut encoder,
&view_target,
&self.entity_group,
&self.indices,
);
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
@@ -5243,7 +5343,7 @@ mod tests {
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
assert_eq!(counts, [4, 1, 1, 0, 1, 1, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -5479,11 +5579,6 @@ mod tests {
}
}
pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, available: u32) -> u32 {
// Only the atlas native minimum determines how many resampling-only mips to skip.
let upscale = (size / minimum.max(1)).max(1).ilog2();
requested.saturating_add(upscale).min(available - 1)
}
#[cfg(test)]
mod sprite_upload_tests {
use super::*;
@@ -5544,13 +5639,6 @@ mod sprite_upload_tests {
}
}
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
assert_eq!(sprite_mipmap_limit(levels, 16, 16, 5), levels);
}
}
#[test]
fn prepared_texture_uploads_preserve_layer_order_and_settings() {
let size = 4;
let layers = 5;
@@ -5,14 +5,20 @@ use super::Vertex;
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
const MATERIALS: usize = 6;
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
/// and translucent item quads (32, drawn without depth writes).
const MATERIALS: usize = 7;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
pub const SHADOW_OVERLAY: u32 = 14;
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
/// The overlay of item quads with translucent pixels (`Sheets.translucentItemSheet`,
/// `RenderPipelines.ITEM_TRANSLUCENT`); past the creeper swell range (16 to 31).
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
/// A translucent item with an enchantment glint.
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
#[derive(Default)]
pub(super) struct EntityBatch {
@@ -45,6 +51,7 @@ impl EntityBatch {
WATER_MASK_OVERLAY => 3,
SHADOW_OVERLAY => 4,
TRANSLUCENT_PARTICLE_OVERLAY => 5,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 6,
_ => 0,
};
self.buckets[material][counts[material]] = index;
@@ -0,0 +1,311 @@
//! The entity outline (`LevelRenderer.entityOutlineTarget`): the glowing entities'
//! silhouettes in their team colours, drawn without depth so walls do not hide them
//! (`RenderTypes.outline`), the `entity_outline` post chain over them (`entity_sobel`, then
//! `entity_outline_box_blur` across and up), and `LevelRenderer.blitEntityOutline` onto
//! the frame after the hand.
use super::Vertex;
/// `entityOutlineTarget`'s `GpuFormat.RGBA8_UNORM`.
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
struct Targets {
size: [u32; 2],
/// `entityOutlineTarget`.
outline: wgpu::TextureView,
/// The chain's `swap` target.
swap: wgpu::TextureView,
/// What each pass reads: the outline (nearest), swap (bilinear), the outline
/// (bilinear), swap (nearest).
groups: [wgpu::BindGroup; 4],
}
pub(super) struct EntityOutline {
silhouette: wgpu::RenderPipeline,
/// Sobel, blur across, blur up (into `FORMAT`) and the blended blit (onto the frame).
passes: [wgpu::RenderPipeline; 4],
layout: wgpu::BindGroupLayout,
nearest: wgpu::Sampler,
bilinear: wgpu::Sampler,
vertices: wgpu::Buffer,
capacity: u64,
quads: u32,
targets: Option<Targets>,
}
impl EntityOutline {
/// `entity` draws the silhouettes with the entity shader's vertex stage and
/// `fs_outline`; `format` is the frame's.
pub(super) fn new(
device: &wgpu::Device,
entity: (
&wgpu::ShaderModule,
&wgpu::PipelineLayout,
&[Option<wgpu::VertexBufferLayout>],
),
format: wgpu::TextureFormat,
) -> Self {
let (entity_shader, entity_layout, entity_vertex) = entity;
// RenderPipelines.OUTLINE_NO_CULL: no depth test, the colour replaced.
let silhouette = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Entity outline silhouettes"),
layout: Some(entity_layout),
vertex: wgpu::VertexState {
module: entity_shader,
entry_point: Some("vs_entity"),
compilation_options: Default::default(),
buffers: entity_vertex,
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: entity_shader,
entry_point: Some("fs_outline"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity outline"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_outline.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Entity outline bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Entity outline"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pass = |entry: &str, target: wgpu::ColorTargetState| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(entry),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_quad"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[Some(target)],
}),
multiview_mask: None,
cache: None,
})
};
// BlendFunction.ENTITY_OUTLINE_BLIT over the colour only (write mask 7).
let blit = wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::COLOR,
};
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
Self {
silhouette,
passes: [
pass("fs_sobel", FORMAT.into()),
pass("fs_blur_x", FORMAT.into()),
pass("fs_blur_y", FORMAT.into()),
pass("fs_blit", blit),
],
layout,
nearest: sampler("Entity outline nearest", wgpu::FilterMode::Nearest),
bilinear: sampler("Entity outline bilinear", wgpu::FilterMode::Linear),
vertices: Self::vertex_buffer(device, 256),
capacity: 256,
quads: 0,
targets: None,
}
}
fn vertex_buffer(device: &wgpu::Device, capacity: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity outline vertices"),
size: capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
silhouettes: &[Vertex],
size: [u32; 2],
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
return;
}
let vertices = silhouettes.len() as u64;
if vertices > self.capacity {
self.capacity = vertices.next_power_of_two();
self.vertices = Self::vertex_buffer(device, self.capacity);
}
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
let texture = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let outline = texture("Entity outline");
let swap = texture("Entity outline swap");
let group = |view: &wgpu::TextureView, sampler: &wgpu::Sampler| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity outline pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
})
};
let groups = [
group(&outline, &self.nearest),
group(&swap, &self.bilinear),
group(&outline, &self.bilinear),
group(&swap, &self.nearest),
];
self.targets = Some(Targets {
size,
outline,
swap,
groups,
});
}
/// `LevelRenderer.executeOutline`, the post chain and `blitEntityOutline` onto `frame`.
pub(super) fn draw(
&self,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
}
// post_effect/entity_outline.json; its last pass only copies swap back to the
// outline target, so the blit reads swap.
let steps = [
("Entity outline sobel", &t.swap, clear),
("Entity outline blur across", &t.outline, clear),
("Entity outline blur up", &t.swap, clear),
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
}
}
}
fn begin<'e>(
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &attachments,
..Default::default()
})
}
@@ -0,0 +1,60 @@
// The entity_outline post chain (post_effect/entity_outline.json) over the silhouettes of
// the glowing entities, then RenderTarget.blitAndBlendToTexture onto the frame.
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct PostOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
// screenquad.vsh, with v running down the rows as wgpu's textures do.
@vertex fn vs_quad(@builtin(vertex_index) i: u32) -> PostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: PostOut;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
fn tap(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(source, source_sampler, uv, 0.0);
}
// entity_sobel.fsh: alpha marks where coverage changes, the colour is the mean of the
// covered texels around.
@fragment fn fs_sobel(in: PostOut) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(source));
let center = tap(in.uv);
let left = tap(in.uv - vec2(texel.x, 0.0));
let right = tap(in.uv + vec2(texel.x, 0.0));
let up = tap(in.uv - vec2(0.0, texel.y));
let down = tap(in.uv + vec2(0.0, texel.y));
let total = clamp(abs(center.a - left.a) + abs(center.a - right.a)
+ abs(center.a - up.a) + abs(center.a - down.a), 0.0, 1.0);
let color = center.rgb * center.a + left.rgb * left.a + right.rgb * right.a
+ up.rgb * up.a + down.rgb * down.a;
return vec4(color * 0.2, total);
}
// entity_outline_box_blur.fsh at Radius 2: bilinear taps at -1.5 and 0.5 texels and half
// a tap at 2; the colour averaged, the alpha summed.
fn blur(uv: vec2<f32>, step: vec2<f32>) -> vec4<f32> {
let blurred = tap(uv + step * -1.5) + tap(uv + step * 0.5) + tap(uv + step * 2.0) / 2.0;
return vec4(blurred.rgb / 2.5, blurred.a);
}
// BlurDir (1, 0).
@fragment fn fs_blur_x(in: PostOut) -> @location(0) vec4<f32> {
return blur(in.uv, vec2(1.0 / f32(textureDimensions(source).x), 0.0));
}
// BlurDir (0, 1): up the screen in GL's bottom-up textures, towards row 0 here.
@fragment fn fs_blur_y(in: PostOut) -> @location(0) vec4<f32> {
return blur(in.uv, vec2(0.0, -1.0 / f32(textureDimensions(source).y)));
}
// blit_screen.fsh, blended by RenderPipelines.ENTITY_OUTLINE_BLIT.
@fragment fn fs_blit(in: PostOut) -> @location(0) vec4<f32> {
return tap(in.uv);
}
+13
View File
@@ -204,6 +204,19 @@ impl Gizmos {
position[1] + f64::from(size.1),
position[2] + half,
];
self.entity_box(min, max, position, eye_height, living, view);
}
/// The same for an entity whose box is not centred on its position
/// (a painting or an item frame): `min` and `max` are the box.
pub fn entity_box(
&mut self,
min: [f64; 3],
max: [f64; 3],
position: [f64; 3],
eye_height: f32,
living: bool,
view: (f32, f32),
) {
self.cuboid(min, max, WHITE, STROKE_WIDTH);
if living {
let eye = min[1] + f64::from(eye_height);
+17 -2
View File
@@ -29,12 +29,22 @@ pub(crate) type State = HurtState<DamageTypeId>;
pub struct HealthRules {
pub difficulty: Difficulty,
pub natural_regeneration: bool,
/// The `drowning_damage`, `fall_damage`, `fire_damage` and
/// `freeze_damage` rules `Player.isInvulnerableTo` reads.
pub drowning_damage: bool,
pub fall_damage: bool,
pub fire_damage: bool,
pub freeze_damage: bool,
}
impl Default for HealthRules {
fn default() -> Self {
Self {
difficulty: Difficulty::Normal,
natural_regeneration: true,
drowning_damage: true,
fall_damage: true,
fire_damage: true,
freeze_damage: true,
}
}
}
@@ -633,8 +643,13 @@ impl ImmunityWorld for Environment<'_> {
fn enchantment_immunity(&mut self) -> io::Result<bool> {
Ok(false)
}
fn damage_rule(&mut self, _: DamageRule) -> io::Result<bool> {
Ok(true)
fn damage_rule(&mut self, rule: DamageRule) -> io::Result<bool> {
Ok(match rule {
DamageRule::Drowning => self.rules.drowning_damage,
DamageRule::Fall => self.rules.fall_damage,
DamageRule::Fire => self.rules.fire_damage,
DamageRule::Freeze => self.rules.freeze_damage,
})
}
fn changing_dimension(&mut self) -> io::Result<bool> {
Ok(false)
+2
View File
@@ -912,6 +912,8 @@ impl HudBatch {
// StringDecomposer.iterateFormatted inside Font.drawInternal:
// `§` codes inside a span restyle what follows and are not drawn.
let text = text.resolved();
// Language.getVisualOrder: shaped and reordered for right-to-left text.
let text = crate::bidi::visual(&text);
let font = &atlas.font;
let alpha = (color >> 24) as u8;
let mut glyphs = Vec::new();
+66 -2
View File
@@ -46,6 +46,9 @@ pub struct ItemQuad {
pub foil: Foil,
/// `MaterialInfo.lightEmission` (block light floor).
pub light_emission: u8,
/// The quad's sprite has translucent pixels (`MaterialInfo.itemRenderType`
/// is a translucent sheet): drawn with the translucent entities.
pub translucent: bool,
}
/// Which glint render type a quad uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -159,7 +162,7 @@ impl ItemRenderState {
let m = pose * layer.matrix;
match &layer.geometry {
LayerGeometry::Quads(baked) => {
submit_quads(baked, layer, self.display, m, sprites, sink);
submit_quads(renderer, baked, layer, self.display, m, sprites, sink);
}
LayerGeometry::Special { model, argument } => special::submit(
renderer,
@@ -203,6 +206,7 @@ pub fn argb(color: i32) -> u32 {
/// `ItemFeatureRenderer.prepareMainSubmit` for one cuboid layer.
fn submit_quads(
renderer: &ItemRenderer,
baked: &BakedItemModel,
layer: &LayerRender,
display: DisplayContext,
@@ -216,6 +220,8 @@ fn submit_quads(
FoilType::Special => Foil::Special,
};
let _ = display;
// Quads of one layer mostly share a sprite: look its translucency up once.
let mut last: Option<(&str, bool)> = None;
for q in &baked.quads {
let Some(r) = sprites
.get(sprite_name(&q.material.sprite))
@@ -239,6 +245,16 @@ fn submit_quads(
let pos = q.positions.map(|p| m.transform_point3(Vec3::from(p)));
let uv =
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v]);
let translucent = match last {
Some((sprite, translucent)) if sprite == q.material.sprite => translucent,
_ => {
let translucent = renderer
.sprites
.translucent(sprite_name(&q.material.sprite));
last = Some((&q.material.sprite, translucent));
translucent
}
};
sink.quad(ItemQuad {
pos,
uv,
@@ -246,6 +262,7 @@ fn submit_quads(
color: argb(color),
foil,
light_emission: q.light_emission,
translucent: translucent || q.material.force_translucent,
});
}
}
@@ -260,6 +277,9 @@ struct FileSprites {
root: PathBuf,
permutations: Vec<PalettedPermutations>,
cache: Mutex<HashMap<String, Option<SpriteMask>>>,
/// Sprites with a pixel that is neither fully transparent nor opaque
/// (`Transparency.hasTranslucent`), filled in as sprites load.
translucent: Mutex<HashMap<String, bool>>,
}
impl SpriteLookup for FileSprites {
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
@@ -283,6 +303,13 @@ fn argb_pixels(rgba: &[u8]) -> Vec<u32> {
.collect()
}
impl FileSprites {
fn translucent(&self, sprite: &str) -> bool {
let known = |this: &Self| this.translucent.lock().ok()?.get(sprite).copied();
known(self).unwrap_or_else(|| {
self.mask(sprite);
known(self).unwrap_or(false)
})
}
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
crate::atlas_sources::texture_path(&self.root, sprite, directory)
}
@@ -303,6 +330,10 @@ impl FileSprites {
(w, h, pixels)
}
};
let translucent = argb.iter().any(|p| matches!(p >> 24, 1..=254));
if let Ok(mut cache) = self.translucent.lock() {
cache.insert(sprite.to_owned(), translucent);
}
let alpha: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
let animation = fs::read_to_string(png.with_extension("png.mcmeta"))
.ok()
@@ -452,6 +483,7 @@ impl ItemRenderer {
root: root.to_path_buf(),
permutations,
cache: Mutex::default(),
translucent: Mutex::default(),
},
baked: Mutex::default(),
extents: Mutex::default(),
@@ -694,7 +726,12 @@ impl ItemSink for WorldSink<'_> {
} else {
self.light
};
let overlay = if q.foil == Foil::None { 0 } else { 7 };
let overlay = match (q.foil, q.translucent) {
(Foil::None, true) => crate::gfx::TRANSLUCENT_ITEM_OVERLAY,
(Foil::None, false) => 0,
(_, true) => crate::gfx::TRANSLUCENT_GLINT_ITEM_OVERLAY,
(_, false) => 7,
};
for (p, uv) in q.pos.iter().zip(q.uv) {
self.out.push(Vertex {
normal: [0; 4],
@@ -759,6 +796,33 @@ pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64
mod tests {
use super::*;
/// A bad item model surfaces only once items.json has loaded, so a broken
/// items.json still reports itself first.
#[test]
fn item_model_errors_wait_for_the_item_definitions() -> crate::Result<()> {
let root =
std::env::temp_dir().join(format!("horizon-item-model-errors-{}", std::process::id()));
let model = root.join("assets/horizon/models/INVALID.json");
fs::create_dir_all(model.parent().unwrap())?;
fs::write(model, "{}")?;
let library = crate::model_library::ParsedModels::load(&root).into_item_library();
assert!(library.is_err());
fs::write(root.join("items.json"), "{")?;
let load = || {
ItemRenderer::load_with_library(&root, library.clone())
.err()
.unwrap()
.to_string()
};
let broken_items = load();
fs::write(root.join("items.json"), "{}")?;
let bad_model = load();
let _ = fs::remove_dir_all(&root);
assert!(!broken_items.starts_with("item models:"));
assert!(bad_model.starts_with("item models:"));
Ok(())
}
#[test]
fn rendered_amounts_follow_the_count_bands() {
let bands: Vec<u32> = [0, 1, 2, 16, 17, 32, 33, 48, 49, 64]
@@ -358,6 +358,7 @@ mod tests {
color,
foil: Foil::None,
light_emission: 0,
translucent: false,
}
}
@@ -119,6 +119,7 @@ fn draw(
color,
foil,
light_emission: 0,
translucent: false,
});
}
for (child_name, child) in &part.children {
@@ -498,7 +499,7 @@ pub(super) fn submit(
let skin = (!argument.is_null())
.then(|| host.player_skin(argument))
.flatten()
.unwrap_or_else(|| "entity/player/wide/steve".to_owned());
.unwrap_or_else(|| "entity/player/slim/steve".to_owned());
part("player", &none, &skin, white, Foil::None);
}
SpecialKind::Trident => {
+11
View File
@@ -229,6 +229,17 @@ fn profile_tag(p: &Profile) -> Tag {
if let Some(name) = &p.name {
entries.push(("name", Tag::String(name.as_str().into())));
}
if let Some(id) = p.id {
entries.push((
"id",
Tag::IntArray(vec![
(id >> 96) as i32,
(id >> 64) as i32,
(id >> 32) as i32,
id as i32,
]),
));
}
entries.push(("properties", Tag::List(properties)));
Tag::compound(entries)
}
+40 -2
View File
@@ -2,7 +2,12 @@
//! player state (inventory, health, game mode) that the host's gameplay code
//! operates on when it acts for them.
use super::{Joined, PlayerSender, entity::Tracker};
use crate::{items::ItemStack, motion::PlayerMotion};
use crate::{
items::ItemStack,
motion::PlayerMotion,
pose::{Dimensions, Pose},
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
item::Stack,
packets::{configuration::ClientInformation, play},
@@ -407,8 +412,41 @@ impl Guest {
}
f
}
/// `Player.getDesiredPose`: sleeping, gliding, then the body's own pose
/// (swimming, crawling, crouching) once its server tick has run.
pub fn pose(&self) -> Pose {
if self.sleeping {
Pose::Sleeping
} else if self.fall_flying {
Pose::FallFlying
} else if let Some(m) = &self.motion {
m.pose
} else if self.sneaking && !self.flying {
Pose::Crouching
} else {
Pose::Standing
}
}
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
pub fn dimensions(&self) -> Dimensions {
let pose = self.pose();
match &self.motion {
Some(m) if m.pose == pose => m.dimensions,
_ => pose.dimensions(1.0),
}
}
/// `Entity.getBoundingBox`.
pub fn bounds(&self) -> Aabb {
let [x, y, z] = self.position;
let d = self.dimensions();
let half = f64::from(d.width) / 2.0;
Aabb::new(
Vec3::new(x - half, y, z - half),
Vec3::new(x + half, y + f64::from(d.height), z + half),
)
}
pub fn eye_height(&self) -> f64 {
if self.sneaking { 1.27 } else { 1.62 }
f64::from(self.dimensions().eye_height)
}
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
/// its block interaction range is 4.5, 5 in creative.
+2 -12
View File
@@ -2344,18 +2344,8 @@ impl Hub {
}
// getBoundingBox: the box of the body's pose (swimming and crawling
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
let (half, height) = if g.fall_flying {
(0.3, 0.6)
} else if let Some(m) = &g.motion {
(
f64::from(m.dimensions.width) / 2.0,
f64::from(m.dimensions.height),
)
} else if g.sneaking {
(0.3, 1.5)
} else {
(0.3, 1.8)
};
let d = g.dimensions();
let (half, height) = (f64::from(d.width) / 2.0, f64::from(d.height));
let at = |p: [f64; 3]| {
Aabb::new(
Vec3::new(p[0] - half, p[1], p[2] - half),
+2
View File
@@ -15,6 +15,7 @@ pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod bidi;
pub mod biome;
pub mod block_entities;
pub mod block_entity_anim;
@@ -74,6 +75,7 @@ pub mod fog;
pub mod font;
pub mod friends_screen;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gamepad;
pub mod gfx;
pub mod gizmos;
+41 -2
View File
@@ -651,6 +651,9 @@ pub struct Attrs {
pub struct Extra {
/// Players: crouching and riding (HumanoidModel's poses).
pub crouching: bool,
/// A player's or mannequin's synced pose (`Entity.DATA_POSE`), which
/// sets its box (`Avatar.POSES`).
pub avatar_pose: crate::pose::Pose,
/// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise.
pub sprinting: bool,
pub passenger: bool,
@@ -845,6 +848,9 @@ pub struct Mob {
pub previous_swell: i32,
pub ambient_timer: i32,
pub fall_distance: f64,
/// `Entity.pistonDeltas` and `pistonDeltasGameTime`: how far a moving
/// block has pushed this mob this tick (0.51 at most on any axis).
pub piston: horizon_world::piston_movement::PistonMovement,
pub persistent: bool,
/// `CustomName` (plain text) and `CustomNameVisible`.
pub custom_name: Option<String>,
@@ -1326,6 +1332,8 @@ pub struct Seen {
pub active_raid: bool,
/// A copper golem's `openedChestPos` (`ContainerUser.hasContainerOpen`).
pub opened_chest: Option<[i32; 3]>,
/// `isPassenger`.
pub passenger: bool,
/// A villager's reputation of the player (`getPlayerReputation`).
pub player_rep: i32,
/// `isPushable` and pushed by others (`Entity.push(Entity)` moves it).
@@ -1417,6 +1425,7 @@ impl Mob {
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
active_raid: raider::has_active_raid(m),
opened_chest: m.extra.opened_chest,
passenger: m.extra.mount.is_some(),
player_rep: match (villager, player) {
(true, Some(uuid)) => villager::ext(m).map_or(0, |e| e.gossips.reputation(uuid)),
_ => 0,
@@ -1596,6 +1605,8 @@ pub struct PlayerView {
/// A LAN guest: its own client moves it, and touching mobs push it there
/// (`EntitySelector.pushableBy` on the client).
pub remote: bool,
/// `isPassenger`.
pub passenger: bool,
}
impl PlayerView {
/// `LivingEntity.hasEffect`, by id without namespace.
@@ -1608,6 +1619,8 @@ pub struct MobWorld<'a> {
pub world: &'a World,
pub registry: &'a horizon_world::registry::BlockRegistry,
pub facts: &'a BlockFacts,
/// Fluid states and flow facts, for the currents that push mobs.
pub fluid_tables: &'a crate::motion::FluidResources,
pub movement: &'a horizon_world::movement_properties::BoundMovementProperties,
pub collision: &'a BlockCollisionRegistry<'a>,
pub data: &'a MobData,
@@ -1696,6 +1709,7 @@ impl Mob {
// Mob.ambientSoundTime starts at 0.
ambient_timer: 0,
fall_distance: 0.0,
piston: Default::default(),
persistent: false,
random,
ai: Box::default(),
@@ -1926,7 +1940,7 @@ impl Mob {
let dead = !self.alive();
let sound = ai::voice_sounds(self)[if dead { 2 } else { 1 }];
if !dead {
self.ambient_timer = -self.kind.ambient_interval;
self.ambient_timer = -self.ambient_interval();
}
if !sound.is_empty() {
let pitch = self.voice_pitch();
@@ -2007,6 +2021,11 @@ impl Mob {
pub fn has_effect(&self, effect: &str) -> bool {
self.extra.effects.iter().any(|e| e.0 == effect)
}
/// `LivingEntity.isCurrentlyGlowing`: the Glowing effect or the `Glowing` tag (on a
/// server's client, `glowing` holds shared flag 6 instead).
pub fn is_currently_glowing(&self) -> bool {
self.glowing || self.has_effect("minecraft:glowing")
}
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
// MobEffectInstance.update: a stronger effect replaces the old one
@@ -2036,10 +2055,30 @@ impl Mob {
let at = [self.position.x, self.eye_y(), self.position.z];
events.push(MobEvent::Sound(event, at, 1.0, 1.0));
}
/// `Mob.getAmbientSoundInterval`; `HappyGhast` waits six times as long
/// while it carries a passenger.
pub fn ambient_interval(&self) -> i32 {
let ridden = self.rider.is_some() || self.extra.rider.is_some();
if self.species() == Species::HappyGhast && ridden {
self.kind.ambient_interval * 6
} else {
self.kind.ambient_interval
}
}
/// LivingEntity.getVoicePitch.
pub fn voice_pitch(&mut self) -> f32 {
// HappyGhast.getVoicePitch: always 1.0, babies and all.
if self.species() == Species::HappyGhast {
return 1.0;
}
// ZombieVillager.getVoicePitch: babies are 2.0, not 1.5.
let baby = if self.species() == Species::ZombieVillager {
2.0
} else {
1.5
};
let pitch = (self.random.next_float() - self.random.next_float()) * 0.2
+ if self.is_baby() { 1.5 } else { 1.0 };
+ if self.is_baby() { baby } else { 1.0 };
// Bat.getVoicePitch: a little lower.
if self.species() == Species::Bat {
pitch * 0.95
+57 -18
View File
@@ -185,6 +185,18 @@ pub fn register(m: &mut Mob) {
if m.species() == Species::Rabbit {
super::rabbit::register(m);
}
if m.species() == Species::Frog {
// `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true).
// The speed attribute is 1.0, so the controller's scaling is
// what keeps a frog at a walking pace on land.
m.ai.mov.kind = super::control::MoveKind::SmoothSwimming {
max_turn_x: 85.0,
max_turn_y: 10.0,
in_water: 0.02,
outside: 0.1,
gravity: true,
};
}
if m.species() == Species::Turtle {
super::turtle::register(m);
}
@@ -321,6 +333,20 @@ fn generic(m: &mut Mob) {
m.ai.targets.add(1, HurtByTarget::new());
m.ai.targets.add(2, NearestTarget::player(true));
} else {
if m.kind.species == Species::Goat {
// GoatAi's idle and core behaviours (the ram and long jump
// activities are not here): panic 2.0, AnimalMakeLove, tempting
// with goat_food and baby follow at 1.25, then stroll.
g.add(0, Float);
g.add(1, Panic::new(2.0));
g.add(2, Breed::new(1.0));
g.add(3, Tempt::new(1.25, m.kind.food.clone(), false));
g.add(4, FollowParent::new(1.25));
g.add(5, Stroll::avoiding_water(1.0));
g.add(6, LookAtPlayer::new(6.0));
g.add(7, RandomLookAround::default());
return;
}
g.add(0, Float);
let rabbit = m.kind.species == Species::Rabbit;
g.add(1, Panic::new(if rabbit { 2.2 } else { 1.25 }));
@@ -462,6 +488,16 @@ pub fn tick_swing(m: &mut Mob) {
}
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
}
/// `Entity.isInRain`: rain at the block position, or at the top of the box.
pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool {
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let top = (m.position.y + f64::from(m.height())).floor() as i32;
w.raining_at(x, y, z) || w.raining_at(x, top, z)
}
/// `LivingEntity.baseTick` refills a breather's air by 4, then
/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills
/// it, anywhere else it runs down from the pre-tick value, and at -20 the
@@ -472,15 +508,7 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
if m.no_ai {
return;
}
// Entity.isInRain: rain at the block, or at the top of the box.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let top = (m.position.y + f64::from(m.height())).floor() as i32;
let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z);
if m.fluids.in_water || in_rain {
if m.fluids.in_water || in_rain(m, w) {
m.air = super::axolotl::MAX_AIR;
} else {
m.air = before - 1;
@@ -508,8 +536,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
);
m.damage(fire, Damage::of(DamageKind::InFire));
}
// Fire: water extinguishes, lava ignites, burning hurts every second.
if m.fluids.in_water && m.fire_ticks > 0 {
// Fire: water or rain (Entity.isInRain) extinguishes, lava ignites,
// burning hurts every second.
if m.fire_ticks > 0 && (m.fluids.in_water || in_rain(m, w)) {
m.fire_ticks = 0;
events.push(MobEvent::Sound(
"entity.generic.extinguish_fire",
@@ -580,9 +609,7 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.air = (m.air + 4).min(300);
}
if m.kind.flags.water_sensitive
&& (m.fluids.in_water || w.raining && w.can_see_sky(ex, ey, ez))
{
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
m.damage(1.0, Damage::of(DamageKind::Drown));
}
freeze_tick(m, w);
@@ -608,7 +635,7 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
let t = m.ambient_timer;
m.ambient_timer += 1;
if roll < t {
m.ambient_timer = -m.kind.ambient_interval;
m.ambient_timer = -m.ambient_interval();
play_ambient(m, w, events);
}
}
@@ -970,7 +997,7 @@ fn sun_burn_tick(m: &mut Mob, w: &MobWorld) -> bool {
m.position.z.floor() as i32,
);
let br = w.brightness(x, y, z);
let wet = m.fluids.in_water || w.raining && w.can_see_sky(x, y, z);
let wet = m.fluids.in_water || in_rain(m, w);
br > 0.5 && m.random.next_float() * 30.0 < (br - 0.4) * 2.0 && !wet && w.can_see_sky(x, y, z)
}
/// Mob.serverAiStep
@@ -1757,8 +1784,20 @@ pub fn step_sound(
S::Husk => quiet("entity.husk.step"),
S::Drowned => quiet("entity.drowned.step"),
S::ZombieVillager => quiet("entity.zombie_villager.step"),
S::CamelHusk => Some(("entity.camel_husk.step", 0.4, 1.0)),
S::Camel => Some(("entity.camel.step", 1.0, 1.0)),
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
// powder have their own step.
S::CamelHusk | S::Camel => {
let sand = w.has_tag(
"minecraft:camel_sand_step_sound_blocks",
&w.facts.get(state).name,
);
Some(match (m.species(), sand) {
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
})
}
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
// CopperGolem.playStepSound: its oxidation level's step.
S::CopperGolem => Some((super::copper_golem::sound(m, "step"), 1.0, 1.0)),
+1 -1
View File
@@ -259,7 +259,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
S::Dolphin => {
// Dolphin.tick: moisture out of water, hop on land.
if m.fluids.in_water || w.raining {
if m.fluids.in_water || super::ai::in_rain(m, w) {
m.extra.counter = 2400;
} else {
m.extra.counter -= 1;
+16 -9
View File
@@ -323,12 +323,19 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) {
match who {
Who::Player => {
cx.events.push(MobEvent::AttackPlayer(amount, from));
let seconds = if cx.w.difficulty.hard { 18 } else { 10 };
cx.events.push(MobEvent::EffectPlayer {
effect: "minecraft:poison",
ticks: seconds * 20,
amplifier: 0,
});
// Poison only on normal (10 s) and hard (18 s).
let seconds = match cx.w.difficulty.id {
2 => 10,
3 => 18,
_ => 0,
};
if seconds > 0 {
cx.events.push(MobEvent::EffectPlayer {
effect: "minecraft:poison",
ticks: seconds * 20,
amplifier: 0,
});
}
}
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
target: id,
@@ -421,10 +428,10 @@ pub fn drop_off_nectar(m: &mut Mob) {
set_has_nectar(m, false);
st(m).crops_grown = 0;
}
/// `Bee.setStayOutOfHiveCountdown`, which in 26.3 only resets the time
/// without nectar.
/// `Bee.setStayOutOfHiveCountdown`: the bee will not enter a hive for 400
/// ticks (the hive passes that when a sedated hive lets its bees out).
pub fn set_stay_out_of_hive_countdown(m: &mut Mob) {
st(m).ticks_without_nectar = 0;
st(m).stay_out_of_hive = 400;
}
fn drop_hive(m: &mut Mob) {
let b = st(m);
+2 -1
View File
@@ -980,9 +980,10 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
Species::Parrot => {
let flap = m.anim.flap_o + (m.anim.flap - m.anim.flap_o) * s.partial;
let speed = m.anim.flap_speed_o + (m.anim.flap_speed - m.anim.flap_speed_o) * s.partial;
// `Parrot.isFlying` is `!onGround`, in water too.
let pose = if m.extra.sitting {
ParrotPose::Sitting
} else if !m.on_ground && !m.fluids.in_water {
} else if !m.on_ground {
ParrotPose::Flying
} else {
ParrotPose::Standing
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more