mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
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:
commit
e5ca012ed8
144 files changed
+7145
-867
No files matched your search
Generated
+7
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
File renamed without changes.
@@ -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" => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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}"), &[])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
+38
-30
@@ -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]
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user