Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 266c4576a5 Launcher: java_launch's Windows paths, one part at a time
The log config and old versions' asset folder were joined from
"a/b" strings; on Windows their tests compared those mixed
separators with paths joined part by part. Join them part by part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:21:50 +01:00
432 changed files with 1146 additions and 23845 deletions

No files matched your search

-76
View File
@@ -1,76 +0,0 @@
---
name: vanilla-parity
description: Fix a Horizon bug or gap so it behaves 1:1 like vanilla 26.3, starting from the port ledger (tools/port_ledger.py) instead of grepping. Use for any issue, Discord report or PR that makes Horizon match vanilla: "X doesn't work like in Java", a missing sound, particle, animation, mob behaviour, block rule, screen or packet.
---
# Vanilla parity with the port ledger
The port ledger pairs every vanilla method with the Rust fn that ports it (or says it is missing, or needs
no port) and lists where a port isn't wired like vanilla. Ask it first; grep only for what it can't answer.
Full reference: `docs/port-ledger.md`.
## 0. Have a fresh ledger
```sh
python3 tools/port_ledger.py build # ~20 s; again after pulling main
```
It needs the vanilla jar (the launcher's `versions/26.3/26.3.jar`, or `--jar`) and/or the lab's decompiled
sources (`python3 tools/lab.py decompile`, or `--sources DIR`). If `build` says it has no vanilla code, run
the lab first; don't guess vanilla from memory.
## 1. From the report to the code
```sh
python3 tools/port_ledger.py issue 3236 # a GitHub issue number
python3 tools/port_ledger.py issue "mooshroom stays red after lightning" # or the report's text
```
It ranks the vanilla classes the report names and lists, per class, its Rust home files, where each method is
ported, and the known gaps (`missing` rows from the verdict map, seam/client/drift gaps). Often the cause is
already written there ("Type is not saved"). Then drill in:
```sh
python3 tools/port_ledger.py where MushroomCow.readAdditionalSaveData # ports, callers, callees, gaps
python3 tools/port_ledger.py class MushroomCow # every method: where, or missing
python3 tools/port_ledger.py what crates/horizon-client/src/mobs/farm.rs:120 # which vanilla method a fn ports
```
A ledger answer is a lead, not a verdict: about 1 in 12 map rows is wrong. Read the vanilla method (path and
line are in the output) and the Rust fn before changing anything.
## 2. Port it the vanilla way
- Translate the vanilla method, don't reinvent it: same order of operations, same constants, the same random
draws in the same order (`horizon_core::random`), Java `Math` through `horizon_core::jmath`.
- Open the fn's doc with the vanilla method it ports: `` /// `MushroomCow.readAdditionalSaveData`: ... ``.
That one line is what makes the next agent find it.
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
usually the same bug on the other path.
## 3. Before the PR
```sh
python3 tools/port_ledger.py check --base origin/main
```
It lists the ports your branch touches with their open gaps, citations to add, stale citations, and (against
the base) the gaps the branch closes and opens. Close what belongs to your fix, cite what you ported, leave
no new stale citation. If you ported a method the verdict map called `missing`, change its row in
`tools/port_map/*.tsv` to `port` with the fn (`file.rs::Owner::fn`) and run
`python3 tools/port_ledger.py map-check tools/port_map/<file>.tsv`.
Then the usual: the harness for the area (`docs/architecture.md`), `cargo check`/tests per `AGENTS.md`, and
a small PR that says which vanilla method it now matches.
## Finding work
```sh
python3 tools/port_ledger.py gaps --kind seam # ported on two paths, wired on one: the best leads
python3 tools/port_ledger.py gaps --kind client # client-only effects the server path never reaches
python3 tools/port_ledger.py gaps --kind drift # a port missing a sound, particle, item or constant
rg -n "player:" tools/port_map/*.tsv # missing behaviour a player would see
```
+2 -7
View File
@@ -35,12 +35,11 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -100,9 +99,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## Vanilla reference
- Start parity work from the port ledger, not grep: `python3 tools/port_ledger.py issue N` (or `where
Class.method`, `what file:line`), and `check --base origin/main` before the PR. How:
[.claude/skills/vanilla-parity/SKILL.md](.claude/skills/vanilla-parity/SKILL.md), [docs/port-ledger.md](docs/port-ledger.md).
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
@@ -114,8 +110,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
Generated
+2 -43
View File
@@ -1378,6 +1378,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"cpal",
"flate2",
"gilrs",
"glam",
@@ -1385,7 +1386,6 @@ dependencies = [
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
@@ -1412,6 +1412,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
@@ -1440,48 +1441,6 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"cpal",
"flate2",
"glam",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-hdr",
"horizon-items",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
-1
View File
@@ -9,7 +9,6 @@ members = [
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
+1 -30
View File
@@ -40,10 +40,6 @@ static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
fn read(&self, path: &str) -> Option<Vec<u8>>;
/// Whether `path` is there, without reading it where the source can tell.
fn exists(&self, path: &str) -> bool {
self.read(path).is_some()
}
/// The namespaces under `assets/` (resource packs may add their own).
fn namespaces(&self) -> Vec<String> {
vec!["minecraft".into()]
@@ -72,9 +68,6 @@ impl ResourceSource for std::path::PathBuf {
fn read(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(self.join(path)).ok()
}
fn exists(&self, path: &str) -> bool {
self.join(path).is_file()
}
fn namespaces(&self) -> Vec<String> {
std::fs::read_dir(self.join("assets"))
.into_iter()
@@ -197,7 +190,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
for (code, info) in value["language"].as_object().into_iter().flatten() {
// Only languages whose file exists somewhere are selectable.
let file = format!("assets/minecraft/lang/{code}.json");
if code != DEFAULT && !sources.iter().any(|s| s.exists(&file)) {
if code != DEFAULT && !sources.iter().any(|s| s.read(&file).is_some()) {
continue;
}
found.insert(
@@ -291,26 +284,4 @@ mod tests {
);
assert_eq!(langs[1].label(), "Français (France)");
}
#[test]
fn languages_are_listed_without_reading_their_files() {
struct NoLangReads;
impl ResourceSource for NoLangReads {
fn read(&self, path: &str) -> Option<Vec<u8>> {
assert!(!path.contains("/lang/"), "read {path}");
(path == "assets/pack.mcmeta").then(|| {
br#"{"language":{"fr_fr":{"name":"French"},"de_de":{"name":"German"}}}"#
.to_vec()
})
}
fn exists(&self, path: &str) -> bool {
path == "assets/minecraft/lang/fr_fr.json"
}
}
let langs = available(&[&NoLangReads]);
assert_eq!(
langs.iter().map(|l| l.code.as_str()).collect::<Vec<_>>(),
["en_us", "fr_fr"]
);
}
}
-67
View File
@@ -1,67 +0,0 @@
[package]
name = "horizon-client-render"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
bytemuck = { version = "1.24", features = ["derive"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha2 = "0.10.9"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
# Sound output: horizon-client's `audio` feature.
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[lints]
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
-115
View File
@@ -1,115 +0,0 @@
//! The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer,
//! the HUD, menus and settings. Gameplay (horizon-client) builds on it; nothing here reaches gameplay.
#![forbid(unsafe_code)]
pub mod assets;
pub mod atlas_sources;
pub mod audio;
pub mod auth;
pub mod bidi;
pub mod biome;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_render;
pub mod border_render;
pub mod breaking;
pub mod brewing;
pub mod camera;
pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod clouds;
pub mod collisions;
pub mod command_block;
pub mod containers;
pub mod crash;
pub mod datapack;
pub mod dynamic_light;
pub mod effects;
pub mod environment;
pub mod extras;
pub mod fast_hash;
pub mod fog;
pub mod font;
pub mod gfx;
pub mod graphics;
pub mod graphics_style;
pub mod graphics_ui;
pub mod hud;
pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod level_loading;
pub mod light;
pub mod lod;
pub mod logging;
pub mod menu_blur;
pub mod mesher;
pub mod minimap;
pub mod mipmap;
pub mod mod_list;
pub mod model_library;
pub mod models;
pub mod options_file;
pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_screen;
pub mod packs;
pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod providers;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
pub mod settings;
pub mod shader_pack_list;
pub mod signs;
pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod spans;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
pub mod tab_list;
pub mod telemetry;
pub mod testgen;
pub mod title_edition;
pub mod trims;
pub mod ui;
pub mod vault;
pub mod weather;
pub mod widgets;
pub mod world;
pub mod worldborder;
pub mod zip;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
@@ -1,345 +0,0 @@
//! Decoded block sprites kept on disk between launches (M-11): reading and
//! decoding ~1300 small PNGs is most of `Sprites::load`. One file per resource
//! root, valid only while the `textures` trees still have the same paths,
//! sizes and modification times and the format version is the same; anything
//! else (missing, corrupt, stale, unwritable) means the normal decoding path.
use crate::mipmap::Strategy;
use std::{
collections::HashMap,
fs,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
};
const MAGIC: &[u8; 4] = b"HSPR";
/// Bump when `Decoded` or the decoding in `Sprites::decode` changes.
const VERSION: u32 = 1;
const FILE: &str = "horizon-sprites.cache";
/// What decoding one texture gives, before it takes a layer.
#[derive(Clone)]
pub(crate) struct Decoded {
pub side: u32,
pub frames: Vec<Vec<u8>>,
/// The `.mcmeta` `animation` value, as text.
pub animation: Option<String>,
pub strategy: Strategy,
pub alpha_cutoff_bias: f32,
pub has_transparent: bool,
pub opaque: bool,
pub translucent: bool,
}
pub(crate) struct SpriteCache {
path: PathBuf,
fingerprint: u64,
entries: HashMap<String, Decoded>,
dirty: bool,
}
/// Paths, sizes and modification times of every file under `assets/*/textures`,
/// summed so the directory order does not matter.
fn fingerprint(root: &Path) -> Option<u64> {
fn walk(dir: &Path, base: &Path, sum: &mut u64) -> Option<()> {
for entry in fs::read_dir(dir).ok()? {
let entry = entry.ok()?;
let meta = entry.metadata().ok()?;
if meta.is_dir() {
walk(&entry.path(), base, sum)?;
continue;
}
let mut hasher = DefaultHasher::new();
entry.path().strip_prefix(base).ok()?.hash(&mut hasher);
meta.len().hash(&mut hasher);
meta.modified().ok()?.hash(&mut hasher);
*sum = sum.wrapping_add(hasher.finish());
}
Some(())
}
let mut sum = 0;
for namespace in fs::read_dir(root.join("assets")).ok()? {
let textures = namespace.ok()?.path().join("textures");
if textures.is_dir() {
walk(&textures, root, &mut sum)?;
}
}
Some(sum)
}
impl SpriteCache {
/// The cache of `root`: its stored entries when still valid, else empty.
/// `None` when the textures cannot be fingerprinted (no caching then).
pub fn open(root: &Path) -> Option<Self> {
let fingerprint = fingerprint(root)?;
let path = root.join(FILE);
let entries = fs::read(&path)
.ok()
.and_then(|bytes| parse(&bytes, fingerprint))
.unwrap_or_default();
Some(Self {
path,
fingerprint,
entries,
dirty: false,
})
}
pub fn get(&self, name: &str) -> Option<&Decoded> {
self.entries.get(name)
}
pub fn insert(&mut self, name: &str, decoded: Decoded) {
self.entries.insert(name.to_owned(), decoded);
self.dirty = true;
}
/// Write new entries; a failure only costs the next launch the decoding.
pub fn save(&mut self) {
if !self.dirty {
return;
}
self.dirty = false;
let temporary = self
.path
.with_extension(format!("{}.tmp", std::process::id()));
if fs::write(&temporary, serialize(self.fingerprint, &self.entries)).is_ok()
&& fs::rename(&temporary, &self.path).is_err()
{
let _ = fs::remove_file(&temporary);
}
}
}
fn serialize(fingerprint: u64, entries: &HashMap<String, Decoded>) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.extend_from_slice(&VERSION.to_le_bytes());
out.extend_from_slice(&fingerprint.to_le_bytes());
out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
for (name, d) in entries {
put_bytes(&mut out, name.as_bytes());
out.extend_from_slice(&d.side.to_le_bytes());
out.extend_from_slice(&(d.frames.len() as u32).to_le_bytes());
for frame in &d.frames {
put_bytes(&mut out, frame);
}
put_bytes(&mut out, d.animation.as_deref().unwrap_or("").as_bytes());
out.push(u8::from(d.animation.is_some()));
out.push(d.strategy as u8);
out.extend_from_slice(&d.alpha_cutoff_bias.to_bits().to_le_bytes());
out.push(
u8::from(d.has_transparent) | u8::from(d.opaque) << 1 | u8::from(d.translucent) << 2,
);
}
out
}
fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
out.extend_from_slice(bytes);
}
struct Reader<'a>(&'a [u8]);
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
let (head, tail) = self.0.split_at_checked(n)?;
self.0 = tail;
Some(head)
}
fn u32(&mut self) -> Option<u32> {
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
}
fn bytes(&mut self) -> Option<&'a [u8]> {
let n = self.u32()? as usize;
self.take(n)
}
}
fn parse(bytes: &[u8], fingerprint: u64) -> Option<HashMap<String, Decoded>> {
let mut r = Reader(bytes);
if r.take(4)? != MAGIC || r.u32()? != VERSION {
return None;
}
if u64::from_le_bytes(r.take(8)?.try_into().ok()?) != fingerprint {
return None;
}
let count = r.u32()?;
let mut entries = HashMap::new();
for _ in 0..count {
let name = String::from_utf8(r.bytes()?.to_vec()).ok()?;
let side = r.u32()?;
let frame_count = r.u32()?;
let mut frames = Vec::new();
for _ in 0..frame_count {
frames.push(r.bytes()?.to_vec());
}
let animation = String::from_utf8(r.bytes()?.to_vec()).ok()?;
let has_animation = r.take(1)?[0] != 0;
let strategy = match r.take(1)?[0] {
0 => Strategy::Auto,
1 => Strategy::Mean,
2 => Strategy::Cutout,
3 => Strategy::StrictCutout,
4 => Strategy::DarkCutout,
_ => return None,
};
let alpha_cutoff_bias = f32::from_bits(r.u32()?);
let flags = r.take(1)?[0];
let frame_len = (side as usize).checked_mul(side as usize)?.checked_mul(4)?;
if frames.is_empty() || frames.iter().any(|f| f.len() != frame_len) {
return None;
}
entries.insert(
name,
Decoded {
side,
frames,
animation: has_animation.then_some(animation),
strategy,
alpha_cutoff_bias,
has_transparent: flags & 1 != 0,
opaque: flags & 2 != 0,
translucent: flags & 4 != 0,
},
);
}
r.0.is_empty().then_some(entries)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(blue: u8, animation: Option<&str>) -> Decoded {
Decoded {
side: 2,
frames: vec![vec![blue; 16], vec![blue + 1; 16]],
animation: animation.map(str::to_owned),
strategy: Strategy::Cutout,
alpha_cutoff_bias: 0.25,
has_transparent: true,
opaque: false,
translucent: true,
}
}
fn scratch(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-sprite-cache-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("assets/minecraft/textures/block")).unwrap();
dir
}
#[test]
fn round_trips() {
let mut entries = HashMap::new();
entries.insert("a:b".to_owned(), sample(1, Some("{\"frametime\":2}")));
entries.insert("a:c".to_owned(), sample(9, None));
let back = parse(&serialize(7, &entries), 7).unwrap();
for (name, d) in &entries {
let e = &back[name];
assert_eq!(
(d.side, &d.frames, &d.animation),
(e.side, &e.frames, &e.animation)
);
assert_eq!(d.alpha_cutoff_bias, e.alpha_cutoff_bias);
assert_eq!(
(d.has_transparent, d.opaque, d.translucent, d.strategy as u8),
(e.has_transparent, e.opaque, e.translucent, e.strategy as u8)
);
}
}
#[test]
fn wrong_fingerprint_truncation_and_garbage_are_misses() {
let mut entries = HashMap::new();
entries.insert("a:b".to_owned(), sample(1, None));
let bytes = serialize(7, &entries);
assert!(parse(&bytes, 8).is_none());
for cut in 0..bytes.len() {
assert!(parse(&bytes[..cut], 7).is_none(), "cut {cut}");
}
assert!(parse(b"garbage", 7).is_none());
let mut extra = bytes.clone();
extra.push(0);
assert!(parse(&extra, 7).is_none());
}
#[test]
fn changed_texture_invalidates_and_corrupt_file_is_ignored() {
let root = scratch("invalidate");
let png = root.join("assets/minecraft/textures/block/x.png");
fs::write(&png, b"one").unwrap();
let mut cache = SpriteCache::open(&root).unwrap();
cache.insert("minecraft:block/x", sample(1, None));
cache.save();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_some()
);
// Same length, new content and time: still stale.
std::thread::sleep(std::time::Duration::from_millis(30));
fs::write(&png, b"two").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
// A new file also invalidates.
let mut cache = SpriteCache::open(&root).unwrap();
cache.insert("minecraft:block/x", sample(1, None));
cache.save();
fs::write(root.join("assets/minecraft/textures/block/y.png"), b"y").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
// Corrupt cache file: empty, no error.
fs::write(root.join(FILE), b"HSPR junk").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
let _ = fs::remove_dir_all(&root);
}
fn write_png(path: &Path, rgba: [u8; 4]) {
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = rgba.iter().copied().cycle().take(16 * 16 * 4).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
}
fn sprite_pixels(root: &Path) -> Vec<u8> {
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
sprites.insert(root, "minecraft:block/x").unwrap();
sprites.save_disk_cache();
sprites.finish();
sprites.pixels
}
#[test]
fn altered_png_is_not_served_stale_and_cache_matches_decoding() {
let root = scratch("stale");
let png = root.join("assets/minecraft/textures/block/x.png");
write_png(&png, [255, 0, 0, 255]);
let decoded = sprite_pixels(&root);
assert!(root.join(FILE).exists());
assert_eq!(
sprite_pixels(&root),
decoded,
"cache hit differs from decoding"
);
std::thread::sleep(std::time::Duration::from_millis(30));
write_png(&png, [0, 255, 0, 255]);
let fresh = sprite_pixels(&root);
assert_eq!(&fresh[..4], &[0, 255, 0, 255]);
// Corrupt and deleted caches fall back to decoding.
fs::write(root.join(FILE), b"HSPR\x01\0\0\0 junk").unwrap();
assert_eq!(sprite_pixels(&root), fresh);
fs::remove_file(root.join(FILE)).unwrap();
assert_eq!(sprite_pixels(&root), fresh);
let _ = fs::remove_dir_all(&root);
}
}
+4 -3
View File
@@ -11,7 +11,6 @@ description = "Horizon native development client"
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
@@ -44,6 +43,9 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
@@ -88,7 +90,7 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["dep:cpal"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
@@ -106,7 +108,6 @@ path = "examples/probe/main.rs"
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -25,7 +25,6 @@ fn stack(spec: &str) -> LootStack {
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
components: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -5,7 +5,7 @@
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
use horizon_client::{
assets::Assets,
beehive::{BeehiveExt, HiveHost},
beehive::HiveHost,
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, HEIGHT, MIN_Y},
@@ -1112,7 +1112,6 @@ pub fn main() -> horizon_client::Result<()> {
others_grid: $grid,
others_index: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
game_time: world.game_time,
day_time: world.day_time,
raining: s.rain,
@@ -36,14 +36,11 @@ use horizon_client::{
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
spawn_box,
},
spawner::{
SpawnDataExt, Spawner, SpawnerAdded, SpawnerEvent, SpawnerExt, SpawnerLevel,
SpawnerSpawn,
},
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
species::{self, Baby},
trial_spawner::{
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
TrialSpawnerExt, TrialSpawnerLevel, TrialState,
TrialSpawnerLevel, TrialState,
},
},
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
@@ -1,58 +0,0 @@
//! M-11 gate: block assets loaded through the sprite disk cache are byte-identical
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::Path,
time::Instant,
};
fn digest(render: &RenderAssets) -> u64 {
let mut h = DefaultHasher::new();
render.pixels.hash(&mut h);
(
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
)
.hash(&mut h);
render.sprite_layers.hash(&mut h);
render.translucent_layers.hash(&mut h);
format!("{:?}", render.sprite_mips).hash(&mut h);
for animation in &render.animations {
animation.layer.hash(&mut h);
for frame in 0..animation.frame_count() {
animation.pixels(frame).hash(&mut h);
}
for tick in 0..400 {
format!("{:?}", animation.blend(tick)).hash(&mut h);
}
}
h.finish()
}
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("usage: sprite_cache_parity ROOT")?;
let root = Path::new(&root);
let assets = Assets::load(root)?;
let _ = std::fs::remove_file(root.join("horizon-sprites.cache"));
let start = Instant::now();
let cold = digest(&assets.reload(root)?);
let cold_ms = start.elapsed().as_secs_f64() * 1000.;
let size = std::fs::metadata(root.join("horizon-sprites.cache"))?.len();
let start = Instant::now();
let warm = digest(&assets.reload(root)?);
let warm_ms = start.elapsed().as_secs_f64() * 1000.;
println!(
"cold={cold:016x} ({cold_ms:.1} ms) warm={warm:016x} ({warm_ms:.1} ms) cache_bytes={size}"
);
if cold != warm {
return Err("cached sprites differ from decoded sprites".into());
}
println!("PARITY OK");
Ok(())
}
@@ -42,11 +42,11 @@ impl HeightmapState for StateFacts {
}
#[derive(Clone)]
pub struct Properties(Arc<Vec<StateFacts>>);
#[cfg(any(test, feature = "test-support"))]
#[cfg(test)]
impl Properties {
/// Heightmap facts for tests, per state: (air, has a fluid, blocks
/// motion, blocks motion but is not leaves). State 0 is minecraft:air.
pub fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
pub(crate) fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
Self(Arc::new(
states
.iter()
@@ -196,7 +196,7 @@ impl Assets {
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
let mut sprites = crate::sprites::Sprites::default();
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
@@ -217,7 +217,6 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
@@ -187,14 +187,7 @@ pub fn list_textures(root: &Path) -> Vec<String> {
};
for e in entries.flatten() {
let p = e.path();
// `file_type` comes with the listing; `Path::is_dir` is a stat per
// entry, kept for links (which it follows).
let dir = match e.file_type() {
Ok(t) if t.is_symlink() => p.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => p.is_dir(),
};
if dir {
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "png")
&& let Ok(rel) = p.strip_prefix(&base)
File renamed without changes.
File renamed without changes.
+22 -58
View File
@@ -83,19 +83,10 @@ fn pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
}
}
/// The bee occupant's codec and constructors, over the data in `chunk::block_entity`.
pub trait OccupantExt: Sized {
impl Occupant {
/// `Occupant.of`: the bee's saved tag without the ignored tags, staying
/// 2400 ticks with nectar, else 600.
fn of(tag: Tag) -> Self;
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
fn create(ticks_in_hive: i32) -> Self;
fn has_nectar(&self) -> bool;
fn from_nbt(tag: &Tag) -> Option<Self>;
fn to_nbt(&self) -> Tag;
}
impl OccupantExt for Occupant {
fn of(mut tag: Tag) -> Self {
pub fn of(mut tag: Tag) -> Self {
if let Tag::Compound(c) = &mut tag {
strip_ignored(c);
}
@@ -106,7 +97,8 @@ impl OccupantExt for Occupant {
min_ticks_in_hive: if nectar { 2400 } else { 600 },
}
}
fn create(ticks_in_hive: i32) -> Self {
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
pub fn create(ticks_in_hive: i32) -> Self {
Self {
entity_data: Tag::compound([("id", Tag::String("minecraft:bee".into()))]),
ticks_in_hive,
@@ -136,50 +128,13 @@ impl OccupantExt for Occupant {
}
}
/// `BeehiveBlockEntity`'s behaviour, over the data in `chunk::block_entity`.
pub trait BeehiveExt: Sized {
fn full(&self) -> bool;
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
/// and `flower_pos`.
fn from_nbt(tag: &Tag) -> Self;
/// `saveAdditional`.
fn to_nbt(&self) -> Vec<(&'static str, Tag)>;
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
/// the bee's flower, or now and then swaps its own for it. The host then
/// discards the bee.
fn add_occupant(
&mut self,
at: [i32; 3],
tag: Tag,
bee_flower: Option<[i32; 3]>,
host: &mut impl HiveHost,
) -> bool;
/// `serverTick`: the bees whose time is up come out, then the hive
/// hums now and then while it holds any. `honey` is the hive state's
/// honey level as the tick starts (`None` without the property);
/// `facing` its front.
fn server_tick(
&mut self,
at: [i32; 3],
facing: [i32; 2],
honey: Option<i32>,
host: &mut impl HiveHost,
);
/// `releaseAllOccupants`: the ids of the bees let out.
fn release_all(
&mut self,
at: [i32; 3],
facing: [i32; 2],
honey: Option<i32>,
status: Release,
host: &mut impl HiveHost,
) -> Vec<u32>;
}
impl BeehiveExt for Beehive {
fn full(&self) -> bool {
impl Beehive {
pub fn full(&self) -> bool {
self.bees.len() == MAX_OCCUPANTS
}
fn from_nbt(tag: &Tag) -> Self {
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
/// and `flower_pos`.
pub fn from_nbt(tag: &Tag) -> Self {
let bees = match tag.get("bees") {
Some(Tag::List(list)) => list
.iter()
@@ -193,7 +148,8 @@ impl BeehiveExt for Beehive {
flower: pos(tag.get("flower_pos")),
}
}
fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
/// `saveAdditional`.
pub fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![(
"bees",
Tag::List(self.bees.iter().map(Occupant::to_nbt).collect()),
@@ -203,7 +159,10 @@ impl BeehiveExt for Beehive {
}
out
}
fn add_occupant(
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
/// the bee's flower, or now and then swaps its own for it. The host then
/// discards the bee.
pub fn add_occupant(
&mut self,
at: [i32; 3],
tag: Tag,
@@ -223,7 +182,11 @@ impl BeehiveExt for Beehive {
host.sound("block.beehive.enter", corner);
true
}
fn server_tick(
/// `serverTick`: the bees whose time is up come out, then the hive
/// hums now and then while it holds any. `honey` is the hive state's
/// honey level as the tick starts (`None` without the property);
/// `facing` its front.
pub fn server_tick(
&mut self,
at: [i32; 3],
facing: [i32; 2],
@@ -260,7 +223,8 @@ impl BeehiveExt for Beehive {
host.sound("block.beehive.work", hum);
}
}
fn release_all(
/// `releaseAllOccupants`: the ids of the bees let out.
pub fn release_all(
&mut self,
at: [i32; 3],
facing: [i32; 2],
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -230,7 +230,7 @@ pub struct Info {
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
pub current: Option<(Info, i64)>,
pub(crate) current: Option<(Info, i64)>,
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -278,7 +278,7 @@ pub struct SpawnData {
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
pub enum Next {
pub(crate) enum Next {
Potential(usize),
Own(SpawnData),
}
@@ -288,7 +288,7 @@ pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
pub next: Option<Next>,
pub(crate) next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
@@ -301,24 +301,6 @@ pub struct Spawner {
pub spin: f64,
pub o_spin: f64,
}
impl Default for Spawner {
fn default() -> Self {
Self {
spawn_delay: 20,
potentials: Vec::new(),
next: None,
min_spawn_delay: 200,
max_spawn_delay: 800,
spawn_count: 4,
max_nearby_entities: 6,
required_player_range: 16,
spawn_range: 4,
client_delay: 20,
spin: 0.0,
o_spin: 0.0,
}
}
}
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
@@ -332,26 +314,6 @@ pub struct TrialConfig {
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
impl Default for TrialConfig {
/// `TrialSpawnerConfig.Builder`'s defaults.
fn default() -> Self {
Self {
spawn_range: 4,
total_mobs: 6.0,
simultaneous_mobs: 2.0,
total_mobs_added_per_player: 2.0,
simultaneous_mobs_added_per_player: 1.0,
ticks_between_spawn: 40,
spawn_potentials: Vec::new(),
loot_tables_to_eject: vec![
("minecraft:spawners/trial_chamber/consumables".into(), 1),
("minecraft:spawners/trial_chamber/key".into(), 1),
],
items_to_drop_when_ominous:
"minecraft:spawners/trial_chamber/items_to_drop_when_ominous".into(),
}
}
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -385,28 +347,6 @@ pub struct TrialSpawner {
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<ItemStack>>,
}
impl Default for TrialSpawner {
fn default() -> Self {
Self {
normal: ConfigSlot::default(),
ominous: ConfigSlot::default(),
target_cooldown_length: 36000,
required_player_range: 14,
detected_players: Vec::new(),
current_mobs: Vec::new(),
cooldown_ends_at: 0,
next_mob_spawns_at: 0,
total_mobs_spawned: 0,
next_spawn_data: None,
ejecting_loot_table: None,
is_ominous: false,
has_display: false,
spin: 0.0,
o_spin: 0.0,
dispensing: None,
}
}
}
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
@@ -330,7 +330,7 @@ pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
// separator the build machine used.
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
let root = manifest
.strip_suffix("crates/horizon-client-render")
.strip_suffix("crates/horizon-client")
.unwrap_or(&manifest);
let mut frames: Vec<(String, Option<String>)> = Vec::new();
for line in backtrace.lines() {
@@ -605,7 +605,7 @@ mod tests {
);
assert_eq!(
stack_lines(&bt, true),
"horizon_client::crash::debug_crash_tick (crates/horizon-client-render/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
"horizon_client::crash::debug_crash_tick (crates/horizon-client/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
);
}
+3 -116
View File
@@ -129,27 +129,6 @@ fn is_animal(id: &str) -> bool {
| "zombie_nautilus"
)
}
/// The `NeutralMob`s whose anger is `Extra::anger`: they write and read
/// `anger_end_time` and `angry_at`. The bee keeps its own anger, and a
/// piglin's is a brain memory, not `NeutralMob`'s.
fn persistent_anger(s: crate::mobs::Species) -> bool {
use crate::mobs::Species as S;
matches!(
s,
S::Wolf | S::IronGolem | S::PolarBear | S::Enderman | S::ZombifiedPiglin
)
}
/// The single player's UUID (`offline_uuid("Player")`): the entity the
/// neutral mobs are angry at.
fn player_uuid() -> [i32; 4] {
let u = horizon_protocol::crypto::offline_uuid("Player");
[
(u >> 96) as u32 as i32,
(u >> 64) as u32 as i32,
(u >> 32) as u32 as i32,
u as u32 as i32,
]
}
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: &m.kind.id,
@@ -180,22 +159,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
] {
put(&mut tag, key, value);
}
// `NeutralMob.addPersistentAngerSaveData`: the anger ends at an
// absolute game time, and the target is the entity the mob is angry at.
if persistent_anger(m.species()) {
put(
&mut tag,
"anger_end_time",
Tag::Long(if m.extra.anger_end > 0 {
m.extra.anger_end
} else {
-1
}),
);
if m.extra.angry_at == Some(crate::mobs::Who::Player) {
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
}
}
// `Entity.saveWithoutId` writes the flag only when set.
if m.name_visible {
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
@@ -629,13 +592,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"minecraft:wither" => {
map.insert("Invul".into(), Tag::Int(m.extra.counter));
}
// IronGolem.addAdditionalSaveData.
"minecraft:iron_golem" => {
map.insert(
"PlayerCreated".into(),
Tag::Byte(i8::from(m.extra.player_created)),
);
}
"minecraft:warden" => {
if let Some(st) = &m.extra.warden {
map.insert("listener".into(), st.vibration.to_nbt());
@@ -759,13 +715,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.into_iter()
.enumerate()
{
// Enchanted or trimmed gear is saved with its components.
// Enchanted gear is saved with its `minecraft:enchantments`.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
&& !g.slots[index].is_empty()
{
let mut stack = crate::items::ItemStack::new(id, 1);
stack.data_mut().enchantments = g.slots[index].clone();
stack.data_mut().trim = g.trims[index].clone();
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
@@ -995,12 +950,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.legs = slot("legs");
m.extra.feet = slot("feet");
}
// Their enchantments and trims.
// Their enchantments.
for (index, key) in crate::mobs::gear::KEYS.iter().enumerate() {
if let Some(stack) = eq.get(key).and_then(crate::save::item_from_nbt) {
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
let trim = stack.data().and_then(|d| d.trim.clone());
crate::mobs::gear::set_trim(&mut m, index, trim);
}
}
} else {
@@ -1012,17 +965,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
// Mob.readAdditionalSaveData overrides what the constructor set (copper
// golems and elder guardians start persistent).
m.persistent = flag(tag, "PersistenceRequired");
// `NeutralMob.readPersistentAngerSaveData`: the anger end time and the
// target come back with the mob.
if persistent_anger(m.species()) {
m.extra.anger_end = number(tag.get("anger_end_time")).unwrap_or(-1);
if tag
.get("angry_at")
.is_some_and(|t| uuid_from_tag(Some(t)) == Some(player_uuid()))
{
m.extra.angry_at = Some(crate::mobs::Who::Player);
}
}
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
@@ -1334,10 +1276,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
m.extra.sheared = flag(tag, "Sheared");
}
// IronGolem.readAdditionalSaveData: not player created when absent.
if m.species() == crate::mobs::Species::IronGolem {
m.extra.player_created = flag(tag, "PlayerCreated");
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
@@ -1571,10 +1509,6 @@ pub fn arrow_to_nbt(
// Projectile: LeftOwner only when set; Owner as the UUID.
map.remove(&"LeftOwner".into());
map.remove(&"inBlockState".into());
map.remove(&"weapon".into());
}
if let Some(weapon) = &a.fired_from_weapon {
put(&mut tag, "weapon", item_to_nbt(weapon));
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
@@ -1621,7 +1555,6 @@ pub fn arrow_from_nbt(
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
a.pickup_item = item;
a.fired_from_weapon = tag.get("weapon").and_then(item_from_nbt);
p.stuck = a.in_ground;
Some((p, tag.get("inBlockState").cloned()))
}
@@ -3047,49 +2980,3 @@ mod fireball_nbt_tests {
);
}
}
#[cfg(test)]
mod neutral_anger_tests {
use super::*;
/// #2784: a neutral mob's anger survives the save: the anger ends at an
/// absolute game time and the target comes back with it
/// (`NeutralMob.addPersistentAngerSaveData` / `readPersistentAngerSaveData`).
#[test]
fn a_neutral_mobs_anger_survives_the_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
types["minecraft:wolf"].clone(),
Vec3::new(0.0, 64.0, 0.0),
0.0,
);
m.extra.anger = 400;
m.extra.anger_end = 3000;
m.extra.angry_at = Some(crate::mobs::Who::Player);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
assert!(tag.get("angry_at").is_some());
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!(back.extra.anger_end, 3000);
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
// A mob that is no NeutralMob writes no anger.
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None);
assert!(tag.get("anger_end_time").is_none());
// The anger that is left when the mob comes back.
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
}
}
+1 -5
View File
@@ -173,16 +173,12 @@ pub fn passes_cobweb(
stuck
}
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
}
pub fn hurt_mobs_with_kind(mobs: &mut [Mob], position: Vec3, amount: f32, kind: DamageKind) {
if amount <= 0.0 {
return;
}
let bounds = bounds(position);
let source = MobDamage {
kind,
kind: DamageKind::FallingBlock,
attacker: None,
from: Some([position.x, position.y, position.z]),
};
File renamed without changes.
File renamed without changes.
+153 -265
View File
@@ -8,7 +8,6 @@ mod allays;
mod armor_stands;
mod banners;
mod beacon;
mod beds;
mod beehives;
mod block_entity_anims;
mod block_entity_ticks;
@@ -29,7 +28,6 @@ mod chat;
mod chiseled_bookshelf;
mod client_ambient;
mod clipboard;
mod code_of_conduct;
mod combat;
mod command_blocks;
mod command_harness;
@@ -110,9 +108,12 @@ mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_sounds;
mod mod_browser;
#[cfg(feature = "mods")]
mod mod_browser_recipes;
mod mod_input;
#[cfg(feature = "mods")]
mod mod_panel;
mod mod_recipes;
mod mod_render;
mod mods;
mod mount_menu;
@@ -122,9 +123,6 @@ mod moving_pistons;
mod music;
mod names;
mod native_hooks;
mod native_panel;
mod native_panel_recipes;
mod native_recipes;
mod native_shaders;
mod natural_spawning;
mod nautili;
@@ -1410,8 +1408,6 @@ struct Game<'a> {
server_packs: server_packs::ServerPackState,
/// The link a chat click asks to open (`ConfirmLinkScreen`).
link_prompt: Option<String>,
/// A server's Code of Conduct to accept (game/code_of_conduct.rs).
code_of_conduct: Option<code_of_conduct::Prompt>,
dialogs: dialogs::DialogState,
/// Single player's server while it loads its world (`Minecraft.doWorldLoad`).
integrated_starting: Option<integrated::Starting>,
@@ -1677,9 +1673,6 @@ struct Game<'a> {
/// The head of the bed while sleeping; the visual timer also survives waking.
sleeping: Option<[i32; 3]>,
sleep_timer: u32,
/// The beds players sleep in that this game marked `occupied`, by level
/// (game/beds.rs).
player_beds: Vec<(Dim, [i32; 3])>,
/// `SleepStatus`'s active and sleeping players at the last announcement.
sleep_seen: (i64, i64),
dim: Dim,
@@ -1829,10 +1822,6 @@ struct Game<'a> {
extras: extras::ExtrasState,
/// Horizon mods (`game/mods.rs`), none running unless turned on.
mods: mods::ModsState,
/// Every item and recipe, for mods (`game/native_recipes.rs`), made once.
recipe_tables: native_recipes::RecipeTables,
/// The item panel beside container screens (`game/native_panel.rs`).
panel: native_panel::Panel,
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
native_shaders: native_shaders::NativeShaders,
/// Meshed sections waiting for an upload budget (Horizon settings).
@@ -2066,12 +2055,6 @@ impl Game<'_> {
p.position.z,
])
}
/// Where targeting starts and points: the eyes along the view. Every
/// crosshair target (blocks, fluids, entities, mod raycasts) is found
/// along this ray, so a tracked controller can stand in for it.
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
Some((self.eye_position()?, view_vector(self.yaw, self.pitch)))
}
fn reach(&self) -> f64 {
if self.creative { 5.0 } else { 4.5 }
}
@@ -2146,8 +2129,8 @@ impl Game<'_> {
point: hit.point,
});
}
let (origin, dir) = self.aim()?;
self.clip_ray(origin, dir, self.reach())
let origin = self.eye_position()?;
self.clip_ray(origin, view_vector(self.yaw, self.pitch), self.reach())
}
/// The first block outline hit within `reach` along the unit `dir`.
fn clip_ray(&self, origin: [f64; 3], dir: [f64; 3], reach: f64) -> Option<Hit> {
@@ -3414,32 +3397,6 @@ impl Game<'_> {
pos,
data,
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
// EnderDragonFight.spawnNewGateway: an always-visible burst
// and a sound at the gateway for every nearby player.
LevelEvent::LevelEvent { id: 3000, pos, .. } => {
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 0.5,
f64::from(pos.z) + 0.5,
];
self.particles.add_with(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::ExplosionEmitter,
),
at,
[0.0; 3],
true,
true,
);
let random = self.particles.level_random();
let pitch = (1.0 + (random.next_float() - random.next_float()) * 0.2) * 0.7;
self.audio.play_unshared(
"minecraft:block.end_gateway.spawn",
Some(at),
10.0,
pitch,
);
}
// LevelEventHandler 3009: a sniffer egg cracking.
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
self.sniffer_egg_crack_particles(pos)
@@ -3456,14 +3413,11 @@ impl Game<'_> {
f64::from(pos.z) + 0.5,
]);
let sound = match id {
1503 => Some(("minecraft:block.end_portal_frame.fill", 1.0)),
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)),
1045 => Some(("minecraft:block.pointed_dripstone.land", 2.0)),
1052 => Some(("minecraft:block.sulfur_spike.land", 2.0)),
1046 => Some((
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
2.0,
@@ -3499,9 +3453,7 @@ impl Game<'_> {
let r = self.particles.level_random();
2.6 + (r.next_float() - r.next_float()) * 0.8
}
1045 | 1046 | 1047 | 1052 => {
self.particles.level_random().next_float() * 0.1 + 0.9
}
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
}
@@ -3512,25 +3464,21 @@ impl Game<'_> {
}
_ => 1.0,
};
if matches!(
id,
1019 | 1021 | 1029 | 1030 | 1045 | 1052 | 1053 | 1054 | 1503
) {
// Clients play these sounds from their level events;
if matches!(id, 1019 | 1021 | 1029 | 1030 | 1053 | 1054) {
// Clients play the anvil sound from its level event;
// capturing it also sends a duplicate sound packet.
self.audio.play_unshared(name, c, volume, pitch);
} else {
self.audio.play(name, c, volume, pitch);
}
}
// LevelEventHandler draws smoke after the sound.
// 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),
1503 => self.end_portal_frame_smoke(pos),
_ => {}
}
}
@@ -3556,18 +3504,7 @@ impl Game<'_> {
LevelEvent::DropResources { pos, state } => {
self.with_level(|_, level, _| level.drop_resources_now(pos, state));
}
LevelEvent::FallingBlock {
pos,
state,
hurt_per_distance,
} => {
let mut damage = horizon_client::falling_block::Damage::falling(
&self.res.facts().get(state).name,
);
if let Some(per_distance) = hurt_per_distance {
damage.hurt_entities = true;
damage.per_distance = per_distance;
}
LevelEvent::FallingBlock { pos, state } => {
let p = Vec3::new(
f64::from(pos.x) + 0.5,
f64::from(pos.y),
@@ -3585,7 +3522,9 @@ impl Game<'_> {
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
damage,
damage: horizon_client::falling_block::Damage::falling(
&self.res.facts().get(state).name,
),
saved: None,
});
}
@@ -4043,7 +3982,7 @@ impl Game<'_> {
hardness != 0.0
};
if wears && per_block > 0 && max > 0 {
let loss = self.wear(&stack, per_block);
let loss = self.durability_loss(&stack, per_block);
if let Some(held) = self.inventory[self.selected].as_mut() {
held.damage += loss;
if held.damage >= max {
@@ -5333,7 +5272,7 @@ impl Game<'_> {
&& !self.creative
&& let Some(stack) = self.armor[1].clone()
{
let loss = self.wear(&stack, glider_wear);
let loss = self.durability_loss(&stack, glider_wear);
if let Some(elytra) = self.armor[1].as_mut() {
elytra.damage += loss;
}
@@ -5450,10 +5389,6 @@ impl Game<'_> {
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos),
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
// Entity.restituteMovementAfterCollisions: GameEvent.BOUNCE.
MotionEvent::Bounce { .. } => {
self.post_entity_event("minecraft:bounce", horizon_client::mobs::Who::Player)
}
MotionEvent::SmashLanding {
state,
position,
@@ -5626,7 +5561,6 @@ impl Game<'_> {
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
self.announce_sleep_status();
self.free_left_beds();
horizon_client::spans::time("tick_portal", || self.tick_nether_portal())?;
if running {
horizon_client::spans::time("tick_end", || self.tick_end())?;
@@ -6049,9 +5983,7 @@ impl Game<'_> {
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
// The pack prompt replaces the connecting screen (setScreen), but
// in game it opens over the world.
let mut batch = if (self.server_packs.prompt.is_some()
|| self.code_of_conduct.is_some()
|| self.dialog_open())
let mut batch = if (self.server_packs.prompt.is_some() || self.dialog_open())
&& self.player.is_none()
{
HudBatch::new(size)
@@ -6060,7 +5992,6 @@ impl Game<'_> {
};
self.draw_dialog(&mut batch);
self.draw_pack_prompt(&mut batch);
self.draw_code_of_conduct(&mut batch);
self.draw_link_prompt(&mut batch);
// Gui.extractRenderState: the debug overlay comes after the screen
// and the toasts, so it is over everything, menus included.
@@ -8086,7 +8017,7 @@ impl Game<'_> {
);
}
}
if self.own_body_in_view()
if !self.camera_type.first_person()
&& let Some(p) = &self.player
&& p.fire_ticks > 0
&& !p.spectator
@@ -8166,7 +8097,7 @@ impl Game<'_> {
// Mobs and their projectiles (a span each).
self.mob_geometry(camera, alpha, &mut out);
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), !self.own_body_in_view());
self.mark_player(out.len(), self.camera_type.first_person());
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
self.mark_entity(out.len(), "minecraft:player");
self.guest_model_geometry(camera, alpha, &mut out);
@@ -8571,7 +8502,8 @@ impl Game<'_> {
darken
}
fn mob_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64)> = None;
for (i, m) in self.mobs.iter().enumerate() {
@@ -8592,11 +8524,7 @@ impl Game<'_> {
/// strength and whether it is a critical hit): the weapon and effects
/// scaled by the attack strength, a mace's smash, the critical hit, then
/// enchantments.
pub(super) fn melee_damage(
&mut self,
target_kind: &str,
living: bool,
) -> (f32, f32, bool, bool) {
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool, bool) {
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
// Riptide's spin replaces the attack damage attribute.
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
@@ -8606,19 +8534,27 @@ impl Game<'_> {
hand_damage + self.effect_attack_bonus()
};
let scale = self.attack_strength_scale();
// getEnchantedDamage(target, damage, source) - damage, taken before the
// attack strength scales the base damage.
let enchant_bonus = self.enchanted_damage(target_kind, damage) - damage;
let mut amount = damage * (0.2 + scale * scale * 0.8);
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
amount += self.smash_damage_bonus(target_kind);
let crit = living && self.critical_attack(scale);
let player = self.player.as_ref();
// Player.canCriticalAttack: falling, off the ground, not in water,
// not blind (isMobilityRestricted), not riding, not sprinting.
let falling = player.is_some_and(|p| {
!p.ground.on_ground
&& p.fall_distance > 0.0
&& !p.fluids.was_touching_water
&& !p.sprinting
});
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
let crit = scale > 0.9 && falling && !restricted;
if crit {
amount *= 1.5;
}
// Enchantment damage (sharpness, smite, bane) scaled by the attack strength.
amount += enchant_bonus * scale;
(amount, scale, crit, enchant_bonus * scale > 0.0)
let magic_boost = self.enchant_damage_bonus(target_kind) * scale;
amount += magic_boost;
(amount, scale, crit, magic_boost > 0.0)
}
/// `Player.getAttackStrengthScale(0.5)`.
fn attack_strength_scale(&self) -> f32 {
@@ -8626,14 +8562,7 @@ impl Game<'_> {
((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0)
}
/// `Player.isSweepAttack`: a charged, grounded sword hit below the movement limit.
pub(super) fn sweep_attack_position(&mut self, scale: f32, crit: bool) -> Option<[f64; 3]> {
// Player.getSpeed: the movement speed attribute as it is now (the
// field is only refreshed by the local movement step, never for a
// server's player).
let p = self.player.as_mut()?;
if let Ok(speed) = p.attribute("minecraft:movement_speed") {
p.speed = speed as f32;
}
pub(super) fn sweep_attack_position(&self, scale: f32, crit: bool) -> Option<[f64; 3]> {
let p = self.player.as_ref()?;
let sword = self.held().is_some_and(|s| {
self.res
@@ -8645,116 +8574,120 @@ impl Game<'_> {
if !p.is_sweep_attack(scale, crit, sword) {
return None;
}
// Mth.sin / Mth.cos of yRot * (PI / 180), from its sine table.
use horizon_core::geometry::trig;
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
let yaw = f64::from(self.yaw.to_radians());
Some([
p.position.x - f64::from(trig::sin(yaw)),
p.position.x - yaw.sin(),
p.position.y + f64::from(p.dimensions.height) * 0.5,
p.position.z + f64::from(trig::cos(yaw)),
p.position.z + yaw.cos(),
])
}
/// `Player.doSweepAttack` after a successful hit, in vanilla's order: the
/// sweep sound, the victims around `target` (its box, and the mob that
/// was hit, when there is one) and then the arc.
pub(super) fn sweep_attack(
&mut self,
at: [f64; 3],
from: Option<[f64; 3]>,
target: Option<(Aabb, mace::Spared)>,
scale: f32,
) {
self.audio
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
if let Some((area, mob)) = target {
self.sweep_damage(area, mob, scale);
}
let arc = horizon_client::particles::ParticleOptions::simple(
/// 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]>) {
let sweep = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
);
self.particles.add(&arc, at, [0.0; 3]);
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(&arc, at, spread, 0.0, 0);
self.lan_send_particles(&sweep, at, spread, 0.0, 0);
self.audio
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
}
/// What `Player.doSweepAttack` deals before the target's enchantment
/// bonus and the attack strength: `1 + sweeping_damage_ratio * baseDamage`.
pub(super) fn sweep_base_damage(&self, scale: f32) -> f32 {
// `baseDamage` of `Player.attack` before a critical hit (a sweep has
// none) and the enchantments: the attack damage attribute with the
// strength and weakness effects, scaled by the attack strength.
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
let base_damage = (hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
// The enchantment's attribute modifier on the attribute's 0 to 1 range.
let ratio = self
.res
.stations()
.enchantments
.attribute_add(
self.inventory[self.selected].as_ref(),
"minecraft:sweeping_damage_ratio",
)
.clamp(0.0, 1.0) as f32;
1.0 + ratio * base_damage
}
/// The damage and knockback part of `Player.doSweepAttack` after a sweep
/// hit on the mob at `target`: every other living mob in its box grown by
/// (1, 0.25, 1) and closer than 3 blocks (`distanceToSqr < 9`, in creative
/// too), but the player's allies
/// (`isAlliedTo`), takes `1 + sweeping_damage_ratio * baseDamage` with the
/// weapon's enchantments, scaled by the attack strength, and is pushed 0.4
/// along the player's yaw.
pub(super) fn sweep_damage(&mut self, target: usize, scale: f32) {
let Some(p) = self.player.as_ref() else {
return;
};
let from = [p.position.x, p.position.y, p.position.z];
let sweep_damage = self.sweep_base_damage(scale);
let hit_box = self.mobs[target]
.bounds()
.inflate(horizon_core::geometry::Vec3::new(1.0, 0.25, 1.0));
let (uuid, allies) = (
self.level.player_uuid.or(Some([0, 0, 0, 1])),
self.player_allies(),
);
let nearby: Vec<usize> = self
.mobs
.iter()
.enumerate()
.filter(|(i, m)| {
*i != target
&& m.alive()
&& !m.removed
&& !allies.contains(&m.id)
&& !mace::ally_of_player(m, uuid)
&& m.bounds().intersects(hit_box)
&& m.position.distance_squared(p.position) < 9.0
})
.map(|(i, _)| i)
.collect();
let (xd, zd) = self.extra_knockback_direction();
for i in nearby {
let kind = self.mobs[i].kind.id.clone();
let kind = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
let damage = (sweep_damage + self.enchant_damage_bonus(&kind)) * scale;
let source = self.attack_source(from);
if self.hurt_by_player(i, damage, source) {
self.mobs[i].knockback(0.4, xd, zd);
self.last_hurt_by_player
.insert(self.mobs[i].id, self.lan_acting);
}
}
// `getEntitiesOfClass(LivingEntity.class)` takes the other players too.
let mut sweep = self.player_sweep(sweep_damage, scale);
sweep.hit_box = hit_box;
self.sweep_other_players(sweep);
}
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
/// and can't be hit critically): knockback when sprinting at full
/// strength, then strong or weak, or the sweep around the vehicle's
/// box `area`. Before `swing_arm` resets the charge.
pub(super) fn vehicle_attack_sounds(&mut self, area: Aabb, magic: Option<i32>) {
self.attack_effects(area, None, mace::Spared::Nothing, magic);
}
/// `Player.attack` after a target that is not a mob took the hit:
/// the sprint knockback sound, a living target's critical hit (`living`:
/// its entity id, which makes the hit critical when the player falls),
/// the sweep around the target's box `area` and the strong or weak sound.
pub(super) fn attack_effects(
&mut self,
area: Aabb,
living: Option<i32>,
spared: mace::Spared,
magic: Option<i32>,
) {
let scale = self.attack_strength_scale();
let full = scale > 0.9;
/// strength, then strong or weak. Before `swing_arm` resets the charge.
pub(super) fn vehicle_attack_sounds(&mut self) {
let full = self.attack_strength_scale() > 0.9;
let player = self.player.as_ref();
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
if full && player.is_some_and(|p| p.sprinting) {
self.audio
.play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0);
}
let crit = living.is_some() && self.critical_attack(scale);
let sweep = self.sweep_attack_position(scale, crit);
if let Some(at) = sweep {
self.sweep_attack(at, from, Some((area, spared)), scale);
}
if crit {
self.audio
.play("minecraft:entity.player.attack.crit", from, 1.0, 1.0);
if let Some(id) = living {
self.area_critical_hit(id, area, false);
}
} else if sweep.is_none() {
let sound = if full {
"minecraft:entity.player.attack.strong"
} else {
"minecraft:entity.player.attack.weak"
};
let sound = if full {
"minecraft:entity.player.attack.strong"
} else {
"minecraft:entity.player.attack.weak"
};
if let Some(at) = self.sweep_attack_position(self.attack_strength_scale(), false) {
self.sweep_attack(at, from);
} else {
self.audio.play(sound, from, 1.0, 1.0);
}
// Player.magicCrit: the enchantments added damage.
if let Some(id) = magic {
self.area_critical_hit(id, area, true);
}
}
/// `critical_hit` around the box `area` of entity `id` (`magic`: the
/// enchanted hit instead of the crit).
fn area_critical_hit(&mut self, id: i32, area: Aabb, magic: bool) {
let (feet, size) = (area.bottom_center(), area.size());
self.critical_hit(
self.lan_acting,
id,
[feet.x, feet.y, feet.z],
size.x,
size.y,
magic,
);
}
/// `Player.canCriticalAttack` for a living target at attack strength
/// `scale`: falling, off the ground, not in water, not blind (mobility
/// restricted), not riding, not sprinting.
pub(super) fn critical_attack(&self, scale: f32) -> bool {
let player = self.player.as_ref();
let falling = player.is_some_and(|p| {
!p.ground.on_ground
&& p.fall_distance > 0.0
&& !p.fluids.was_touching_water
&& !p.sprinting
});
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
scale > 0.9 && falling && !restricted
}
/// Player.attack with the attack-strength charge, crits and knockback.
fn attack(&mut self) -> bool {
@@ -8780,11 +8713,16 @@ impl Game<'_> {
if self.attack_redirectable() {
return true;
}
let hand_damage = 1.0
+ self
.held()
.and_then(|h| self.res.items().get(&h.item))
.map_or(0.0, |i| i.attack_damage);
if self.attack_frame()
|| self.attack_armor_stand()
|| self.attack_cushion()
|| self.attack_boat()
|| self.attack_cart()
|| self.attack_boat(hand_damage)
|| self.attack_cart(hand_damage)
|| self.attack_dragon()
|| self.attack_guest()
{
@@ -8813,7 +8751,7 @@ impl Game<'_> {
pub(super) fn attack_mob(&mut self, index: usize) {
let target_kind = self.mobs[index].kind.id.clone();
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
let (amount, scale, crit, magic) = self.melee_damage(&target_kind, true);
let (amount, scale, crit, magic) = self.melee_damage(&target_kind);
let sweep = self.sweep_attack_position(scale, crit);
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
self.attack_ticks = 0;
@@ -8925,11 +8863,8 @@ impl Game<'_> {
};
// Player.playServerSideSound: at the player.
if let Some(at) = sweep {
let target = (
self.mobs[index].bounds(),
mace::Spared::Mob(self.mobs[index].id),
);
self.sweep_attack(at, from, Some(target), scale);
self.sweep_attack(at, from);
self.sweep_damage(index, scale);
} else {
self.audio.play(sound, from, 1.0, 1.0);
}
@@ -8974,8 +8909,10 @@ impl Game<'_> {
fn explode(&mut self, center: [f64; 3], radius: f32) -> Result<()> {
let interaction = if !self.ui.rules.bool("mob_griefing") {
explosion::BlockInteraction::Keep
} else if self.ui.rules.bool("mob_explosion_drop_decay") {
explosion::BlockInteraction::DestroyWithDecay
} else {
self.destroy_type("mob_explosion_drop_decay")
explosion::BlockInteraction::Destroy
};
self.server_explode(center, radius, interaction, false)
}
@@ -9162,7 +9099,6 @@ impl Game<'_> {
self.rand_sequences = rand_sequences;
self.border = Default::default();
self.set_mode_id(level.mode());
self.previous_mode = level.previous_game_mode.map(game_mode::mode_from_id);
let mut inventory = vec![None; 36];
self.armor = [None, None, None, None];
self.offhand = None;
@@ -9236,7 +9172,6 @@ impl Game<'_> {
self.player = None;
self.sleeping = None;
self.sleep_timer = 0;
self.player_beds.clear();
self.spawn = None;
self.respawn_travel = None;
self.respawn_config = self
@@ -9306,8 +9241,7 @@ impl Game<'_> {
};
let from = self.dim;
let destination = self.switch_level(target, player.position)?;
let at = player.position;
self.trigger_dimension(from, target, [at.x, at.y, at.z]);
self.trigger_dimension(from, target);
self.travelling = Some((player, destination));
self.end_travel = (target == Dim::End || from == Dim::End) && self.end_portal_travel;
Ok(())
@@ -9534,7 +9468,6 @@ impl Game<'_> {
self.stats.custom("leave_game", 1);
}
self.lan_close(false);
self.free_player_beds()?;
self.save_game()?;
if let Some(mut remote) = self.remote.take() {
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
@@ -9567,7 +9500,6 @@ impl Game<'_> {
self.player = None;
self.sleeping = None;
self.sleep_timer = 0;
self.player_beds.clear();
self.clear_entities();
self.screen = Screen::Playing;
self.clear_lod();
@@ -9697,7 +9629,7 @@ impl Game<'_> {
.is_some_and(|info| info.equip_damage_on_hurt)
&& !(fire && self.res.items().resists_fire(&s, &name)) =>
{
self.wear(&s, amount)
self.durability_loss(&s, amount)
}
_ => 0,
})
@@ -9728,7 +9660,8 @@ impl Game<'_> {
}
/// Voxel traversal that also stops at fluid source blocks (bucket pick).
fn pick_fluid(&self) -> Option<Hit> {
let (origin, dir) = self.aim()?;
let origin = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let mut t = 0.0;
let mut last = origin.map(|v| v.floor() as i32);
while t <= self.reach() {
@@ -10012,7 +9945,7 @@ impl Game<'_> {
return;
}
let amount = match self.held().cloned() {
Some(held) => self.wear(&held, amount),
Some(held) => self.durability_loss(&held, amount),
None => amount,
};
let max = self
@@ -10048,7 +9981,7 @@ impl Game<'_> {
let Some(held) = self.held().cloned() else {
return;
};
let amount = self.wear(&held, amount);
let amount = self.durability_loss(&held, amount);
let max = self.res.items().get(&held.item).map_or(0, |i| i.max_damage);
if amount == 0 || max == 0 {
return;
@@ -10560,7 +10493,6 @@ impl Game<'_> {
let ammo = arrow_slot
.and_then(|i| self.stack_in(i).cloned())
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
let weapon = self.held().cloned();
let (power_bonus, infinity) = self.bow_enchantments(&ammo.item);
let kind = horizon_client::projectiles::ProjectileKind::Arrow {
base_damage: 2.0 + power_bonus,
@@ -10581,7 +10513,6 @@ impl Game<'_> {
self.creative || infinity,
));
a.set_owner(Some(horizon_client::mobs::Who::Player));
a.fired_from_weapon = weapon;
}
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
if let Some(seconds) = self.bow_projectile_ignite()
@@ -10589,9 +10520,10 @@ impl Game<'_> {
{
a.ignite_for_seconds(seconds);
}
let (velocity, on_ground) = self.player.as_ref().map_or((Vec3::ZERO, true), |pl| {
(self.known_player_movement(), pl.ground.on_ground)
});
let (velocity, on_ground) = self
.player
.as_ref()
.map_or((Vec3::ZERO, true), |pl| (pl.velocity, pl.ground.on_ground));
arrow.shoot_from_rotation(
&mut self.world.random,
self.pitch,
@@ -11060,22 +10992,6 @@ impl Game<'_> {
} else {
(pos, [pos[0] - d[0], pos[1], pos[2] - d[2]])
};
// AbstractBedBlock.useWithoutItem: in an occupied bed a villager
// asleep there is woken (kickVillagerOutOfBed), else someone else
// sleeps in it; the player stays up either way.
let head_state = self.world.block(head[0], head[1], head[2]);
let occupied = self
.pack
.assets
.registry
.state(head_state)
.is_some_and(|s| s.properties.get("occupied").is_some_and(|v| v == "true"));
if occupied {
if !self.kick_villager_out_of_bed(head) {
self.tell_sleeper("block.minecraft.bed.occupied", "This bed is occupied", true);
}
return;
}
// ServerPlayer.bedInRange / isReachableBedBlock: within 3 blocks
// sideways and 2 vertically of the head or the foot.
let reachable = |bed: [i32; 3]| {
@@ -11180,7 +11096,6 @@ impl Game<'_> {
self.using = None;
self.sleeping = Some(head);
self.sleep_timer = 0;
self.occupy_bed(head);
self.stats.custom("sleep_in_bed", 1);
self.trigger_player("minecraft:slept_in_bed");
self.stats.reset("custom", "minecraft:time_since_rest");
@@ -11197,14 +11112,9 @@ impl Game<'_> {
fn tick_sleep(&mut self) {
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
if let Some(head) = self.sleeping {
// LivingEntity.tick's checkBedExists: a sleeper whose bed is
// gone gets up.
let bed = self.world.block(head[0], head[1], head[2]);
let bed_gone = !self.res.facts().get(bed).name.ends_with("_bed");
// Vanilla has no Shift wake-up: the chat screen's capitals must
// not get the sleeper up.
if (self.screen != Screen::Chat && self.keys.contains(&KeyCode::ShiftLeft))
|| bed_gone
|| self
.player
.as_ref()
@@ -11545,7 +11455,6 @@ impl Game<'_> {
let level = &mut self.level;
level.creative = self.creative;
level.game_mode = mode;
level.previous_game_mode = self.previous_mode.map(game_mode::mode_id_of);
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
level.difficulty =
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
@@ -13021,8 +12930,6 @@ impl ApplicationHandler for Game<'_> {
let d = scaled(raw_y) as f32;
if self.dialog_open() {
self.dialog_scroll(d);
} else if self.code_of_conduct.is_some() {
self.code_of_conduct_scroll(d);
} else if let Some(title) = &mut self.title
&& self.screen != Screen::Win
{
@@ -13098,11 +13005,6 @@ impl ApplicationHandler for Game<'_> {
self.link_prompt_click();
}
}
WindowEvent::MouseInput { state, button, .. } if self.code_of_conduct.is_some() => {
if state == ElementState::Pressed && button == MouseButton::Left {
self.code_of_conduct_click()?;
}
}
WindowEvent::MouseInput { state, button, .. }
if self.server_packs.prompt.is_some() =>
{
@@ -13365,8 +13267,6 @@ impl ApplicationHandler for Game<'_> {
self.answer_link_prompt(false);
}
}
// CodeOfConductScreen.shouldCloseOnEsc is false: no key leaves the screen.
WindowEvent::KeyboardInput { .. } if self.code_of_conduct.is_some() => {}
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
@@ -13806,14 +13706,6 @@ impl ApplicationHandler for Game<'_> {
event_loop.exit();
return;
}
// The world a backup made before its upgrade opens (EditWorldScreen's callback).
if let Some(folder) = self.title.as_mut().and_then(|t| t.take_open_request())
&& let Err(error) = self.open_world(&folder, None, None, false, None)
{
self.error = Some(error);
event_loop.exit();
return;
}
self.tick_server_packs();
self.poll_control(event_loop);
if self.pending_save.is_some() {
@@ -13915,7 +13807,6 @@ impl<'a> Game<'a> {
pending_pack_reload: false,
server_packs: Default::default(),
link_prompt: None,
code_of_conduct: None,
dialogs: Default::default(),
integrated_starting: None,
world_renderer_ready: false,
@@ -14093,7 +13984,6 @@ impl<'a> Game<'a> {
lightning_seen: [0; 2],
sleeping: None,
sleep_timer: 0,
player_beds: Vec::new(),
sleep_seen: (0, 0),
dim: Dim::Overworld,
world_root: PathBuf::new(),
@@ -14198,8 +14088,6 @@ impl<'a> Game<'a> {
frame_graph: Default::default(),
extras: Default::default(),
mods: Default::default(),
recipe_tables: Default::default(),
panel: Default::default(),
native_shaders: Default::default(),
sky_darken_memo: Default::default(),
persist: Default::default(),
+4 -187
View File
@@ -74,22 +74,6 @@ impl Toast {
(fully_for as f64 / (5000.0 * multiplier / n).max(1.0) % n) as usize
}
}
/// Where the player's fall, lava ride and Nether trip began, the blast that
/// last hit the player, and the effects the last tick saw (`ServerPlayer`'s
/// fields of the same names). A guest keeps their own, as `levitation` does.
#[derive(Clone, Debug, Default)]
pub(super) struct TravelMarks {
/// `startingToFallPosition`.
pub(super) fall: Option<[f64; 3]>,
/// `currentExplosionImpactPos`, and whether a wind charge made the blast.
pub(super) blast: Option<([f64; 3], bool)>,
/// `enteredLavaOnVehiclePosition`.
pub(super) lava_ride: Option<[f64; 3]>,
/// `enteredNetherPosition`.
pub(super) nether: Option<[f64; 3]>,
/// The (effect, amplifier) pairs `EffectsChangedTrigger` last saw.
pub(super) effects: Vec<(&'static str, i32)>,
}
#[derive(Default)]
pub(super) struct AdvState {
pub(super) tree: Arc<Tree>,
@@ -105,8 +89,6 @@ pub(super) struct AdvState {
pub(super) last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
pub(super) levitation: Option<([f64; 3], u64)>,
/// The fall, lava ride, Nether trip, blast and effects `tick_marks` follows.
pub(super) marks: TravelMarks,
/// While the game acts for a network player (`as_guest`): the
/// advancements whose progress changed (`progressChanged`), the recipes
/// unlocked and the announcements, for their client and everyone's.
@@ -711,7 +693,6 @@ impl Game<'_> {
if let Some((start, since)) = self.adv.levitation {
self.trigger_levitation(start, (self.ticks - since) as i64);
}
self.tick_marks();
if self.ticks.is_multiple_of(20) {
let me = self.player_entity();
self.trigger("minecraft:location", |c, w| {
@@ -801,162 +782,6 @@ impl Game<'_> {
&& adv::range_i(&c["duration"], duration)
});
}
/// `ServerPlayer.tick`: `trackStartFallingPosition` and the end of a fall
/// (`resetFallDistance`), `trackEnteredOrExitedLavaOnVehicle`, the way back
/// from the Nether, and `onEffectAdded`, `onEffectUpdated` and
/// `onEffectsRemoved`, which are seen as each tick ends.
fn tick_marks(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let falling = p.fall_distance > 0.0;
let dead = p.health.dead_or_dying();
let lava = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.is_some_and(|m| m.fluids.in_lava);
match (falling, self.adv.marks.fall) {
(true, None) => {
self.adv.marks.fall = Some(at);
// The blast a wind charge made is spent by the fall, and counts
// only if the player was above where it hit.
if let Some((impact, wind_charge)) = self.adv.marks.blast.take()
&& wind_charge
&& impact[1] <= at[1]
{
self.trigger_fall_after_explosion(impact);
}
}
// `resetFallDistance`: a fall ends by landing, climbing or water.
(false, Some(start)) => {
self.adv.marks.fall = None;
if !dead {
self.trigger_distance("minecraft:fall_from_height", start);
}
}
_ => {}
}
match (lava, self.adv.marks.lava_ride) {
(true, None) => self.adv.marks.lava_ride = Some(at),
(true, Some(start)) => self.trigger_distance("minecraft:ride_entity_in_lava", start),
(false, Some(_)) => self.adv.marks.lava_ride = None,
(false, None) => {}
}
// Back in the Overworld from the Nether: measured from where it was entered.
if let Some(start) = self.adv.marks.nether
&& self.dim == Dim::Overworld
{
self.adv.marks.nether = None;
self.trigger_distance("minecraft:nether_travel", start);
}
let effects: Vec<(&'static str, i32)> = self
.potions
.effects
.list
.iter()
.map(|e| (e.id, e.amplifier))
.collect();
if effects != self.adv.marks.effects {
self.adv.marks.effects = effects;
self.trigger_effects_changed(None);
}
}
/// `DistanceTrigger`: the player's `player` condition, `start_position`
/// at `start`, and the `distance` from `start` to the player.
fn trigger_distance(&mut self, name: &str, start: [f64; 3]) {
let me = self.player_entity();
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::location(&c["start_position"], start, w)
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
});
}
/// `FallAfterExplosionTrigger` for a wind charge's blast that hit the
/// player at `impact`, measured to where the player is now.
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3]) {
let me = self.player_entity();
let cause = EntityView {
kind: "minecraft:wind_charge".into(),
position: impact,
..EntityView::default()
};
self.trigger("minecraft:fall_after_explosion", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let cctx = adv::Entities {
this: Some(&cause),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::location(&c["start_position"], impact, w)
&& (c["distance"].is_null() || adv::distance(&c["distance"], impact, me.position))
&& adv::conditions(&c["cause"], &cctx, w)
});
}
/// `EffectsChangedTrigger`: the player's `effects`, and for an effect that
/// `source` gave, that entity (`source`).
pub(super) fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
let me = self.player_entity();
self.trigger("minecraft:effects_changed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let effects = c["effects"].is_null()
|| adv::entity(
&serde_json::json!({ "minecraft:effects": c["effects"] }),
&me,
w,
me.position,
);
let source = c["source"].is_null()
|| source.as_ref().is_some_and(|s| {
let sctx = adv::Entities {
this: Some(s),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["source"], &sctx, w)
});
adv::conditions(&c["player"], &pctx, w) && effects && source
});
}
/// `ItemDurabilityTrigger`: a player's item takes damage (`ItemStack.applyDamage`),
/// `stack` as it is before the damage. The durability and delta ranges are not
/// read: no advancement in the data uses them.
pub(super) fn trigger_item_durability(&mut self, stack: &ItemStack) {
if self.remote.is_some() || self.no_player_here() {
return;
}
let me = self.player_entity();
let item = item_view(stack);
self.trigger("minecraft:item_durability_changed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& (c["item"].is_null() || adv::item(&c["item"], &item, w))
});
}
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
/// what the player looks at (`PlayerPredicate.lookingAt`).
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
@@ -1408,20 +1233,12 @@ impl Game<'_> {
&& test("child", child.as_ref())
});
}
/// `ChangeDimensionTrigger`, and `ServerPlayer.enteredNetherPosition`: the
/// Overworld position the player went into the Nether from (`at`) is kept
/// for the way back, and any other trip drops it.
pub(super) fn trigger_dimension(&mut self, from: Dim, to: Dim, at: [f64; 3]) {
let (from_id, to_id) = (from.id().to_owned(), to.id().to_owned());
/// `ChangeDimensionTrigger`.
pub(super) fn trigger_dimension(&mut self, from: Dim, to: Dim) {
let (from, to) = (from.id().to_owned(), to.id().to_owned());
self.trigger("minecraft:changed_dimension", move |c, _| {
c["from"].as_str().is_none_or(|f| f == from_id)
&& c["to"].as_str().is_none_or(|t| t == to_id)
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
});
if from == Dim::Overworld && to == Dim::Nether {
self.adv.marks.nether = Some(at);
} else if !(from == Dim::Nether && to == Dim::Overworld) {
self.adv.marks.nether = None;
}
}
/// `ToastManager.SLOT_COUNT`.
pub(super) const TOAST_SLOTS: usize = 5;
-3
View File
@@ -597,9 +597,6 @@ impl Game<'_> {
Mode::Delete { world } => {
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
}
Mode::BackupBeforeUpgrade { world } => {
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
}
Mode::Error { message, .. } => {
json!({ "mode": "world-operation-error", "message": message })
}
+4 -29
View File
@@ -2,7 +2,7 @@
//! falling, `interactAt` equipment swaps by clicked height, the two-hit break
//! of `hurtServer` (creative breaks at once), and `ArmorStandRenderer` with
//! the default pose, the hit wobble and humanoid armor layers.
use super::Game;
use super::{Game, view_vector};
use horizon_client::{
entities::ArmorStand,
items::ItemStack,
@@ -153,13 +153,7 @@ impl Game<'_> {
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
let e = &m.extra;
self.armor_layers(
[
(e.head.as_deref(), e.armor[0].as_ref()),
(e.chest.as_deref(), e.armor[1].as_ref()),
(e.legs.as_deref(), e.armor[2].as_ref()),
(e.feet.as_deref(), e.armor[3].as_ref()),
]
.map(|(item, stack)| item.map(|i| (i, stack))),
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
)
}
/// The layers of the armour items `worn` (with their stacks when known,
@@ -226,7 +220,6 @@ impl Game<'_> {
.armor
.iter()
.chain(guests.flat_map(|g| g.armor.iter()))
.chain(self.mobs.iter().flat_map(|m| m.extra.armor.iter()))
.zip(
["helmet", "chestplate", "leggings", "boots"]
.into_iter()
@@ -374,7 +367,8 @@ impl Game<'_> {
}
/// The armor stand under the crosshair, its distance and the hit point.
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, a) in self.armor_stands.iter().enumerate() {
@@ -510,25 +504,6 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
// The stand's box and id, before the hit may remove it.
let (area, id) = (self.armor_stands[i].bounds(), self.armor_stands[i].id);
let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true);
let hurt = self.punch_armor_stand(i);
if hurt {
self.attack_effects(
area,
Some(id as i32),
super::mace::Spared::Stand(id),
magic.then_some(id as i32),
);
self.swing_arm();
}
hurt
}
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
/// creative player breaks it, otherwise the first hit wobbles it and a
/// second within five ticks breaks it.
pub(super) fn punch_armor_stand(&mut self, i: usize) -> bool {
let time = self.world.game_time;
let at = self.armor_stands[i].position;
let at = [at.x, at.y, at.z];
-97
View File
@@ -1,97 +0,0 @@
//! Beds' `occupied` (`BedBlock.OCCUPIED`): the head of a bed someone sleeps
//! in, which the foot copies (`updateShape`). `LivingEntity.startSleeping`
//! sets it and `stopSleeping` clears it. A villager's come with its mob
//! events (`MobEvent::BedOccupied`); a player's bed is set when it lies
//! down (`try_sleep`) and freed in the level's tick once nobody sleeps in it
//! any more, whichever way they got up: waking, the Leave Bed button, death,
//! a broken bed, or leaving the game.
use super::{Dim, Game};
use horizon_client::Result;
impl Game<'_> {
/// `setBlockAndUpdate(head, state.setValue(OCCUPIED, occupied))`, when
/// the block at `head` is still a bed and not already so.
pub(super) fn set_bed_occupied(&mut self, head: [i32; 3], occupied: bool) {
let registry = &self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(head[0], head[1], head[2]))
.cloned()
else {
return;
};
let value = if occupied { "true" } else { "false" };
if state.properties.get("occupied").is_none_or(|v| v == value) {
return;
}
state.properties.insert("occupied".into(), value.into());
let Some(id) = registry.id(&state) else {
return;
};
if let Err(e) = self.level_set(head, id) {
log::warn!("could not mark the bed at {head:?} occupied={value}: {e}");
}
}
/// `LivingEntity.startSleeping` for the player in place (the host, or
/// a guest during its turn): the bed it lies in is occupied.
pub(super) fn occupy_bed(&mut self, head: [i32; 3]) {
self.set_bed_occupied(head, true);
if !self.player_beds.contains(&(self.dim, head)) {
self.player_beds.push((self.dim, head));
}
}
/// Whether a player in the level in place still sleeps in `head`.
fn player_in_bed(&self, head: [i32; 3]) -> bool {
self.sleeping == Some(head)
|| self.lan.as_ref().is_some_and(|h| {
h.hub
.guests
.iter()
.any(|g| g.bed == Some(head) && g.dimension == self.dim.id())
})
}
/// `LivingEntity.stopSleeping` for the players who got up: the beds of
/// this level nobody sleeps in any more are free again. Runs in each
/// level's tick, after the players' packets and sleep ticks, never inside
/// a guest's turn (where the hub is taken out of `Game`).
pub(super) fn free_left_beds(&mut self) {
let dim = self.dim;
let left: Vec<[i32; 3]> = self
.player_beds
.iter()
.filter(|(d, head)| *d == dim && !self.player_in_bed(*head))
.map(|(_, head)| *head)
.collect();
for head in left {
self.player_beds.retain(|b| *b != (dim, head));
self.set_bed_occupied(head, false);
}
}
/// The levels with a bed a player marked occupied: they tick until the
/// bed is freed.
pub(super) fn player_bed_levels(&self) -> impl Iterator<Item = Dim> + '_ {
self.player_beds.iter().map(|(d, _)| *d)
}
/// `PlayerList.removeAll` before the server's last save: every player
/// leaves (`ServerPlayer.disconnect` stops its sleep), so no bed is
/// saved occupied by a player who is not in it when the world opens
/// again. Each bed is freed in its own level.
pub(super) fn free_player_beds(&mut self) -> Result<()> {
if self.player_beds.is_empty() {
return Ok(());
}
let back = self.dim;
for (dim, head) in std::mem::take(&mut self.player_beds) {
self.enter_level(None, dim)?;
// The in-frame game has only the level it is in.
if self.dim == dim {
self.set_bed_occupied(head, false);
}
}
self.enter_level(None, back)
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
use super::Game;
use horizon_client::{
Result,
beehive::{self, Beehive, BeehiveExt, HiveHost, Release},
beehive::{self, Beehive, HiveHost, Release},
containers::BlockEntity,
items::ItemStack,
mobs::{Mob, Who, bee},
+6 -8
View File
@@ -664,9 +664,10 @@ impl Game<'_> {
if !self.boat_models.has(wood, chest) {
return false;
}
let Some((eye, dir)) = self.aim() else {
let Some(eye) = self.eye_position() else {
return false;
};
let dir = view_vector(self.yaw, self.pitch);
let reach = self.reach();
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
// block along the ray.
@@ -743,7 +744,8 @@ impl Game<'_> {
true
}
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64)> = None;
for (i, b) in self.boats.iter().enumerate() {
@@ -809,19 +811,15 @@ impl Game<'_> {
true
}
/// `VehicleEntity.hurtServer` from the player's attack.
pub(super) fn attack_boat(&mut self) -> bool {
pub(super) fn attack_boat(&mut self, damage: f32) -> bool {
let Some((i, d)) = self.boat_hit() else {
return false;
};
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
let area = self.boats[i].bounds();
// Player.attack's full damage against the boat's entity type.
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
let (damage, _, _, magic) = self.melee_damage(&kind, false);
self.vehicle_attack_sounds();
self.hurt_boat(i, damage, self.creative);
self.vehicle_attack_sounds(area, magic.then_some(id));
self.swing_arm();
true
}
@@ -155,7 +155,7 @@ impl MapData {
m.unlimited = self.unlimited;
m
}
/// `MapItemSavedData.locked`: the same pixels and banner markers, locked.
/// `MapItemSavedData.locked`: the same pixels, locked.
pub(super) fn locked_copy(&self) -> MapData {
let mut m = MapData::fresh(
f64::from(self.center[0]),
@@ -165,7 +165,6 @@ impl MapData {
);
m.center = self.center;
m.colors = self.colors.clone();
m.banners = self.banners.clone();
m.tracking = self.tracking;
m.unlimited = self.unlimited;
m.locked = true;
@@ -1,278 +0,0 @@
//! A server's Code of Conduct bound to the game (`ClientConfigurationPacketListenerImpl.
//! handleCodeOfConduct` and `CodeOfConductScreen`): the text in a scroll area,
//! the "do not notify again" box, Acknowledge and Disconnect, and the server
//! list entry that keeps the accepted text. The server waits in configuration
//! until the answer, so the connection stays open while the screen shows.
use super::{Game, Screen, server_packs::saves};
use horizon_client::{
Result,
dialog_screen::BOX,
font::Font,
hud::{HudBatch, rgba},
multiplayer,
text::Text,
ui,
};
use horizon_core::random::java_string_hash;
use horizon_protocol::servers;
/// The text's line height.
const LINE: f32 = 9.0;
/// The footer's button: `Button.DEFAULT_WIDTH` and the row's spacing of 8.
const BUTTON: (f32, f32) = (150.0, 20.0);
const GAP: f32 = 8.0;
/// The text box's cell padding (`s.padding(12)`) and the text's padding inside it.
const MARGIN: f32 = 12.0;
const PAD: f32 = 4.0;
/// Narrowest column: the two buttons and their gap.
const COLUMN: f32 = 2.0 * BUTTON.0 + GAP;
/// An open `CodeOfConductScreen`.
pub(super) struct Prompt {
text: String,
/// `text.hashCode()`: what the server list keeps once it is accepted.
hash: i32,
/// `stopShowing`: the box is ticked.
stop_showing: bool,
scroll: ui::Scroll,
}
/// Where the screen's parts go (`WarningScreen`: title, the text box with 12 px
/// around it, the checkbox, then the buttons, 8 px apart): the title and the
/// wrapped text in its box (`area`: x, y, w, h), then the check and the
/// two buttons.
struct Layout {
title: (Text, f32, f32),
lines: Vec<Text>,
area: [f32; 4],
/// The text's full height with its padding: more than the area's scrolls.
content: f32,
check: [f32; 4],
label: Text,
buttons: [[f32; 4]; 2],
}
impl Prompt {
fn layout(&self, font: &Font, (w, h): (f32, f32)) -> Layout {
let title = Text::translate("multiplayer.codeOfConduct.title", &[]).bold();
let label = Text::translate("multiplayer.codeOfConduct.check", &[]);
// `FittingMultiLineTextWidget`: `width - 100` wide, `height - 100` at
// most, the text 4 px inside.
let col = (w - 100.0).max(COLUMN);
let lines: Vec<Text> = font
.split_lines(&Text::literal(self.text.as_str()), col - 2.0 * PAD)
.into_iter()
.map(|(line, _)| line)
.collect();
let content = LINE * lines.len() as f32 + 2.0 * PAD;
let box_h = content.min((h - 100.0).max(LINE + 2.0 * PAD));
let fixed = LINE + GAP + MARGIN + MARGIN + GAP + BOX + GAP + BUTTON.1;
let total = fixed + box_h;
let ly = ((h - total) / 2.0).trunc();
let cell = |cw: f32| ((w - cw) / 2.0).trunc();
let area_y = ly + LINE + GAP + MARGIN;
let check_y = area_y + box_h + MARGIN + GAP;
let check_w = BOX + 4.0 + font.width_text(&label);
let button_y = check_y + BOX + GAP;
let bx = cell(COLUMN);
Layout {
title: (title.clone(), cell(font.width_text(&title)), ly),
lines,
area: [cell(col), area_y, col, box_h],
content,
check: [cell(check_w), check_y, check_w, BOX],
label,
buttons: [
[bx, button_y, BUTTON.0, BUTTON.1],
[bx + BUTTON.0 + GAP, button_y, BUTTON.0, BUTTON.1],
],
}
}
}
impl Game<'_> {
/// The server list entry of the joined address, if it has one.
fn accepted_code_of_conduct(&self) -> Option<i32> {
let address = self.server_packs.address.as_deref()?;
servers::load(&multiplayer::servers_file(&saves()))
.into_iter()
.find(|s| s.ip == address)
.map(|s| s.accepted_code_of_conduct)
}
/// `ServerList.saveSingleServer`: the joined address's entry keeps `accepted`.
fn store_code_of_conduct(&self, accepted: i32) {
let Some(address) = &self.server_packs.address else {
return;
};
let file = multiplayer::servers_file(&saves());
let mut list = servers::load(&file);
let mut found = false;
for entry in list.iter_mut().filter(|s| &s.ip == address) {
entry.accepted_code_of_conduct = accepted;
found = true;
}
if found && let Err(e) = servers::save(&file, &list) {
log::warn!("Horizon: could not save {}: {e}", file.display());
}
}
/// `handleCodeOfConduct`: text accepted before for this server is
/// accepted at once; otherwise the screen opens over the connecting one.
pub(super) fn handle_code_of_conduct(&mut self, text: String) {
let Some(r) = &self.remote else {
return;
};
let hash = java_string_hash(text.encode_utf16());
if self.accepted_code_of_conduct() == Some(hash) {
r.session.accept_code_of_conduct();
return;
}
self.capture(false);
self.code_of_conduct = Some(Prompt {
text,
hash,
stop_showing: false,
scroll: ui::Scroll::default(),
});
}
/// `CodeOfConductScreen.onResult`: accepting tells the server, declining
/// ends the connection. The entry keeps the text only when it was
/// accepted with the box ticked, else the stored text is cleared.
fn answer_code_of_conduct(&mut self, accepted: bool) -> Result<()> {
let Some(prompt) = self.code_of_conduct.take() else {
return Ok(());
};
if accepted {
if let Some(r) = &self.remote {
r.session.accept_code_of_conduct();
}
if self.player.is_some() && self.screen == Screen::Playing {
self.capture(true);
}
}
let kept = if accepted && prompt.stop_showing {
prompt.hash
} else {
0
};
self.store_code_of_conduct(kept);
if accepted {
return Ok(());
}
let reason = Text::translate("multiplayer.disconnect.code_of_conduct", &[]);
self.leave_server(Some(reason.plain()))
}
pub(super) fn draw_code_of_conduct(&self, batch: &mut HudBatch) {
let Some(prompt) = &self.code_of_conduct else {
return;
};
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
if self.player.is_none() {
// In place of the connecting screen, on its background.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
}
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
let layout = prompt.layout(&atlas.font, (w, h));
let cursor = self.cursor;
let [ax, ay, aw, ah] = layout.area;
let offset = prompt
.scroll
.amount
.clamp(0.0, (layout.content - ah).max(0.0));
let (title, tx, ty) = &layout.title;
batch.text_styled(atlas, title, *tx, *ty, u32::MAX, true);
ui::nine_slice(
batch,
atlas,
"gui/sprites/widget/text_field",
ax,
ay,
aw,
ah,
u32::MAX,
);
let start = batch.vertices.len();
for (i, line) in layout.lines.iter().enumerate() {
let y = ay + PAD + i as f32 * LINE - offset;
batch.text_styled(atlas, line, ax + PAD, y, u32::MAX, true);
}
batch.clip_since(start, ax + 1.0, ay + 1.0, ax + aw - 1.0, ay + ah - 1.0);
if layout.content > ah {
// AbstractTextAreaWidget.scrollBarX: the box's right edge.
ui::scrollbar(batch, atlas, ax + aw, ay, ay + ah, layout.content, offset);
}
let [x, y, cw, ch] = layout.check;
let hover = ui::hovered(cursor, x, y, cw, ch);
let sprite = match (prompt.stop_showing, hover) {
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
(true, false) => "gui/sprites/widget/checkbox_selected",
(false, true) => "gui/sprites/widget/checkbox_highlighted",
(false, false) => "gui/sprites/widget/checkbox",
};
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
batch.text_styled(
atlas,
&layout.label,
x + BOX + 4.0,
y + 4.0,
rgba(224, 224, 224, 255),
true,
);
let labels = [
Text::translate("gui.acknowledge", &[]),
Text::translate("menu.disconnect", &[]),
];
for ([x, y, bw, bh], label) in layout.buttons.into_iter().zip(&labels) {
let hover = ui::hovered(cursor, x, y, bw, bh);
ui::button(batch, atlas, x, y, bw, bh, label, true, hover);
}
}
/// A left click: the box toggles, a button answers.
pub(super) fn code_of_conduct_click(&mut self) -> Result<()> {
let size = self.gui_size();
let cursor = self.cursor;
let Some(prompt) = self.code_of_conduct.as_mut() else {
return Ok(());
};
let layout = prompt.layout(&self.gui.atlas.font, size);
let [x, y, w, h] = layout.check;
let toggled = ui::hovered(cursor, x, y, w, h);
if toggled {
prompt.stop_showing = !prompt.stop_showing;
}
let answer = layout
.buttons
.iter()
.position(|&[x, y, w, h]| ui::hovered(cursor, x, y, w, h))
.map(|i| i == 0);
if !toggled && answer.is_none() {
return Ok(());
}
self.audio.play_button_click();
match answer {
Some(accepted) => self.answer_code_of_conduct(accepted),
None => Ok(()),
}
}
/// The mouse wheel over the screen: the text scrolls 10 a step.
pub(super) fn code_of_conduct_scroll(&mut self, delta: f32) {
let size = self.gui_size();
let Some(prompt) = self.code_of_conduct.as_mut() else {
return;
};
let layout = prompt.layout(&self.gui.atlas.font, size);
let [_, ay, _, ah] = layout.area;
prompt
.scroll
.wheel(delta, 10.0, ay, ay + ah, layout.content);
}
}
-3
View File
@@ -51,9 +51,6 @@ pub(super) struct CombatTracker {
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
pub(super) player_attacker: Option<([f64; 3], f32)>,
/// While set, a sweep attack's reach to the other players is kept here
/// instead of landing, for the caller that holds the LAN host.
pub(super) sweeps: Option<Vec<super::lan_pvp::Sweep>>,
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
+1 -2
View File
@@ -1376,8 +1376,7 @@ impl Game<'_> {
self.player = Some(player);
return Err(err("command.failed", &[]));
}
let at = player.position;
self.trigger_dimension(from, dimension, [at.x, at.y, at.z]);
self.trigger_dimension(from, dimension);
let target = Vec3::new(pos[0], pos[1], pos[2]);
player.position = target;
player.velocity = Vec3::ZERO;
@@ -64,17 +64,6 @@ pub(in crate::game) struct ExecState {
pub function_depth: u32,
}
/// A modifier that brings the sources to `max_command_forks` ends the chain
/// (`BuildContexts.ERROR_FORK_LIMIT_REACHED`); the error is only shown when
/// no fork came before it (`CommandSourceStack.handleError`).
fn fork_limit_reached(forked: bool, limit: usize) -> R<i32> {
if forked {
Ok(0)
} else {
Err(err("command.forkLimit", &[&limit]))
}
}
/// Java's `(int)`/`(short)`/`(byte)` narrowing of a double.
fn java_int(v: f64) -> i32 {
v as i32
@@ -314,8 +303,6 @@ impl Game<'_> {
/// ExecuteCommand: `ctx` is the context holding the `execute` literal.
pub(in crate::game) fn run_execute(&mut self, ctx: &Context) -> R<i32> {
// BuildContexts: a modifier may not bring the sources to this many.
let fork_limit = self.ui.rules.int("max_command_forks") as usize;
let mut sources = vec![self.command_source.unwrap_or_else(|| self.player_source())];
let mut c = ctx;
// A fork modifier (as, at, positioned as, rotated as, facing entity,
@@ -367,9 +354,6 @@ impl Game<'_> {
let mut kept = Vec::new();
for s in sources {
let (keep, _) = self.with_source(s, |g| g.condition(c, want))?;
if kept.len() + usize::from(keep) >= fork_limit {
return fork_limit_reached(forked, fork_limit);
}
if keep {
kept.push(s);
}
@@ -385,11 +369,7 @@ impl Game<'_> {
};
let mut next = Vec::new();
for s in sources {
let new = self.with_source(s, |g| g.modify(word, c, s))?;
if next.len() + new.len() >= fork_limit {
return fork_limit_reached(forked, fork_limit);
}
next.extend(new);
next.extend(self.with_source(s, |g| g.modify(word, c, s))?);
}
sources = next;
}
@@ -8,10 +8,6 @@ use horizon_client::{
command::CommandError,
text::{self, Style, Text},
};
use horizon_protocol::packets::{
Empty,
play::{self, Clientbound as C, DialogHolder},
};
use std::{collections::HashMap, time::Instant};
/// Waypoint.Icon: the locator bar style and colour of a transmitter.
@@ -47,6 +43,8 @@ pub(in crate::game) struct CommandExtras {
pub jfr: Option<(Instant, u64, String)>,
/// Waypoint icons changed by `/waypoint modify`.
pub waypoint_icons: HashMap<Target, WaypointIcon>,
/// The dialog `/dialog show` opened (its id or inline definition).
pub dialog: Option<String>,
/// `/datapack disable vanilla` took the built-in pack out of the selection.
pub vanilla_disabled: bool,
/// The waypoint manager's tracked transmitters (None until first used).
@@ -432,9 +430,9 @@ impl Game<'_> {
let Some(Arg::Resource(dialog)) = ctx.get("dialog").cloned() else {
return Ok(0);
};
self.show_dialog_to(&targets, &dialog);
self.open_dialog(&dialog)?;
} else {
self.clear_dialog_to(&targets);
self.close_dialog();
}
let (single, multiple) = if id == "dialog.show" {
(
@@ -1041,77 +1039,15 @@ impl Game<'_> {
}
impl Game<'_> {
/// `ServerPlayer.openDialog` for every target of `/dialog show`: a guest
/// is sent `ClientboundShowDialogPacket`, the window's own player opens
/// the dialog on its screen.
fn show_dialog_to(&mut self, targets: &[Target], dialog: &str) {
let Some(holder) = self.dialog_holder(dialog) else {
return;
};
let packet = C::ShowDialog(play::ShowDialog {
dialog: holder.clone(),
});
for &t in targets {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => self.show_play_dialog(&holder),
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
/// `ServerPlayer.openDialog`: the dialog screen for a dialog id or inline
/// definition.
pub(in crate::game) fn open_dialog(&mut self, dialog: &str) -> R<()> {
self.cmds.dialog = Some(dialog.to_owned());
Ok(())
}
/// `ServerPlayer.closeDialog` for every target of `/dialog clear`.
fn clear_dialog_to(&mut self, targets: &[Target]) {
let packet = C::ClearDialog(Empty);
for &t in targets {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => self.clear_dialog(),
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
}
/// `ServerPlayer.openDialog`'s dialog: an entry of the `minecraft:dialog`
/// registry, or the dialog an inline `ResourceOrIdArgument` carries.
fn dialog_holder(&self, dialog: &str) -> Option<DialogHolder> {
let text = dialog.trim();
if text.starts_with('{') {
return horizon_client::command::snbt::parse_fully(text)
.ok()
.map(DialogHolder::Direct);
}
// A registry id, written with or without its namespace.
let id = if text.contains(':') {
text.to_owned()
} else {
format!("minecraft:{text}")
};
let index = self
.ui
.registries
.ids("minecraft:dialog")
.iter()
.position(|name| *name == id);
match index {
Some(i) => Some(DialogHolder::Registry(i as i32)),
None => {
log::warn!("Horizon: no dialog {id} in the registry");
None
}
}
/// `ClientboundClearDialogPacket`: close the dialog screen.
pub(in crate::game) fn close_dialog(&mut self) {
self.cmds.dialog = None;
}
}
impl Game<'_> {
+11 -139
View File
@@ -460,9 +460,6 @@ impl Game<'_> {
others_grid: None,
others_index: None,
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
// day.json: false from tick 12542, true from 23460 (a step track).
monsters_burn: self.dim == Dim::Overworld
&& !(12542..23460).contains(&self.world.day_time.rem_euclid(24000)),
game_time: self.world.game_time,
day_time: self.world.day_time,
raining: weather && rain > 0.2,
@@ -800,12 +797,6 @@ impl Game<'_> {
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
// LivingEntity.die: a sleeper gets up, and its bed is free again.
if let Some(bed) = horizon_client::mobs::villager::sleeping_pos(m) {
self.wake_villager(m.id);
// The body may be gone already.
self.set_bed_occupied(bed, false);
}
let killer = self.last_hurt_by_player.remove(&m.id);
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
let mut dying = m.clone();
@@ -1179,20 +1170,13 @@ impl Game<'_> {
volume,
spread,
} => {
// WitherBoss.customServerAiStep: the spawn roar is global
// level event 1023 at the block position.
if sound == "entity.wither.spawn" {
let pos = at.map(|v| v.floor() as i32);
self.global_level_event(1023, pos, 0);
} else {
// The client's own level random: nothing drawn from the server's.
let mut r = horizon_core::random::LegacyRandom::new(
self.world.game_time.wrapping_mul(341_873_128_712),
);
let pitch = (r.next_float() - r.next_float()) * spread + 1.0;
self.audio
.play(&format!("minecraft:{sound}"), Some(at), volume, pitch)
}
// The client's own level random: nothing drawn from the server's.
let mut r = horizon_core::random::LegacyRandom::new(
self.world.game_time.wrapping_mul(341_873_128_712),
);
let pitch = (r.next_float() - r.next_float()) * spread + 1.0;
self.audio
.play(&format!("minecraft:{sound}"), Some(at), volume, pitch)
}
MobEvent::SoundId(name, at, volume, pitch) => {
self.audio.play(&name, Some(at), volume, pitch)
@@ -1489,7 +1473,6 @@ impl Game<'_> {
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
MobEvent::BedOccupied { pos, occupied } => self.set_bed_occupied(pos, occupied),
MobEvent::SetDoorOpen { pos, open } => {
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
@@ -1660,7 +1643,7 @@ impl Game<'_> {
}
}
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
MobEvent::AxolotlSupportPlayer { mob } => {
MobEvent::AxolotlSupportPlayer => {
let previous = self
.potions
.effects
@@ -1679,12 +1662,6 @@ impl Game<'_> {
.effects
.list
.retain(|e| e.id != "mining_fatigue");
let source = self
.mobs
.iter()
.find(|m| m.id == mob)
.map(|m| self.mob_entity(m));
self.trigger_effects_changed(source);
}
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
@@ -2658,53 +2635,6 @@ impl Game<'_> {
}
}
}
/// `onProjectileHit` of chorus flowers and decorated pots: an impact
/// projectile (`#impact_projectiles`) breaks the block when
/// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes
/// the `projectiles_can_break_blocks` rule and a shooter that may break
/// blocks (`canBreakBlockInAdventureMode`: never a mob, never an
/// adventure player). A pot is cracked first (flags 260), so it shatters.
fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) {
use horizon_client::command::args::GameMode;
let impact = matches!(
p.kind,
ProjectileKind::Arrow { .. }
| ProjectileKind::Trident
| ProjectileKind::Snowball
| ProjectileKind::Egg
| ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. }
| ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
);
let shooter_may_break = match p.owner {
Some(Who::Player) => self.mode != GameMode::Adventure,
Some(Who::Mob(_)) => false,
None => true,
};
if !impact || !shooter_may_break || !self.ui.rules.bool("projectiles_can_break_blocks") {
return;
}
let state = self.world.block(pos[0], pos[1], pos[2]);
let at = BlockPos::new(pos[0], pos[1], pos[2]);
match &*self.res.facts().get(state).name {
"minecraft:chorus_flower" => {}
"minecraft:decorated_pot" => {
let registry = &self.pack.assets.registry;
let Some(mut pot) = registry.state(state).cloned() else {
return;
};
pot.properties.insert("cracked".into(), "true".into());
let Ok(cracked) = registry.resolve(&pot) else {
return;
};
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, cracked, 260));
}
_ => return,
}
let _ = self.destroy_block_dropping(pos);
}
/// What an arrow's tick asked for: its particles and sounds.
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
@@ -2869,7 +2799,6 @@ impl Game<'_> {
&& face != [0; 3]
{
self.bell_projectile_hit(p, pos, at, face);
self.projectile_breaks_block(p, pos);
}
self.combat.projectile_owner = match p.owner {
Some(Who::Mob(id)) => Some(id),
@@ -3095,56 +3024,7 @@ impl Game<'_> {
.find(|m| m.id == id)
.map_or(0.0, |m| m.attrs.knockback_resistance),
};
let knockback = if !hurt {
0.0
} else if let Some(weapon) =
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref())
{
let victim = match who {
Who::Player => Some("minecraft:player"),
Who::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.as_str()),
};
let owner = match p.owner {
Some(Who::Player) => Some("minecraft:player"),
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.as_str()),
None => None,
};
let arrow_type = if p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
{
"minecraft:spectral_arrow"
} else {
"minecraft:arrow"
};
let ctx = horizon_client::enchant::EffectContext {
this: victim,
attacker: owner,
direct_attacker: Some(arrow_type),
damage_type: Some("minecraft:arrow"),
items: Some(self.res.items()),
..Default::default()
};
self.res.stations().enchantments.modify_value(
Some(weapon),
"minecraft:knockback",
&ctx,
&mut self.world.random,
0.0,
)
} else {
p.bonus.punch as f32
};
let push = f64::from(knockback) * 0.6 * (1.0 - resistance).max(0.0);
let push = f64::from(p.bonus.punch) * 0.6 * (1.0 - resistance).max(0.0);
if hurt && push > 0.0 && flight > 1e-6 {
let k = push / flight;
self.handle_mob_events(vec![MobEvent::Push {
@@ -4236,7 +4116,7 @@ impl Game<'_> {
.get(&stack.item)
.map_or(0, |i| i.max_damage);
if max > 0 {
let loss = self.wear(&stack, 16);
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;
@@ -4275,10 +4155,7 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return Ok(false);
};
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
// last moved, not a velocity the server kept for a guest.
let on_ground = p.ground.on_ground;
let velocity = self.known_player_movement();
let (velocity, on_ground) = (p.velocity, p.ground.on_ground);
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.shoot_from_rotation(
@@ -4863,7 +4740,6 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Explosion => "minecraft:explosion",
K::Fall => "minecraft:fall",
K::FallingBlock => "minecraft:falling_block",
K::FallingStalactite => "minecraft:falling_stalactite",
K::OnFire => "minecraft:on_fire",
K::InFire => "minecraft:in_fire",
K::Lava => "minecraft:lava",
@@ -4899,7 +4775,6 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
components: Vec::new(),
}
}
/// A generated loot stack as a Horizon item stack.
@@ -4922,9 +4797,6 @@ fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
for (id, text) in &stack.names {
item.data_mut().set_loot_name(id, text);
}
for (id, value) in &stack.components {
item.data_mut().set_loot_component(id, value);
}
item
}
impl Game<'_> {
+3 -2
View File
@@ -6,7 +6,7 @@
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
//! only an entity whose `Pos` is the click point's height. Placement uses
//! outline bounds, collision shapes and suffocation predicates.
use super::{Game, Hit};
use super::{Game, Hit, view_vector};
use horizon_client::entities::{Cushion, dye_index};
use horizon_client::items::ItemStack;
use horizon_client::mobs::anim::Poses;
@@ -107,7 +107,8 @@ impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
impl Game<'_> {
/// The cushion under the crosshair, its distance and the hit point.
pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, c) in self.cushions.iter().enumerate() {
+1 -4
View File
@@ -299,14 +299,13 @@ impl Lists {
}
/// The world presets of 26.3's `worldgen/world_preset`.
const WORLD_PRESETS: [&str; 7] = [
const WORLD_PRESETS: [&str; 6] = [
"minecraft:normal",
"minecraft:flat",
"minecraft:large_biomes",
"minecraft:amplified",
"minecraft:single_biome_surface",
"minecraft:debug_all_block_states",
"minecraft:flat_all_dimensions",
];
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
@@ -1376,8 +1375,6 @@ impl Game<'_> {
}
std::thread::sleep(Duration::from_millis(100));
log("INFO", "Saving worlds");
// ServerPlayer.disconnect: nobody is left in a bed.
self.free_player_beds()?;
self.save_game()?;
self.log_saved_levels();
self.lan_close(false);
+2 -1
View File
@@ -51,7 +51,8 @@ impl Game<'_> {
/// The redirectable projectile under the crosshair within the attack
/// reach, and how far along the view it is.
pub(super) fn redirectable_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
self.creatures
.projectiles
@@ -15,10 +15,7 @@ use horizon_client::{
gamerules::Value as RuleValue,
item_data::{canon_stack, parse_stack},
items::ItemStack,
mobs::{
Mob, Species,
spawner::{SpawnDataExt, SpawnerExt},
},
mobs::{Mob, Species},
motion::PlayerMotion,
projectiles::ProjectileKind,
world::World,
+19 -21
View File
@@ -912,9 +912,8 @@ impl Game<'_> {
// The blast's damage source carries the player, so the dragon
// remembers them before the blast can kill it.
self.fight.blast_by = by;
// EndCrystal.hurtServer: ExplosionInteraction.BLOCK.
let interaction = self.destroy_type("block_explosion_drop_decay");
let _ = self.server_explode(at, 6.0, interaction, false);
let _ =
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
self.fight.blast_by = None;
}
if respawn_crystal {
@@ -1089,7 +1088,8 @@ impl Game<'_> {
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(u32, f64)> = None;
for (id, b) in self.special_targets() {
@@ -1122,21 +1122,22 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
// A dragon part is no LivingEntity: `canCriticalAttack` is false.
let (amount, _, _, _) = self.melee_damage("minecraft:ender_dragon", false);
self.swing_arm();
// The part's box.
let area = self
.special_targets()
.into_iter()
.find(|(part, _)| *part == id)
.map(|(_, b)| b);
let hurt = self.hit_special(id, amount, true, false);
// The sweep and the sounds read the charge, which the hit then spends.
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
let mut amount =
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
let falling = self
.player
.as_ref()
.is_some_and(|p| !p.ground.on_ground && p.velocity.y < 0.0 && !p.sprinting);
if scale > 0.9 && falling {
amount *= 1.5;
}
amount += self.enchant_damage_bonus("minecraft:ender_dragon") * scale;
self.attack_ticks = 0;
self.swing_arm();
self.hit_special(id, amount, true, false);
true
}
/// The 24 nodes (`findClosestNode` builds them on first use).
@@ -2103,7 +2104,6 @@ impl Game<'_> {
75,
(96.0 * a.sin()).floor() as i32,
];
self.level_event_at(3000, pos, 0);
let _ = self.place_gateway_at(pos);
}
if !self.fight.previously_killed
@@ -3221,12 +3221,10 @@ impl Game<'_> {
}
}
}
// DragonRespawnStage.SUMMONING_PILLARS: ExplosionInteraction.BLOCK.
let interaction = self.destroy_type("block_explosion_drop_decay");
let _ = self.server_explode(
[f64::from(cx) + 0.5, f64::from(h), f64::from(cz) + 0.5],
5.0,
interaction,
super::explosion::BlockInteraction::Destroy,
false,
);
// EndSpikeFeature.placeSpike with crystal_invulnerable and the beam.
@@ -71,24 +71,10 @@ impl Game<'_> {
)
.max(0.0) as u32
}
/// `ItemStack.hurtAndBreak` for the player's `stack`: the wear Unbreaking
/// leaves, and `ItemDurabilityTrigger` when some is left (`applyDamage`).
pub(super) fn wear(&mut self, stack: &ItemStack, amount: u32) -> u32 {
let loss = self.durability_loss(stack, amount);
if loss > 0 {
self.trigger_item_durability(stack);
}
loss
}
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
self.enchanted_damage(target, 0.0)
}
/// `Player.getEnchantedDamage`: `base` run through the held weapon's
/// damage effects against a mob type.
pub(super) fn enchanted_damage(&mut self, target: &str, base: f32) -> f32 {
let Some(held) = self.held().cloned() else {
return base;
return 0.0;
};
let ctx = EffectContext {
this: Some(target),
@@ -104,7 +90,7 @@ impl Game<'_> {
"minecraft:damage",
&ctx,
&mut self.world.random,
base,
0.0,
)
}
/// Strength / weakness on the attack damage attribute.
@@ -470,7 +456,7 @@ impl Game<'_> {
}
hits.push(1.0 + self.world.random.next_float() * 4.0);
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
let loss = self.wear(&stack, 2);
let loss = self.durability_loss(&stack, 2);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += loss;
if self
@@ -555,7 +541,7 @@ impl Game<'_> {
/// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the
/// `ammo_use` conditions as the tool, so Infinity only frees plain arrows.
pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) {
let Some(bow) = self.held().cloned() else {
let Some(bow) = self.inventory[self.selected].clone() else {
return (0.0, false);
};
let e = &self.res.stations().enchantments;
@@ -590,7 +576,7 @@ impl Game<'_> {
/// `EnchantmentHelper.onProjectileSpawned` ignite effects of the held bow
/// (flame): the seconds the new arrow burns for.
pub(super) fn bow_projectile_ignite(&mut self) -> Option<f32> {
let bow = self.held().cloned()?;
let bow = self.inventory[self.selected].clone()?;
let ctx = EffectContext {
this: Some("minecraft:arrow"),
items: Some(self.res.items()),
@@ -728,7 +714,7 @@ impl Game<'_> {
let Some(stack) = self.armor[3].clone() else {
return;
};
let loss = self.wear(&stack, amount);
let loss = self.durability_loss(&stack, amount);
let max = self
.res
.items()
+8 -2
View File
@@ -376,8 +376,14 @@ impl Game<'_> {
}
self.consume_held();
self.swing_arm();
// EnderEyeItem.useOn emits this event for all nearby players.
self.level_event_at(1503, hit.pos, 0);
let center = hit.pos.map(|v| f64::from(v) + 0.5);
// LevelEvent 1503: END_PORTAL_FRAME_FILL.
self.audio.play(
"minecraft:block.end_portal_frame.fill",
Some(center),
1.0,
1.0,
);
if let Some(c) = self.portal_ring_center(hit.pos) {
for dz in -1..=1 {
for dx in -1..=1 {
@@ -258,8 +258,6 @@ impl Game<'_> {
if dist > 1.0 || p.health.dead_or_dying() {
return;
}
// ServerPlayer.onExplosionHit: the blast that hit the player is theirs.
self.adv.marks.blast = Some((feet, false));
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
let eye = [
@@ -505,15 +503,6 @@ impl Game<'_> {
);
}
}
/// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying
/// and plain destruction.
pub(super) fn destroy_type(&self, rule: &str) -> BlockInteraction {
if self.ui.rules.bool(rule) {
BlockInteraction::DestroyWithDecay
} else {
BlockInteraction::Destroy
}
}
/// Runs an explosion whose source empties the hives it destroys
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
/// TNT minecart).
@@ -6,33 +6,11 @@ use horizon_world::level::LevelEvent;
impl Game<'_> {
pub(super) fn finish_falling_block(&mut self, mut block: FallingBlock, pos: [i32; 3]) {
let name = self.res.facts().get(block.state).name.clone();
let anvil = name.ends_with("anvil");
let stalactite = matches!(
&*name,
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
);
let sound_event = match &*name {
"minecraft:pointed_dripstone" => Some(1045),
"minecraft:sulfur_spike" => Some(1052),
_ => None,
};
let anvil = self.res.facts().get(block.state).name.ends_with("anvil");
if let Some(impact) = block.damage.impact(block.fall_distance) {
if impact.amount > 0.0 {
let mob_kind = if stalactite {
horizon_client::mobs::DamageKind::FallingStalactite
} else {
horizon_client::mobs::DamageKind::FallingBlock
};
falling_block::hurt_mobs_with_kind(
&mut self.mobs,
block.position,
impact.amount,
mob_kind,
);
let kind = if stalactite {
"minecraft:falling_stalactite"
} else if anvil {
falling_block::hurt_mobs(&mut self.mobs, block.position, impact.amount);
let kind = if anvil {
"minecraft:falling_anvil"
} else {
"minecraft:falling_block"
@@ -80,13 +58,7 @@ impl Game<'_> {
}
}
if block.damage.cancel_drop {
if let Some(id) = sound_event {
self.level_events.push(LevelEvent::LevelEvent {
id,
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
} else if anvil {
if anvil {
self.level_events.push(LevelEvent::LevelEvent {
id: 1029,
pos: BlockPos::new(pos[0], pos[1], pos[2]),
@@ -127,13 +99,6 @@ impl Game<'_> {
&& block.drop_item
&& self.ui.rules.bool("entity_drops")
{
if let Some(id) = sound_event {
self.level_events.push(LevelEvent::LevelEvent {
id,
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
}
let item = self.res.facts().get(block.state).item.clone();
if &*item != "minecraft:air" {
self.pop_resource(
+4 -3
View File
@@ -2,7 +2,7 @@
//! `ItemFrame.survives`, `interact` (put an item in, then rotate it),
//! `hurtServer` (take the item out, then break the frame) and
//! `ItemFrameRenderer` (the frame model and the item at a 45 degree step).
use super::{Game, Hit};
use super::{Game, Hit, view_vector};
use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary};
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
use horizon_core::{
@@ -160,7 +160,8 @@ impl Game<'_> {
}
/// The frame under the crosshair within reach and its distance.
pub(super) fn frame_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64)> = None;
for (i, f) in self.item_frames.iter().enumerate() {
@@ -188,7 +189,7 @@ impl Game<'_> {
/// Nearer than the targeted block?
pub(super) fn before_block(&self, distance: f64) -> bool {
self.pick().is_none_or(|h| {
let eye = self.aim().map(|(eye, _)| eye).unwrap_or_default();
let eye = self.eye_position().unwrap_or_default();
(0..3)
.map(|a| (h.point[a] - eye[a]).powi(2))
.sum::<f64>()
+13 -18
View File
@@ -3,22 +3,6 @@
use super::{Game, Screen};
use horizon_client::command::args::GameMode;
pub(super) fn mode_id_of(mode: GameMode) -> i32 {
match mode {
GameMode::Survival => 0,
GameMode::Creative => 1,
GameMode::Adventure => 2,
GameMode::Spectator => 3,
}
}
pub(super) fn mode_from_id(id: i32) -> GameMode {
match id {
1 => GameMode::Creative,
2 => GameMode::Adventure,
3 => GameMode::Spectator,
_ => GameMode::Survival,
}
}
impl Game<'_> {
pub(super) fn spectator(&self) -> bool {
self.mode == GameMode::Spectator
@@ -28,10 +12,21 @@ impl Game<'_> {
matches!(self.mode, GameMode::Survival | GameMode::Creative)
}
pub(super) fn mode_id(&self) -> i32 {
mode_id_of(self.mode)
match self.mode {
GameMode::Survival => 0,
GameMode::Creative => 1,
GameMode::Adventure => 2,
GameMode::Spectator => 3,
}
}
pub(super) fn set_mode_id(&mut self, id: i32) {
self.set_mode(mode_from_id(id));
let mode = match id {
1 => GameMode::Creative,
2 => GameMode::Adventure,
3 => GameMode::Spectator,
_ => GameMode::Survival,
};
self.set_mode(mode);
}
/// `ServerPlayer.setGameMode` and `GameType.updatePlayerAbilities`.
pub(super) fn set_mode(&mut self, mode: GameMode) {
+1 -7
View File
@@ -78,13 +78,7 @@ impl Game<'_> {
f64::from(base[1]) + 0.05,
f64::from(base[2]) + 0.5,
];
let id = self.create_triggered(kind, at, 0.0);
// CarvedPumpkinBlock.trySpawnGolem: setPlayerCreated(true) on the iron golem.
if kind == "minecraft:iron_golem"
&& let Some(m) = id.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
{
m.extra.player_created = true;
}
self.create_triggered(kind, at, 0.0);
self.update_pattern_blocks(&blocks)
}
/// Whether placing a golem head at `head` completes a golem.
@@ -478,8 +478,6 @@ impl Game<'_> {
self.lan = Some(host);
}
std::thread::sleep(Duration::from_millis(100));
// ServerPlayer.disconnect: nobody is left in a bed.
self.free_player_beds()?;
self.save_game()?;
self.lan_close(false);
self.save.flush()?;
+14 -22
View File
@@ -342,7 +342,9 @@ fn mob_meta(
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
}
// LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in.
if let Some(bed) = horizon_client::mobs::villager::sleeping_pos(m) {
if horizon_client::mobs::villager::sleeping(m)
&& let Some(bed) = m.extra.bed
{
meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed))));
}
if let Some(name) = &m.custom_name {
@@ -907,23 +909,16 @@ fn mob_equipment(
// slot's item (6) in `head`.
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slots = [
(0u8, &e.main_hand, None),
(1, &e.off_hand, None),
(2, &e.feet, e.armor[3].as_ref()),
(3, &e.legs, e.armor[2].as_ref()),
(4, &e.chest, e.armor[1].as_ref()),
// The body slot's item is no worn armour.
(
if body { 6 } else { 5 },
&e.head,
e.armor[0].as_ref().filter(|_| !body),
),
(0u8, &e.main_hand),
(1, &e.off_hand),
(2, &e.feet),
(3, &e.legs),
(4, &e.chest),
(if body { 6 } else { 5 }, &e.head),
];
for (slot, item, worn) in slots {
for (slot, item) in slots {
if let Some(item) = item {
// The worn armour keeps its stack: a dyed leather piece keeps
// its colour.
let stack = worn.cloned().unwrap_or_else(|| ItemStack {
let stack = ItemStack {
item: item.clone(),
count: 1,
// A wolf's armor shows its cracks.
@@ -937,7 +932,7 @@ fn mob_equipment(
} else {
None
},
});
};
out.push((slot, hub.stack(&Some(stack))));
}
}
@@ -1821,13 +1816,10 @@ impl Game<'_> {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, anvil result sounds, or a
// finished brushing or an eye inserted in a portal frame (these calls name no
// finished brushing (these levelEvent calls name no
// excluded player), nor the digger's own crack and hit
// sound (2019, 2020).
let except = if matches!(
*id,
2015 | 1503 | 1505 | 1029 | 1030 | 1045 | 1052 | 2019 | 2020 | 3000 | 3008
) {
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020 | 3008) {
None
} else {
self.lan_acting
+1 -21
View File
@@ -82,8 +82,6 @@ pub(super) struct GuestPlay {
last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
levitation: Option<([f64; 3], u64)>,
/// The fall, lava ride, Nether trip and blast the guest's advancements track.
marks: super::advancements::TravelMarks,
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
pub(super) portal: horizon_client::nether_portal::PortalState,
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
@@ -168,7 +166,6 @@ pub(super) struct GuestSwap {
adv_loaded: bool,
last_inventory: u64,
levitation: Option<([f64; 3], u64)>,
marks: super::advancements::TravelMarks,
death_cause: Option<String>,
/// The guest's own damage record, in place while they act.
combat: super::combat::CombatTracker,
@@ -399,7 +396,6 @@ impl Game<'_> {
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
let marks = std::mem::replace(&mut self.adv.marks, std::mem::take(&mut play.marks));
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
let acting = (
self.lan_acting.replace(conn),
@@ -472,7 +468,6 @@ impl Game<'_> {
adv_loaded,
last_inventory,
levitation,
marks,
death_cause,
acting,
packets,
@@ -533,7 +528,6 @@ impl Game<'_> {
adv_loaded,
last_inventory,
levitation,
marks,
death_cause,
combat,
acting,
@@ -658,7 +652,6 @@ impl Game<'_> {
self.adv.loaded = adv_loaded;
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
play.levitation = std::mem::replace(&mut self.adv.levitation, levitation);
play.marks = std::mem::replace(&mut self.adv.marks, marks);
let stat_events = std::mem::take(&mut play.stats.events);
let name = g.name.clone();
self.potions.swap_player(&mut g.effects, &mut play.potions);
@@ -1253,14 +1246,7 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
// ServerPlayer.getKnownMovement: the move their client just sent,
// which a throw adds to the projectile.
let movement = g
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
let swung = self.as_guest(host, conn, |g| {
g.known_movement = movement;
g.guest_use = Some(GuestUse::Item(hand));
let swings = g.swings_started;
let _ = g.use_item();
@@ -2734,12 +2720,6 @@ impl Game<'_> {
self.as_guest(host, conn, |game| {
game.sculk_step_on_player();
game.tick_player_sculk_events();
// Entity.restituteMovementAfterCollisions: GameEvent.BOUNCE.
for event in &events {
if matches!(event, MotionEvent::Bounce { .. }) {
game.post_entity_event("minecraft:bounce", horizon_client::mobs::Who::Player);
}
}
});
for event in events {
match event {
@@ -2875,7 +2855,7 @@ impl Game<'_> {
&& let Some(stack) = game.armor[1].clone()
{
// `ItemStack.hurtAndBreak`: Unbreaking can save the wear.
let loss = game.wear(&stack, 1);
let loss = game.durability_loss(&stack, 1);
if let Some(elytra) = game.armor[1].as_mut() {
elytra.damage += loss;
}
@@ -425,12 +425,9 @@ impl Game<'_> {
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
e.pitch = t.pitch;
e.velocity = at(t.velocity);
// Projectile.getAddEntityPacket: the owner's id, the guest's that
// threw it.
e.data = match t.guest.and_then(|conn| hub.guest(conn)) {
Some(g) => g.entity_id,
None => owner(Some(horizon_client::mobs::Who::Player)),
};
// Projectile.getAddEntityPacket: the owner's id; a trident here is
// always the host player's.
e.data = owner(Some(horizon_client::mobs::Who::Player));
e.meta = arrow_meta(false, t.no_physics, 0, t.in_ground);
// ThrownTrident.ID_LOYALTY and ID_FOIL.
if t.loyalty != 0 {
+1 -1
View File
@@ -118,7 +118,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
..
}
| MobEvent::EffectPlayer { .. }
| MobEvent::AxolotlSupportPlayer { .. }
| MobEvent::AxolotlSupportPlayer
| MobEvent::ParrotToShoulder { .. }
)
}
@@ -1,5 +1,5 @@
//! The horse inventory for a server's players (`ServerPlayer.openHorseInventory`):
//! shift + use on a tamed horse, donkey, mule, camel, llama or nautilus, or the
//! shift + use on a tamed horse, donkey, mule, camel or llama, or the
//! inventory key while riding one (`ServerboundPlayerCommandPacket`
//! OPEN_INVENTORY), opens `HorseInventoryMenu` over the mount with
//! `ClientboundMountScreenOpenPacket`. Clicks run on the menu engine
+33 -35
View File
@@ -5,7 +5,7 @@
//! attack (blocking, armour, knockback), when the `pvp` game rule allows it
//! (`ServerPlayer.canHarmPlayer`, `isPvpAllowed`). A player's arrow on
//! another player lands the same way.
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared, ray_box};
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, ray_box, view_vector};
use horizon_client::{
Result,
@@ -50,6 +50,8 @@ pub(super) struct Strike {
knockback_attack: bool,
/// The attacker's team, for the victim's `Player.canHarmPlayer`.
team: Option<horizon_client::scoreboard::Team>,
/// The sweep's damage to the players around the victim.
sweep_hit: Option<Sweep>,
}
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
@@ -66,8 +68,8 @@ pub(super) struct Sweep {
team: Option<horizon_client::scoreboard::Team>,
/// The target's box grown by (1, 0.25, 1).
pub(super) hit_box: Aabb,
/// The entity id of the player the sweep started from, which it spares.
pub(super) skip: Option<i32>,
/// The player the sweep started from, which it spares.
skip: Option<Victim>,
}
impl Strike {
@@ -82,7 +84,8 @@ impl Game<'_> {
/// The guest on the host's crosshair and how far, within reach.
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
let host = self.lan.as_ref()?;
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach = if self.creative { 5.0 } else { 3.0 };
host.hub
.guests
@@ -143,8 +146,12 @@ impl Game<'_> {
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];
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player", true);
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
let sweep = self.sweep_attack_position(scale, crit);
let sweep_hit = sweep.map(|_| {
let base = self.sweep_base_damage(scale);
self.player_sweep(base, scale)
});
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
@@ -169,6 +176,7 @@ impl Game<'_> {
knockback: (strength, xd, zd),
knockback_attack,
team: self.player_team().cloned(),
sweep_hit,
})
}
@@ -361,29 +369,21 @@ impl Game<'_> {
(true, false, false) => "minecraft:entity.player.attack.weak",
};
if let Some(at) = s.sweep.filter(|_| hurt) {
// The sweep around the player that was hit, as its attacker.
let (area, id) = match victim {
Victim::Host => (self.player.as_ref().map(|p| p.bounds()), host.host_entity),
Victim::Guest(conn) => {
let g = host.hub.guest(conn);
(g.map(guest_bounds), g.map_or(-1, |g| g.entity_id))
}
self.sweep_attack(at, Some(s.from));
// The other players next to the victim (Player.doSweepAttack).
let bounds = match victim {
Victim::Host => self.player.as_ref().map(|p| p.bounds()),
Victim::Guest(conn) => host.hub.guest(conn).map(guest_bounds),
};
let target = area.map(|area| (area, Spared::Player(id)));
match s.attacker {
None => {
// The host is checked out here: collect the sweep for the players.
self.combat.sweeps = Some(Vec::new());
self.sweep_attack(at, Some(s.from), target, s.scale);
for sweep in self.combat.sweeps.take().unwrap_or_default() {
self.sweep_players(host, &sweep);
}
}
Some(conn) => {
self.as_guest(host, conn, |g| {
g.sweep_attack(at, Some(s.from), target, s.scale)
});
}
if let (Some(sweep), Some(bounds)) = (&s.sweep_hit, bounds) {
self.sweep_players(
host,
&Sweep {
hit_box: bounds.inflate(Vec3::new(1.0, 0.25, 1.0)),
skip: Some(victim),
..sweep.clone()
},
);
}
} else {
self.audio.play(sound, Some(s.from), 1.0, 1.0);
@@ -400,7 +400,7 @@ impl Game<'_> {
.player
.as_ref()
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
let damage = self.enchanted_damage("minecraft:player", base) * scale;
let damage = (base + self.enchant_damage_bonus("minecraft:player")) * scale;
Sweep {
attacker: self.lan_acting,
from,
@@ -416,9 +416,7 @@ impl Game<'_> {
/// turn the host holds the other players (`as_guest`), so it waits for
/// the turn to end.
pub(super) fn sweep_other_players(&mut self, sweep: Sweep) {
if let Some(collect) = self.combat.sweeps.as_mut() {
collect.push(sweep);
} else if self.lan_acting.is_some() {
if self.lan_acting.is_some() {
self.creatures.sweeps.push(sweep);
} else if let Some(mut host) = self.lan.take() {
self.sweep_players(&mut host, &sweep);
@@ -448,7 +446,7 @@ impl Game<'_> {
&& p.bounds().intersects(sweep.hit_box)
&& p.position.distance_squared(from) < 9.0
{
victims.push((Victim::Host, host.host_entity, self.player_team().cloned()));
victims.push((Victim::Host, self.player_team().cloned()));
}
for g in &host.hub.guests {
if host.hub.here(g)
@@ -461,7 +459,7 @@ impl Game<'_> {
< 9.0
{
let team = self.score.board.players_team(&g.name).cloned();
victims.push((Victim::Guest(g.conn), g.entity_id, team));
victims.push((Victim::Guest(g.conn), team));
}
}
let (xd, zd) = sweep.knockback;
@@ -471,8 +469,8 @@ impl Game<'_> {
g.knock_back_player(0.4, xd, zd);
}
};
for (victim, id, team) in victims {
if sweep.skip == Some(id)
for (victim, team) in victims {
if sweep.skip == Some(victim)
|| horizon_client::scoreboard::teams_allied(team.as_ref(), sweep.team.as_ref())
|| !self.ui.rules.bool("pvp")
{
@@ -179,17 +179,6 @@ impl Game<'_> {
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 1503: smoke on the inserted eye.
pub(super) fn end_portal_frame_smoke(&mut self, pos: BlockPos) {
let options = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..16 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + (5.0 + r.next_double() * 6.0) / 16.0;
let z = f64::from(pos.z) + (5.0 + r.next_double() * 6.0) / 16.0;
self.particles
.add(&options, [x, f64::from(pos.y) + 0.8125, 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) {
@@ -302,12 +291,6 @@ impl Game<'_> {
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
pub(super) fn global_level_event(&mut self, id: i32, pos: [i32; 3], data: i32) {
// ServerLevel.globalLevelEvent: without global_sound_events it is a
// plain level event, which the client has no sound for (1023, 1028
// and 1038 are only in `LevelEventHandler.globalLevelEvent`).
if !self.ui.rules.bool("global_sound_events") {
return;
}
self.global_level_event_sound(id, pos);
if self.audio.capture.is_some() {
self.lan_global_events.push((id, pos, data));
-1
View File
@@ -463,7 +463,6 @@ impl Game<'_> {
.is_some_and(|p| !p.streaming.forced.is_empty())
};
guests.iter().any(|g| g.dimension == d.id())
|| self.player_bed_levels().any(|b| b == *d)
|| forced
|| self.pregen_level() == Some(*d)
// A pending portal arrival keeps its destination level
+2 -168
View File
@@ -10,11 +10,7 @@ use horizon_client::{
mobs::{Damage, Mob, Species},
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_world::registry::BlockStateId;
/// `MaceItem.SMASH_ATTACK_FALL_THRESHOLD`.
@@ -42,22 +38,6 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|| m.species() == Species::Allay && m.extra.tame
}
/// The entity a sweep leaves out: the one the attack hit (`target` of
/// `Player.doSweepAttack`).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Spared {
Nothing,
Mob(u32),
Stand(u32),
/// A player of the open world, by entity id.
Player(i32),
}
/// `Player.doSweepAttack`'s `1 + ratio * f`, where `f` is the attack damage
/// after `baseDamageScaleFactor` (0.2 + strength^2 * 0.8).
fn sweep_base_damage(ratio: f32, attack_damage: f32, scale: f32) -> f32 {
1.0 + ratio * attack_damage * (0.2 + scale * scale * 0.8)
}
/// A smash's knockback on the players, put off while a guest's turn holds
/// the other players.
pub(super) struct SmashPush {
@@ -229,7 +209,7 @@ impl Game<'_> {
&& max > 0
&& !self.creative
{
let loss = self.wear(stack, per_attack);
let loss = self.durability_loss(stack, per_attack);
let cell = self.held_mut();
if let Some(held) = cell.as_mut() {
held.damage += loss;
@@ -360,136 +340,6 @@ impl Game<'_> {
resistance,
);
}
/// `Player.doSweepAttack`'s victims: every other living mob and every
/// armor stand (but a marker one) in `target`'s box inflated by
/// (1, 0.25, 1) and under three blocks from the player takes
/// `getEnchantedDamage(1 + sweeping ratio * attack damage) * strength`,
/// knocked back 0.4 along the player's yaw. The player's allies and the
/// target (`spared`) are spared.
pub(super) fn sweep_damage(&mut self, target: Aabb, spared: Spared, scale: f32) {
let Some(player) = self.player.as_ref() else {
return;
};
let from = [player.position.x, player.position.y, player.position.z];
let area = target.inflate(Vec3::new(1.0, 0.25, 1.0));
let near = |at: Vec3| {
(0..3)
.map(|a| [at.x, at.y, at.z][a] - from[a])
.map(|d| d * d)
.sum::<f64>()
< 9.0
};
// The enchantment's `sweeping_damage_ratio` attribute modifier (level /
// (level + 1)) on the attribute's 0 to 1 range.
let ratio = self
.res
.stations()
.enchantments
.attribute_add(
self.inventory[self.selected].as_ref(),
"minecraft:sweeping_damage_ratio",
)
.clamp(0.0, 1.0) as f32;
// `f` of doSweepAttack: the attack damage attribute after
// baseDamageScaleFactor (a sweep is never a smash or a critical hit).
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
let attribute = if spin > 0.0 {
spin
} else {
1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage)
+ self.effect_attack_bonus()
};
let base = sweep_base_damage(ratio, attribute, scale);
let (xd, zd) = self.extra_knockback_direction();
let uuid = self.level.player_uuid.or(Some([0, 0, 0, 1]));
let allies = self.player_allies();
// getEntitiesOfClass walks the entity sections (x, then z, then y)
// under the box, each in the order its entities were added.
let section = |at: Vec3| {
(
(at.x.floor() as i32) >> 4,
(at.z.floor() as i32) >> 4,
(at.y.floor() as i32) >> 4,
)
};
let mut victims: Vec<((i32, i32, i32), u32, bool)> = Vec::new();
for m in &self.mobs {
if spared != Spared::Mob(m.id)
&& m.alive()
&& !m.removed
&& !allies.contains(&m.id)
&& !ally_of_player(m, uuid)
&& m.bounds().intersects(area)
&& near(m.position)
{
victims.push((section(m.position), m.id, false));
}
}
for s in &self.armor_stands {
if !s.marker
&& spared != Spared::Stand(s.id)
&& s.bounds().intersects(area)
&& near(s.position)
{
victims.push((section(s.position), s.id, true));
}
}
victims.sort_by_key(|&(key, id, _)| (key, id));
for (_, id, is_stand) in victims {
if is_stand {
let Some(i) = self.armor_stands.iter().position(|s| s.id == id) else {
continue;
};
// knockback(0.4, ..) follows a hurtServer that landed, on a
// stand that is still there (LivingEntity.knockback).
if !self.punch_armor_stand(i) {
continue;
}
let (mut kx, mut kz) = (xd, zd);
while kx * kx + kz * kz < f64::from(1.0e-5_f32) {
let r = &mut self.world.random;
kx = (r.next_double() - r.next_double()) * 0.01;
kz = (r.next_double() - r.next_double()) * 0.01;
}
let len = (kx * kx + kz * kz).sqrt();
if let Some(s) = self.armor_stands.iter_mut().find(|s| s.id == id) {
s.velocity = Vec3::new(
s.velocity.x / 2.0 - kx / len * 0.4,
if s.on_ground {
(s.velocity.y / 2.0 + 0.4).min(0.4)
} else {
s.velocity.y
},
s.velocity.z / 2.0 - kz / len * 0.4,
);
}
continue;
}
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
continue;
};
let kind = self.mobs[i].kind.id.clone();
let kind = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
let amount = self.enchanted_damage(&kind, base) * scale;
let source = self.attack_source(from);
// hurtServer first; the knockback and post-attack effects follow
// only when it landed.
if self.hurt_by_player(i, amount, source) {
self.mobs[i].knockback(0.4, xd, zd);
self.last_hurt_by_player
.insert(self.mobs[i].id, self.lan_acting);
self.post_attack(i);
}
}
// `getEntitiesOfClass(LivingEntity.class)` takes the other players too.
let mut sweep = self.player_sweep(base, scale);
sweep.hit_box = area;
sweep.skip = match spared {
Spared::Player(id) => Some(id),
_ => None,
};
self.sweep_other_players(sweep);
}
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
/// the landing block's particles, 50 for every block fallen (200 at most).
pub(super) fn smash_landing_particles(
@@ -559,19 +409,3 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sweep_damage_is_one_without_the_enchantment() {
// A fully charged diamond sword (8 attack damage): neighbours take 1.
assert_eq!(sweep_base_damage(0.0, 8.0, 1.0), 1.0);
// Sweeping Edge III adds the ratio of the (charged) attack damage.
assert_eq!(sweep_base_damage(0.75, 8.0, 1.0), 7.0);
// baseDamageScaleFactor at 0.95 strength.
let scaled = sweep_base_damage(0.5, 8.0, 0.95);
assert!((scaled - (1.0 + 0.5 * 8.0 * (0.2 + 0.95 * 0.95 * 0.8))).abs() < 1e-6);
}
}
+2 -14
View File
@@ -66,10 +66,6 @@ pub(super) struct MapData {
pub unlimited: bool,
pub colors: Vec<u8>,
pub decorations: Vec<Decoration>,
/// `MapBanner` and `MapFrame` entries as saved (`banners`, `frames`), kept
/// so a save does not drop them.
pub banners: Vec<Tag>,
frames: Vec<Tag>,
holders: Vec<Holder>,
/// Changed since the last save / texture upload.
dirty: bool,
@@ -89,8 +85,6 @@ impl MapData {
unlimited: false,
colors: vec![0; 128 * 128],
decorations: Vec::new(),
banners: Vec::new(),
frames: Vec::new(),
holders: Vec::new(),
dirty: true,
texture_dirty: true,
@@ -240,8 +234,8 @@ impl MapData {
("trackingPosition", flag(self.tracking)),
("unlimitedTracking", flag(self.unlimited)),
("colors", Tag::ByteArray(self.colors.clone())),
("banners", Tag::List(self.banners.clone())),
("frames", Tag::List(self.frames.clone())),
("banners", Tag::List(Vec::new())),
("frames", Tag::List(Vec::new())),
]),
),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
@@ -263,10 +257,6 @@ impl MapData {
_ => Vec::new(),
};
colors.resize(128 * 128, 0);
let list = |k: &str| match d.get(k) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
Some(Self {
center: [int("xCenter")?, int("zCenter")?],
scale: int("scale").unwrap_or(0).clamp(0, 4) as u8,
@@ -276,8 +266,6 @@ impl MapData {
unlimited: int("unlimitedTracking").unwrap_or(0) != 0,
colors,
decorations: Vec::new(),
banners: list("banners"),
frames: list("frames"),
holders: Vec::new(),
dirty: false,
texture_dirty: true,
+8 -11
View File
@@ -4,7 +4,7 @@
//! `MinecartItem` placement, `VehicleEntity` breaking, the chest, hopper,
//! furnace and TNT variants, saving, and `AbstractMinecartRenderer`'s old
//! rail-following render with the display block.
use super::{Game, Hit, Keys};
use super::{Game, Hit, Keys, explosion::BlockInteraction, view_vector};
use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
@@ -662,9 +662,9 @@ impl Game<'_> {
// The cart is the explosion's source: it is gone before the blast
// looks for entities, so it can't hit itself.
self.carts.retain(|x| x.id != c.id);
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
let _ = self.with_hive_emptying_blast(|g| g.server_explode(at, power, interaction, false));
let _ = self.with_hive_emptying_blast(|g| {
g.server_explode(at, power, BlockInteraction::Destroy, false)
});
}
/// `AbstractMinecart.push` from each player walking into a cart
/// (`LivingEntity.pushEntities`): the window's player and every server
@@ -998,7 +998,8 @@ impl Game<'_> {
true
}
pub(super) fn cart_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64)> = None;
for (i, c) in self.carts.iter().enumerate() {
@@ -1146,7 +1147,7 @@ impl Game<'_> {
})
}
/// `VehicleEntity.hurtServer` from the player's attack.
pub(super) fn attack_cart(&mut self) -> bool {
pub(super) fn attack_cart(&mut self, damage: f32) -> bool {
let Some((i, d)) = self.cart_hit() else {
return false;
};
@@ -1156,12 +1157,8 @@ impl Game<'_> {
let burning = self
.held()
.is_some_and(|s| s.enchantment_level("minecraft:fire_aspect") > 0);
let area = self.carts[i].bounds();
// Player.attack's full damage against the cart's entity type.
let id = self.carts[i].id as i32;
let (damage, _, _, magic) = self.melee_damage(self.carts[i].kind.id(), false);
self.vehicle_attack_sounds();
self.hurt_cart(i, damage, self.creative, burning);
self.vehicle_attack_sounds(area, magic.then_some(id));
self.swing_arm();
true
}
@@ -1,144 +1,64 @@
//! The item panel beside container screens: the items a recipe viewer mod
//! lists (a native mod's `native_panel_items`, or WIT `interface browser`
//! of a WebAssembly mod, docs/mods.md), drawn as the game's own item slots
//! in pages right of the screen's window, with a search box under them. The
//! mod says what the panel holds; the game pages it and tells the mod what
//! the player clicked, typed, and which keys went down over an item
//! (`native_panel_event`). With no mod showing a panel, each hook here
//! returns after one check.
use super::{
Game, Screen,
native_hooks::{NativePanelEvent as BrowserEvent, NativeStack as BrowserStack},
native_panel_recipes::RecipePage,
};
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
ui::EditBox,
//! Capability `ui`'s panel beside container screens (WIT `interface
//! browser`, docs/mods.md): the items a recipe viewer mod lists, drawn as
//! the game's own item slots in pages right of the screen's window, with a
//! search box under them. The mod says what the panel holds; the game pages
//! it and tells the mod what the player clicked, typed, and which of the
//! mod's keys went down over an item. With no mod showing a panel, each
//! hook here returns after one check.
use super::Game;
#[cfg(feature = "mods")]
use {
super::Screen,
horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
ui::EditBox,
},
horizon_mods::{BrowserClick, BrowserEvent, BrowserStack},
};
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn draw_mod_browser(&mut self, _batch: &mut horizon_client::hud::HudBatch) {}
pub(super) fn mod_browser_press(&mut self, _right: bool) -> bool {
false
}
pub(super) fn mod_browser_typing(&self) -> bool {
false
}
pub(super) fn mod_browser_key(&mut self, _event: &winit::event::KeyEvent) {}
pub(super) fn mod_browser_text(&mut self, _text: Option<&str>, _backspace: bool) {}
pub(super) fn mod_browser_closed(&mut self) {}
}
/// One slot of the panel, in GUI pixels.
#[cfg(feature = "mods")]
const SLOT: f32 = 18.0;
/// The most columns the panel takes, and the fewest it shows with.
#[cfg(feature = "mods")]
const COLUMNS: (usize, usize) = (12, 2);
/// The gap between the window and the panel, and the screen's edges.
#[cfg(feature = "mods")]
const MARGIN: f32 = 8.0;
/// Items a panel may hold.
const ITEMS: usize = 8192;
/// Whose panel it is: a native mod's id, or a running WebAssembly mod's
/// index.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Owner {
Native(String),
#[cfg(feature = "mods")]
Wasm(usize),
}
impl Owner {
fn native_id(&self) -> Option<&str> {
match self {
Owner::Native(id) => Some(id),
#[cfg(feature = "mods")]
Owner::Wasm(_) => None,
}
}
}
/// What a mod shows on the panel: its items, and the recipes shown in
/// their place.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Shown {
pub(super) owner: Owner,
/// Which change the panel is from, so the game converts it once.
pub(super) version: u64,
pub(super) items: Vec<BrowserStack>,
pub(super) recipes: Option<RecipePage>,
}
/// The panel's state in the game.
#[cfg(feature = "mods")]
#[derive(Default)]
pub(super) struct Panel {
/// A native mod's panel: the last one to show something.
native: Option<Shown>,
/// A WebAssembly mod's, converted once per change.
#[cfg(feature = "mods")]
pub(super) wasm: Option<Shown>,
/// The last native change.
versions: u64,
pub(super) struct Browser {
/// The items' owner and version they were made from.
of: Option<(Owner, u64)>,
of: Option<(usize, u64)>,
/// The mod's items as stacks; `None` for an item the game doesn't have.
stacks: Vec<Option<ItemStack>>,
page: usize,
search: Option<EditBox>,
/// The search text the mod last heard.
sent: String,
/// The recipes shown instead of the items.
pub(super) recipes: Option<super::native_panel_recipes::Pages>,
}
impl Panel {
/// What the panel shows: a native mod's (one `on`), else a
/// WebAssembly mod's.
pub(super) fn shown(&self, on: impl Fn(&str) -> bool) -> Option<&Shown> {
let native = self
.native
.as_ref()
.filter(|s| s.owner.native_id().is_some_and(&on));
#[cfg(feature = "mods")]
let native = native.or(self.wasm.as_ref());
native
}
/// `native_panel_items`.
pub(super) fn show_items(&mut self, mod_id: &str, mut items: Vec<BrowserStack>) {
items.truncate(ITEMS);
if let Some(shown) = self.native_of(mod_id, !items.is_empty()) {
shown.items = items;
self.native_changed();
}
}
/// `native_panel_recipes`.
pub(super) fn show_recipes(&mut self, mod_id: &str, page: Option<RecipePage>) {
if let Some(shown) = self.native_of(mod_id, page.is_some()) {
shown.recipes = page;
self.native_changed();
}
}
/// Mod `mod_id`'s panel, made when it shows something on another's;
/// none when it clears a panel that isn't its own.
fn native_of(&mut self, mod_id: &str, showing: bool) -> Option<&mut Shown> {
let own = self
.native
.as_ref()
.is_some_and(|s| s.owner.native_id() == Some(mod_id));
if !own {
if !showing {
return None;
}
self.native = Some(Shown {
owner: Owner::Native(mod_id.to_owned()),
version: 0,
items: Vec::new(),
recipes: None,
});
}
self.native.as_mut()
}
/// A native panel changed: a new version, and no panel once it shows
/// nothing.
fn native_changed(&mut self) {
self.versions += 1;
if let Some(shown) = self.native.as_mut() {
shown.version = self.versions;
if shown.items.is_empty() && shown.recipes.is_none() {
self.native = None;
}
}
}
/// `set-recipes`' recipes, shown instead of the items.
pub(super) recipes: Option<super::mod_browser_recipes::Pages>,
}
/// Where the panel's parts are this frame.
#[cfg(feature = "mods")]
pub(super) struct Layout {
/// The panel's whole area, for clicks that hit none of its parts.
pub(super) area: [f32; 4],
@@ -150,6 +70,7 @@ pub(super) struct Layout {
pub(super) arrows_y: f32,
pub(super) search: [f32; 4],
}
#[cfg(feature = "mods")]
impl Layout {
fn per_page(&self) -> usize {
self.columns * self.rows
@@ -172,62 +93,48 @@ impl Layout {
}
}
#[cfg(feature = "mods")]
pub(super) fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool {
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
}
#[cfg(feature = "mods")]
impl Game<'_> {
fn browser_batch(&self) -> Option<HudBatch> {
let r = self.renderer.as_ref()?;
Some(HudBatch::new([r.config.width, r.config.height]))
}
/// The mod showing a panel, with its items made into stacks once per
/// change; none while no mod shows one or no container screen is up.
fn browser_sync(&mut self) -> Option<Owner> {
/// `set-items`; none while no mod shows one or no container screen is up.
fn browser_sync(&mut self) -> Option<usize> {
if self.screen != Screen::Inventory || self.title.is_some() {
return None;
}
self.panel_wasm_sync();
let disabled = &self.settings.mods.disabled;
let Some(shown) = self.panel.shown(|id| !disabled.contains(id)) else {
self.panel.of = None;
self.panel.stacks.clear();
let Some((owner, version)) = self.mods.mods.browser_items().map(|(o, v, _)| (o, v)) else {
self.mods.browser.of = None;
self.mods.browser.stacks.clear();
return None;
};
let of = (shown.owner.clone(), shown.version);
if self.panel.of.as_ref() != Some(&of) {
if self.mods.browser.of != Some((owner, version)) {
let items = self.res.items();
let stacks = shown
.items
.iter()
.map(|s| browser_stack(items, s))
.collect();
let shown = shown.recipes.clone();
self.panel.stacks = stacks;
let stacks = self
.mods
.mods
.browser_items()
.map_or_else(Vec::new, |(_, _, list)| {
list.iter().map(|s| browser_stack(items, s)).collect()
});
self.mods.browser.stacks = stacks;
// The same title again (the mod updating its buttons) keeps the page.
let old = self.panel.recipes.take();
self.panel.recipes = shown
.as_ref()
.map(|shown| super::native_panel_recipes::Pages::new(items, shown, old));
self.panel.of = Some(of.clone());
}
Some(of.0)
}
/// What the panel shows now.
fn panel_shown(&self) -> Option<&Shown> {
let disabled = &self.settings.mods.disabled;
self.panel.shown(|id| !disabled.contains(id))
}
/// The player did `event` on `owner`'s panel.
pub(super) fn panel_event(&mut self, owner: &Owner, event: BrowserEvent) {
match owner {
Owner::Native(id) => self.native_panel_event(id, &event),
#[cfg(feature = "mods")]
Owner::Wasm(i) => self
let old = self.mods.browser.recipes.take();
self.mods.browser.recipes = self
.mods
.mods
.browser_event(*i, super::mod_panel::event(event)),
.browser_recipes(owner)
.map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old));
self.mods.browser.of = Some((owner, version));
}
Some(owner)
}
/// The panel's parts: right of the window, from the top of the screen to
/// the search box at its foot. None when the screen has no room for it.
@@ -254,12 +161,17 @@ impl Game<'_> {
})
}
fn browser_pages(&self, layout: &Layout) -> usize {
self.panel.stacks.len().div_ceil(layout.per_page()).max(1)
self.mods
.browser
.stacks
.len()
.div_ceil(layout.per_page())
.max(1)
}
/// The stack under the pointer on the panel, an item's or a recipe's,
/// as the mod sent it and as a stack.
fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> {
if self.panel.recipes.is_some() {
if self.mods.browser.recipes.is_some() {
if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) {
return Some((sent, stack));
}
@@ -267,26 +179,26 @@ impl Game<'_> {
return Some((sent, stack));
}
let (index, stack) = self.browser_hovered(layout)?;
let items = &self.panel_shown()?.items;
let (_, _, items) = self.mods.mods.browser_items()?;
Some((items.get(index)?.clone(), stack))
}
/// The panel's item under the pointer: its index and stack.
fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> {
if self.panel.recipes.is_some() {
if self.mods.browser.recipes.is_some() {
return None;
}
let first = self.panel.page * layout.per_page();
let first = self.mods.browser.page * layout.per_page();
(0..layout.per_page()).find_map(|n| {
let (x, y) = layout.slot(n);
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
return None;
}
let stack = self.panel.stacks.get(first + n)?.clone()?;
let stack = self.mods.browser.stacks.get(first + n)?.clone()?;
Some((first + n, stack))
})
}
fn browser_search(&mut self, layout: &Layout) -> &mut EditBox {
let search = self.panel.search.get_or_insert_with(|| {
let search = self.mods.browser.search.get_or_insert_with(|| {
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
edit.max_length = 50;
edit.focused = false;
@@ -299,8 +211,8 @@ impl Game<'_> {
/// The items' page arrows and grid.
fn draw_browser_items(&mut self, batch: &mut HudBatch, layout: &Layout) {
let pages = self.browser_pages(layout);
self.panel.page = self.panel.page.min(pages - 1);
let page = self.panel.page;
self.mods.browser.page = self.mods.browser.page.min(pages - 1);
let page = self.mods.browser.page;
let cursor = self.cursor;
if pages > 1 {
let labels = [
@@ -335,7 +247,7 @@ impl Game<'_> {
}
let first = page * layout.per_page();
for n in 0..layout.per_page() {
let Some(stack) = self.panel.stacks.get(first + n) else {
let Some(stack) = self.mods.browser.stacks.get(first + n) else {
break;
};
let Some(stack) = stack.clone() else {
@@ -373,7 +285,7 @@ impl Game<'_> {
return;
};
let cursor = self.cursor;
if self.panel.recipes.is_some() {
if self.mods.browser.recipes.is_some() {
self.draw_browser_recipes(batch, &layout);
} else {
self.draw_browser_items(batch, &layout);
@@ -384,7 +296,7 @@ impl Game<'_> {
search.suggestion = (search.value.is_empty() && !search.focused).then_some(hint);
let mut search = search.clone();
search.draw(batch, &self.gui.atlas, None);
self.panel.search = Some(search);
self.mods.browser.search = Some(search);
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
@@ -411,7 +323,7 @@ impl Game<'_> {
let cursor = self.cursor;
let shift = self.has_shift_down();
let atlas = &self.gui.atlas;
let search = self.panel.search.get_or_insert_with(|| {
let search = self.mods.browser.search.get_or_insert_with(|| {
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
edit.max_length = 50;
edit
@@ -424,12 +336,12 @@ impl Game<'_> {
return true;
}
search.focused = false;
if self.panel.recipes.is_some() {
return self.browser_recipes_press(&layout, &owner, right)
if self.mods.browser.recipes.is_some() {
return self.browser_recipes_press(&layout, owner, right)
|| inside(cursor, layout.area);
}
let pages = self.browser_pages(&layout);
let page = self.panel.page;
let page = self.mods.browser.page;
let [back, forward] = layout.arrows();
let step = if pages > 1 && page > 0 && inside(cursor, [back.0, back.1, 12.0, 17.0]) {
Some(page - 1)
@@ -442,17 +354,17 @@ impl Game<'_> {
None
};
if let Some(to) = step {
self.panel.page = to;
self.mods.browser.page = to;
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return true;
}
if let Some((index, _)) = self.browser_hovered(&layout) {
let event = BrowserEvent::Clicked {
let event = BrowserEvent::Clicked(BrowserClick {
index: index as u32,
right,
};
self.panel_event(&owner, event);
});
self.mods.mods.browser_event(owner, event);
return true;
}
inside(cursor, layout.area)
@@ -461,8 +373,8 @@ impl Game<'_> {
/// Whether keys go to the panel's search box.
pub(super) fn mod_browser_typing(&self) -> bool {
self.screen == Screen::Inventory
&& self.panel.of.is_some()
&& self.panel.search.as_ref().is_some_and(|s| s.focused)
&& self.mods.browser.of.is_some()
&& self.mods.browser.search.as_ref().is_some_and(|s| s.focused)
}
/// A key press while the search box has focus.
pub(super) fn mod_browser_key(&mut self, event: &winit::event::KeyEvent) {
@@ -470,7 +382,7 @@ impl Game<'_> {
let shortcut = self.edit_shortcut(event);
let ctrl = self.has_control_down();
let shift = self.has_shift_down();
let Some(search) = self.panel.search.as_mut() else {
let Some(search) = self.mods.browser.search.as_mut() else {
return;
};
if let Some(shortcut) = shortcut {
@@ -493,7 +405,7 @@ impl Game<'_> {
}
/// Text typed (or one backspace) into the search box (`horizon-ctl type`).
pub(super) fn mod_browser_text(&mut self, text: Option<&str>, backspace: bool) {
let Some(search) = self.panel.search.as_mut() else {
let Some(search) = self.mods.browser.search.as_mut() else {
return;
};
if backspace {
@@ -505,24 +417,27 @@ impl Game<'_> {
}
/// The mod hears the search text when it changed.
fn browser_searched(&mut self) {
let (Some((owner, _)), Some(search)) = (&self.panel.of, &self.panel.search) else {
let (Some((owner, _)), Some(search)) = (self.mods.browser.of, &self.mods.browser.search)
else {
return;
};
if search.value != self.panel.sent {
let (owner, value) = (owner.clone(), search.value.clone());
self.panel.sent.clone_from(&value);
self.panel.page = 0;
self.panel_event(&owner, BrowserEvent::Search(value));
if search.value != self.mods.browser.sent {
let value = search.value.clone();
self.mods.browser.sent.clone_from(&value);
self.mods.browser.page = 0;
self.mods
.mods
.browser_event(owner, BrowserEvent::Search(value));
}
}
/// The container screen closed: the search box lets go of the keys.
pub(super) fn mod_browser_closed(&mut self) {
if let Some(search) = self.panel.search.as_mut() {
if let Some(search) = self.mods.browser.search.as_mut() {
search.focused = false;
}
}
/// The item under the pointer on a container screen, for the mods' keys
/// (the `Key` event): the panel's, else a slot's.
/// (`browser`'s `key` event): the panel's, else a slot's.
pub(super) fn mod_browser_pointed(&mut self) -> Option<BrowserStack> {
let batch = self.browser_batch()?;
let stack = match self
@@ -541,27 +456,14 @@ impl Game<'_> {
Some(BrowserStack {
item: stack.item.to_string(),
count: stack.count,
components: super::native_recipes::components(&stack),
components: super::mod_recipes::components(&stack),
})
}
/// A key `key` (`key.keyboard.r`) went down: over an item on a container
/// screen, a native mod's panel hears it as a `Key` event.
pub(super) fn native_panel_key(&mut self, key: &str) {
if self.screen != Screen::Inventory || self.title.is_some() || self.mod_browser_typing() {
return;
}
let Some(owner) = self.browser_sync().filter(|o| o.native_id().is_some()) else {
return;
};
if let Some(stack) = self.mod_browser_pointed() {
let key = key.to_owned();
self.panel_event(&owner, BrowserEvent::Key { key, stack });
}
}
}
/// A mod's item as a stack: an item the game has, its components read as
/// `/give` reads them (ones it can't read are left out).
#[cfg(feature = "mods")]
pub(super) fn browser_stack(
items: &horizon_client::items::Items,
s: &BrowserStack,
@@ -581,114 +483,3 @@ pub(super) fn browser_stack(
extra,
})
}
/// No WebAssembly mods: no panel of theirs (`mod_panel.rs` with them).
#[cfg(not(feature = "mods"))]
impl Game<'_> {
fn panel_wasm_sync(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::{BrowserStack, Owner, Panel, RecipePage};
use crate::game::native_hooks::{NativeRecipeLayout, NativeRecipeView};
fn stack(item: &str) -> BrowserStack {
BrowserStack {
item: item.into(),
count: 1,
components: String::new(),
}
}
fn view() -> NativeRecipeView {
NativeRecipeView {
layout: NativeRecipeLayout::Grid,
width: 5,
height: 1,
slots: vec![vec![stack("minecraft:oak_log")]; 12],
results: vec![stack("minecraft:oak_planks")],
note: String::new(),
action: Some(true),
}
}
/// A native mod's items and recipe page are what the panel holds, and
/// its owner (who hears the panel's events) is that mod.
#[test]
fn native_owner_holds_the_panel() {
let mut panel = Panel::default();
let items = vec![stack("minecraft:stone"), stack("minecraft:dirt")];
panel.show_items("recipes", items.clone());
let shown = panel.shown(|_| true).expect("a panel");
assert_eq!(shown.owner, Owner::Native("recipes".into()));
assert_eq!(shown.items, items);
assert_eq!(shown.recipes, None);
let first = shown.version;
let tabs = vec![stack("minecraft:crafting_table")];
let page = RecipePage::new("Oak Planks", tabs.clone(), 0, vec![view()]);
panel.show_recipes("recipes", page);
let shown = panel.shown(|_| true).expect("a panel");
assert_ne!(shown.version, first, "the game converts each change");
assert_eq!(shown.items, items);
let page = shown.recipes.as_ref().expect("a recipe page");
assert_eq!(
(page.title.as_str(), &page.tabs, page.tab),
("Oak Planks", &tabs, 0)
);
// Cut as `set-recipes` cuts it: a 3 x 3 grid at most, 9 slots.
let r = &page.recipes[0];
assert_eq!((r.width, r.height, r.slots.len()), (3, 1, 9));
assert_eq!(r.results, [stack("minecraft:oak_planks")]);
// Another mod clearing its own (empty) panel leaves this one.
panel.show_items("other", Vec::new());
let owner = panel.shown(|_| true).map(|s| s.owner.clone());
assert_eq!(owner, Some(Owner::Native("recipes".into())));
// A mod the player turned off shows nothing.
assert!(panel.shown(|id| id != "recipes").is_none());
// No recipes: the items again; then no items: no panel.
panel.show_recipes("recipes", RecipePage::new("", Vec::new(), 0, Vec::new()));
let shown = panel.shown(|_| true).expect("a panel");
assert_eq!((shown.items.len(), shown.recipes.is_none()), (2, true));
panel.show_items("recipes", Vec::new());
assert!(panel.shown(|_| true).is_none());
}
/// A native mod's panel wins over a WebAssembly mod's, and events go
/// back to a WebAssembly owner in the runtime's form.
#[cfg(feature = "mods")]
#[test]
fn native_owner_wins_over_webassembly() {
use super::{BrowserEvent, Shown};
use crate::game::mod_panel as wasm;
let mut panel = Panel {
wasm: Some(Shown {
owner: Owner::Wasm(0),
version: 7,
items: vec![stack("minecraft:stone")],
recipes: None,
}),
..Panel::default()
};
let owner = |p: &Panel| p.shown(|_| true).map(|s| s.owner.clone());
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
panel.show_items("recipes", vec![stack("minecraft:dirt")]);
assert_eq!(owner(&panel), Some(Owner::Native("recipes".into())));
panel.show_items("recipes", Vec::new());
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
let event = BrowserEvent::SlotClicked {
recipe: 2,
slot: 9,
stack: stack("minecraft:oak_planks"),
right: true,
};
let horizon_mods::BrowserEvent::SlotClicked(c) = wasm::event(event) else {
panic!("a slot click");
};
assert_eq!((c.recipe, c.slot, c.right), (2, 9, true));
assert_eq!(c.stack.item, "minecraft:oak_planks");
}
}
@@ -1,20 +1,17 @@
//! A mod's recipes shown in its item panel's place (`native_panel_recipes`,
//! or `browser.set-recipes` of a WebAssembly mod, docs/mods.md), laid out by the game with its own slots, arrows and
//! `browser.set-recipes` (docs/mods.md): a mod's recipes shown in its item
//! panel's place, laid out by the game with its own slots, arrows and
//! buttons, paged to the panel's height, under a title and a back button.
//! The mod hears clicks on their slots, their action buttons, the back
//! button and page turns.
use super::{
Game,
native_hooks::{
NativePanelEvent as BrowserEvent, NativeRecipeLayout as BrowserLayout,
NativeRecipeView as BrowserRecipe, NativeStack as BrowserStack,
},
native_panel::{Layout, Owner, browser_stack, inside},
mod_browser::{Layout, browser_stack, inside},
};
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
};
use horizon_mods::{BrowserEvent, BrowserLayout, BrowserSlotClick, BrowserStack, RecipePage};
use std::time::Instant;
const SLOT: f32 = 18.0;
@@ -24,54 +21,6 @@ const TITLE: f32 = 18.0;
const TAB: f32 = 20.0;
const GAP: f32 = 6.0;
/// Recipes a page may show, slots in one, stacks a slot cycles, and tabs.
const RECIPES: usize = 256;
const RECIPE_SLOTS: usize = 9;
const SLOT_STACKS: usize = 64;
const TABS: usize = 16;
/// What the panel shows instead of its items: a title, tabs (`tab` the
/// selected one) and the recipes.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct RecipePage {
pub(super) title: String,
pub(super) tabs: Vec<BrowserStack>,
pub(super) tab: u32,
pub(super) recipes: Vec<BrowserRecipe>,
}
impl RecipePage {
/// `native_panel_recipes`' page, cut as `set-recipes` cuts one; none
/// without recipes (the items again).
pub(super) fn new(
title: &str,
tabs: Vec<BrowserStack>,
tab: u32,
recipes: Vec<BrowserRecipe>,
) -> Option<Self> {
let cycle = |mut list: Vec<BrowserStack>| {
list.truncate(SLOT_STACKS);
list
};
let recipes: Vec<BrowserRecipe> = recipes
.into_iter()
.take(RECIPES)
.map(|r| BrowserRecipe {
width: r.width.clamp(1, 3),
height: r.height.clamp(1, 3),
slots: r.slots.into_iter().take(RECIPE_SLOTS).map(cycle).collect(),
results: cycle(r.results),
..r
})
.collect();
(!recipes.is_empty()).then(|| Self {
title: title.to_owned(),
tabs: tabs.into_iter().take(TABS).collect(),
tab,
recipes,
})
}
}
/// One recipe as the game draws it.
pub(super) struct Shown {
layout: BrowserLayout,
@@ -231,7 +180,7 @@ impl Game<'_> {
}
/// The recipes on the open page, each with its corner.
fn recipes_shown(&self, layout: &Layout) -> Vec<(usize, (f32, f32))> {
let Some(pages) = &self.panel.recipes else {
let Some(pages) = &self.mods.browser.recipes else {
return Vec::new();
};
let starts = Self::recipe_pages(pages, layout);
@@ -253,7 +202,7 @@ impl Game<'_> {
}
/// What a slot of a shown recipe holds now: its stacks cycle once a second.
fn recipe_slot_now(&self, recipe: usize, slot: usize) -> Option<&(BrowserStack, ItemStack)> {
let pages = self.panel.recipes.as_ref()?;
let pages = self.mods.browser.recipes.as_ref()?;
let stacks = pages.recipes.get(recipe)?.slots.get(slot)?;
let n = pages.since.elapsed().as_secs() as usize;
stacks.get(n % stacks.len().max(1))
@@ -263,7 +212,7 @@ impl Game<'_> {
&self,
layout: &Layout,
) -> Option<(usize, BrowserStack, ItemStack)> {
let pages = self.panel.recipes.as_ref()?;
let pages = self.mods.browser.recipes.as_ref()?;
let places = tab_places(pages, layout);
places.iter().enumerate().find_map(|(i, &(x, y))| {
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
@@ -278,7 +227,7 @@ impl Game<'_> {
&self,
layout: &Layout,
) -> Option<(usize, usize, BrowserStack, ItemStack)> {
let pages = self.panel.recipes.as_ref()?;
let pages = self.mods.browser.recipes.as_ref()?;
self.recipes_shown(layout)
.into_iter()
.find_map(|(i, (x, y))| {
@@ -295,7 +244,7 @@ impl Game<'_> {
/// Draws the title row, the page's recipes and the page arrows.
pub(super) fn draw_browser_recipes(&mut self, batch: &mut HudBatch, layout: &Layout) {
let Some(pages) = &self.panel.recipes else {
let Some(pages) = &self.mods.browser.recipes else {
return;
};
let cursor = self.cursor;
@@ -375,7 +324,7 @@ impl Game<'_> {
batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true);
}
for (i, (x, y)) in self.recipes_shown(layout) {
let Some(r) = self.panel.recipes.as_ref().map(|p| &p.recipes[i]) else {
let Some(r) = self.mods.browser.recipes.as_ref().map(|p| &p.recipes[i]) else {
break;
};
let (at, arrow, action) = r.places(layout.width());
@@ -456,7 +405,7 @@ impl Game<'_> {
/// The tabs, on slots; the selected one framed.
fn draw_recipe_tabs(&mut self, batch: &mut HudBatch, layout: &Layout) {
let Some(pages) = &self.panel.recipes else {
let Some(pages) = &self.mods.browser.recipes else {
return;
};
let places = tab_places(pages, layout);
@@ -502,10 +451,10 @@ impl Game<'_> {
pub(super) fn browser_recipes_press(
&mut self,
layout: &Layout,
owner: &Owner,
owner: usize,
right: bool,
) -> bool {
let Some(pages) = &self.panel.recipes else {
let Some(pages) = &self.mods.browser.recipes else {
return false;
};
let cursor = self.cursor;
@@ -520,34 +469,38 @@ impl Game<'_> {
None
};
if let Some(to) = turn {
if let Some(p) = self.panel.recipes.as_mut() {
if let Some(p) = self.mods.browser.recipes.as_mut() {
p.page = to;
}
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.panel_event(owner, BrowserEvent::Page(starts[to] as u32));
self.mods
.mods
.browser_event(owner, BrowserEvent::Page(starts[to] as u32));
return true;
}
if let Some((tab, _, _)) = self.recipe_tab_hovered(layout) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.panel_event(owner, BrowserEvent::Tab(tab as u32));
self.mods
.mods
.browser_event(owner, BrowserEvent::Tab(tab as u32));
return true;
}
if inside(cursor, self.back_button(layout).0) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.panel_event(owner, BrowserEvent::Back);
self.mods.mods.browser_event(owner, BrowserEvent::Back);
return true;
}
if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) {
let event = BrowserEvent::SlotClicked {
let event = BrowserEvent::SlotClicked(BrowserSlotClick {
recipe: recipe as u32,
slot: slot as u32,
stack,
right,
};
self.panel_event(owner, event);
});
self.mods.mods.browser_event(owner, event);
return true;
}
let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| {
@@ -559,7 +512,9 @@ impl Game<'_> {
if pages.recipes[i].action == Some(true) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.panel_event(owner, BrowserEvent::Action(i as u32));
self.mods
.mods
.browser_event(owner, BrowserEvent::Action(i as u32));
}
return true;
}
@@ -130,7 +130,6 @@ impl Game<'_> {
&& let Some(name) = horizon_client::keybinds::key_name(key)
&& let Some(stack) = self.mod_browser_pointed()
{
let stack = super::mod_panel::browser_stack(stack);
self.mods.mods.browser_key(name, &stack);
return;
}
-114
View File
@@ -1,114 +0,0 @@
//! Capability `ui`'s item panel of a WebAssembly mod (WIT `interface
//! browser`, docs/mods.md): its items and recipe page as the native panel's
//! (`native_panel.rs`), and the panel's events back in the runtime's types.
use super::{
Game,
native_hooks::{
NativePanelEvent as BrowserEvent, NativeRecipeLayout, NativeRecipeView,
NativeStack as BrowserStack,
},
native_panel::{Owner, Shown},
native_panel_recipes::RecipePage,
};
use horizon_mods as m;
/// A WebAssembly mod's panel, converted once per `set-items` or
/// `set-recipes`.
impl Game<'_> {
pub(super) fn panel_wasm_sync(&mut self) {
let Some((index, version, items)) = self.mods.mods.browser_items() else {
self.panel.wasm = None;
return;
};
let owner = Owner::Wasm(index);
let wasm = self.panel.wasm.as_ref();
if wasm.is_some_and(|s| s.owner == owner && s.version == version) {
return;
}
let items = items.iter().map(stack).collect();
let recipes = self.mods.mods.browser_recipes(index).map(page);
self.panel.wasm = Some(Shown {
owner,
version,
items,
recipes,
});
}
}
pub(super) fn stack(s: &m::BrowserStack) -> BrowserStack {
BrowserStack {
item: s.item.clone(),
count: s.count,
components: s.components.clone(),
}
}
/// A native stack as the runtime's (`browser_key`).
pub(super) fn browser_stack(s: BrowserStack) -> m::BrowserStack {
m::BrowserStack {
item: s.item,
count: s.count,
components: s.components,
}
}
fn layout(l: m::BrowserLayout) -> NativeRecipeLayout {
match l {
m::BrowserLayout::Grid => NativeRecipeLayout::Grid,
m::BrowserLayout::Cooking => NativeRecipeLayout::Cooking,
m::BrowserLayout::Single => NativeRecipeLayout::Single,
m::BrowserLayout::Smithing => NativeRecipeLayout::Smithing,
m::BrowserLayout::Brewing => NativeRecipeLayout::Brewing,
}
}
fn stacks(list: &[m::BrowserStack]) -> Vec<BrowserStack> {
list.iter().map(stack).collect()
}
pub(super) fn page(p: &m::RecipePage) -> RecipePage {
RecipePage {
title: p.title.clone(),
tabs: stacks(&p.tabs),
tab: p.tab,
recipes: p
.recipes
.iter()
.map(|r| NativeRecipeView {
layout: layout(r.layout),
width: r.width,
height: r.height,
slots: r.slots.iter().map(|s| stacks(s)).collect(),
results: stacks(&r.results),
note: r.note.clone(),
action: r.action,
})
.collect(),
}
}
pub(super) fn event(e: BrowserEvent) -> m::BrowserEvent {
match e {
BrowserEvent::Clicked { index, right } => {
m::BrowserEvent::Clicked(m::BrowserClick { index, right })
}
BrowserEvent::Search(text) => m::BrowserEvent::Search(text),
// Not sent to WebAssembly mods: they hear their own keys
// (`browser_key`).
BrowserEvent::Key { key, stack } => m::BrowserEvent::Key(m::BrowserItemKey {
key,
stack: browser_stack(stack),
}),
BrowserEvent::SlotClicked {
recipe,
slot,
stack,
right,
} => m::BrowserEvent::SlotClicked(m::BrowserSlotClick {
recipe,
slot,
stack: browser_stack(stack),
right,
}),
BrowserEvent::Action(i) => m::BrowserEvent::Action(i),
BrowserEvent::Page(i) => m::BrowserEvent::Page(i),
BrowserEvent::Back => m::BrowserEvent::Back,
BrowserEvent::Tab(i) => m::BrowserEvent::Tab(i),
}
}
@@ -1,33 +1,18 @@
//! Every item and recipe the game knows, made once for the mods that read
//! them (native mods' `native_recipes`, capability `recipes` of WebAssembly
//! mods, docs/mods.md), and the recipe book's known displays; placing a
//! recipe and creative gifts. The recipes are the window's own data: the
//! world's in single player, vanilla's on a server (a client is sent no
//! others), while the known displays follow the server's book as the recipe
//! book does.
use super::{
Game,
native_hooks::{
NativeItem as RecipeItem, NativeRecipe as Recipe, NativeRecipeKind as RecipeKind,
NativeRecipeStack as RecipeStack, NativeRecipes as Recipes,
},
};
//! Capability `recipes` (docs/mods.md): every item and recipe the game
//! knows, made once for the mods that read them, and the recipe book's
//! known displays. The recipes are the window's own data: the world's in
//! single player, vanilla's on a server (a client is sent no others), while
//! the known displays follow the server's book as the recipe book does.
use super::Game;
use horizon_client::{
crafting::Ingredient,
items::ItemStack,
recipe_book::{Category, Display},
stations::Smithing,
};
use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
use std::{collections::HashMap, sync::Arc};
/// The tables, made once, and the recipe book's known displays with the
/// book's version they are of.
#[derive(Default)]
pub(super) struct RecipeTables {
tables: Option<Arc<Recipes>>,
known: (Option<u64>, Arc<[i32]>),
}
/// Item id -> its index in [`Recipes::items`].
struct Index(HashMap<Arc<str>, u32>);
impl Index {
@@ -85,34 +70,6 @@ fn recipe(id: String, kind: RecipeKind) -> Recipe {
}
impl Game<'_> {
/// `native_give`: `count` of `item`, refused outside a world, unless
/// creative and for an item the game doesn't have.
pub(super) fn recipe_give(&mut self, item: &str, count: u32) -> Result<(), String> {
if self.player.is_none() || self.res.rest.get().is_none() {
return Err("not in a world".into());
}
if !self.creative {
return Err("only in creative mode".into());
}
if self.res.items().get(item).is_none() {
return Err(format!("no item {item}"));
}
self.mod_give(item, count.clamp(1, 99), "");
Ok(())
}
/// `native_place_recipe`: refused as `recipes.place` refuses it.
pub(super) fn recipe_place(&mut self, book_id: i32, all: bool) -> Result<(), String> {
if !self.mod_recipe_screen() {
return Err("no screen that takes recipes is open".into());
}
if !self.known_displays().contains(&book_id) {
return Err(format!(
"recipe {book_id} is not in the player's recipe book"
));
}
self.mod_place_recipe(book_id, all);
Ok(())
}
/// `recipes.give`: a creative player takes the stack as from the
/// creative inventory; a server hears it as `SetCreativeModeSlot`
/// through the inventory's sync, and refuses it unless creative.
@@ -135,25 +92,22 @@ impl Game<'_> {
extra,
});
}
/// The tables, made on the first call; none while the world resources
/// they come from are still loading behind the title on a slow start.
pub(super) fn made_recipes(&mut self) -> Option<Arc<Recipes>> {
self.res.rest.get()?;
if self.recipe_tables.tables.is_none() {
self.recipe_tables.tables = Some(Arc::new(self.make_mod_recipes()));
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
// The tables come from the world resources, which may still be
// loading behind the title on a slow start.
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
return (None, Arc::from([]));
}
self.recipe_tables.tables.clone()
}
/// The `book_id`s the player's recipe book holds now.
pub(super) fn known_displays(&mut self) -> Arc<[i32]> {
if self.res.rest.get().is_none() {
return Arc::from([]);
if self.mods.recipes.is_none() {
self.mods.recipes = Some(Arc::new(self.make_mod_recipes()));
}
let (version, known) = self.recipe_book_displays();
if self.recipe_tables.known.0 != Some(version) {
self.recipe_tables.known = (Some(version), known.into());
if self.mods.known_recipes.0 != Some(version) {
self.mods.known_recipes = (Some(version), known.into());
}
self.recipe_tables.known.1.clone()
(self.mods.recipes.clone(), self.mods.known_recipes.1.clone())
}
fn make_mod_recipes(&mut self) -> Recipes {
@@ -320,78 +274,3 @@ impl Game<'_> {
}
}
}
/// Capability `recipes` of WebAssembly mods: the same tables in the
/// runtime's types.
#[cfg(feature = "mods")]
impl Game<'_> {
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<horizon_mods::Recipes>>, Arc<[i32]>) {
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
return (None, Arc::from([]));
}
if self.mods.recipes.is_none() {
self.mods.recipes = self.made_recipes().map(|t| Arc::new(wasm::recipes(&t)));
}
(self.mods.recipes.clone(), self.known_displays())
}
}
/// The native tables as the WebAssembly runtime's.
#[cfg(feature = "mods")]
mod wasm {
use super::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
use horizon_mods as m;
fn kind(k: RecipeKind) -> m::RecipeKind {
match k {
RecipeKind::CraftingShaped => m::RecipeKind::CraftingShaped,
RecipeKind::CraftingShapeless => m::RecipeKind::CraftingShapeless,
RecipeKind::Smelting => m::RecipeKind::Smelting,
RecipeKind::Blasting => m::RecipeKind::Blasting,
RecipeKind::Smoking => m::RecipeKind::Smoking,
RecipeKind::Campfire => m::RecipeKind::Campfire,
RecipeKind::Stonecutting => m::RecipeKind::Stonecutting,
RecipeKind::Smithing => m::RecipeKind::Smithing,
RecipeKind::Brewing => m::RecipeKind::Brewing,
}
}
fn stacks(list: &[RecipeStack]) -> Vec<m::RecipeStack> {
list.iter()
.map(|s| m::RecipeStack {
item: s.item,
count: s.count,
components: s.components.clone(),
})
.collect()
}
fn recipe(r: &Recipe) -> m::Recipe {
m::Recipe {
id: r.id.clone(),
kind: kind(r.kind),
category: r.category.clone(),
group: r.group.clone(),
width: r.width,
height: r.height,
slots: r.slots.iter().map(|s| stacks(s)).collect(),
results: stacks(&r.results),
cooking_time: r.cooking_time,
experience: r.experience,
book_id: r.book_id,
}
}
fn item(i: &RecipeItem) -> m::RecipeItem {
m::RecipeItem {
id: i.id.clone(),
name: i.name.clone(),
tags: i.tags.clone(),
}
}
pub(super) fn recipes(t: &Recipes) -> m::Recipes {
m::Recipes {
items: t.items.iter().map(item).collect(),
recipes: t.recipes.iter().map(recipe).collect(),
}
}
}
+8 -1
View File
@@ -92,6 +92,12 @@ pub(super) struct ModsState {
/// Capability `recipes`'s tables, made once a mod reads them.
#[cfg(feature = "mods")]
pub(super) recipes: Option<std::sync::Arc<horizon_mods::Recipes>>,
/// The recipe book's known displays, and the book's version they are of.
#[cfg(feature = "mods")]
pub(super) known_recipes: (Option<u64>, std::sync::Arc<[i32]>),
/// Capability `ui`'s item panel beside container screens.
#[cfg(feature = "mods")]
pub(super) browser: super::mod_browser::Browser,
/// Extras that are official mods now, being installed.
#[cfg(feature = "mods")]
pub(super) moved: super::moved_extras::MovedState,
@@ -494,7 +500,8 @@ impl Game<'_> {
if !self.mods.mods.wants_raycast() {
return None;
}
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach = f64::from(horizon_mods::RAYCAST_MAX);
let (pos, point, state) = match self.clip_ray(eye, dir, reach) {
Some(hit) => (
+23 -37
View File
@@ -1,9 +1,7 @@
//! The horse inventory (`HorseInventoryMenu`, `HorseInventoryScreen`): the
//! saddle and body armour slots (a llama's carpet, a nautilus's armour), the
//! chest of donkeys, mules and llamas and the mount drawn following the
//! cursor. A nautilus has the same two slots and no chest
//! (`NautilusInventoryMenu`). It opens with E while riding a tamed mount or
//! with shift + use on a tamed one. On a
//! saddle and body armour slots (a llama's carpet), the chest of donkeys,
//! mules and llamas and the mount drawn following the cursor. It opens with
//! E while riding a tamed mount or with shift + use on a tamed one. On a
//! server the screen shows the server's menu (`ClientboundMountScreenOpenPacket`)
//! and its clicks go out as container clicks; a server's players get theirs
//! from game/lan_mount_menu.rs.
@@ -11,7 +9,7 @@ use super::{Game, Screen};
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
mobs::{Mob, Species, equine, llama, nautilus},
mobs::{Mob, Species, equine, llama},
};
/// Menu slot ids: the saddle, the body armour and the chest from 710.
@@ -34,18 +32,18 @@ pub(super) fn mount_columns(m: &Mob) -> usize {
0
}
}
/// Whether the mount has a horse inventory (`AbstractHorse`, `AbstractNautilus`).
/// Whether the mount has a horse inventory (`AbstractHorse`).
pub(super) fn has_mount_menu(species: Species) -> bool {
equine::is_equine(species) || llama::is_llama(species) || nautilus::is_nautilus(species)
equine::is_equine(species) || llama::is_llama(species)
}
/// The saddle slot is active (`canUseSlot(SADDLE)` and `CAN_EQUIP_SADDLE`):
/// not on llamas.
fn shows_saddle(species: Species) -> bool {
!llama::is_llama(species)
}
/// The body slot is active: horse armour, a llama's carpet, or nautilus armour.
/// The body slot is active: horse armour, or a llama's carpet.
fn shows_body(species: Species) -> bool {
can_wear_horse_armor(species) || llama::is_llama(species) || nautilus::is_nautilus(species)
can_wear_horse_armor(species) || llama::is_llama(species)
}
/// The open mount menu: copies of the mount's slots, written back on every change.
@@ -80,10 +78,6 @@ fn body_armor_fits(species: Species, item: &str) -> bool {
if llama::is_llama(species) {
return item.ends_with("_carpet");
}
// EntityTypeTags.CAN_WEAR_NAUTILUS_ARMOR: the nautilus armours.
if nautilus::is_nautilus(species) {
return item.ends_with("_nautilus_armor");
}
can_wear_horse_armor(species) && item.ends_with("_horse_armor")
}
@@ -268,26 +262,20 @@ impl Game<'_> {
) {
let m = &self.mobs[index];
let at = m.center();
let saddle_put_on = saddle.is_some() && !m.extra.saddled;
let put_on = if saddle_put_on {
let put_on = if saddle.is_some() && !m.extra.saddled {
saddle.as_ref()
} else {
body.as_ref()
.and_then(Option::as_ref)
.filter(|_| m.extra.head.is_none())
};
let sound = if saddle_put_on && nautilus::is_nautilus(m.species()) {
// AbstractNautilus.getEquipSound: its own saddle sounds.
Some(format!("minecraft:{}", nautilus::saddle_sound(m)))
} else {
put_on.map(|s| {
self.res
.items()
.get(&s.item)
.and_then(|i| i.equip_sound.clone())
.unwrap_or_else(|| "minecraft:item.armor.equip_generic".into())
})
};
let sound = put_on.map(|s| {
self.res
.items()
.get(&s.item)
.and_then(|i| i.equip_sound.clone())
.unwrap_or_else(|| "minecraft:item.armor.equip_generic".into())
});
let m = &mut self.mobs[index];
m.set_saddle(saddle);
if let Some(body) = body {
@@ -432,13 +420,13 @@ impl Game<'_> {
return;
};
let atlas = &self.gui.atlas;
// NautilusInventoryScreen draws its own background.
let background = if nautilus::is_nautilus(open.species) {
"gui/container/nautilus"
} else {
"gui/container/horse"
};
batch.sprite_region(atlas, background, [ox, oy, 176.0, 166.0], 256.0, 256.0);
batch.sprite_region(
atlas,
"gui/container/horse",
[ox, oy, 176.0, 166.0],
256.0,
256.0,
);
if open.columns > 0 {
batch.sprite_region(
atlas,
@@ -464,8 +452,6 @@ impl Game<'_> {
if open.armor.is_none() {
let sprite = if llama::is_llama(open.species) {
"gui/sprites/container/slot/llama_armor"
} else if nautilus::is_nautilus(open.species) {
"gui/sprites/container/slot/nautilus_armor_inventory"
} else {
"gui/sprites/container/slot/horse_armor"
};
+9 -13
View File
@@ -87,20 +87,16 @@ impl Game<'_> {
return false;
}
if !self.creative
&& let Some(stack) = self.inventory[self.selected].clone()
&& let Some(stack) = self.inventory[self.selected].as_mut()
{
self.trigger_item_durability(&stack);
if let Some(stack) = self.inventory[self.selected].as_mut() {
stack.damage += wear;
let max = self
.res
.items()
.get(&stack.item)
.map_or(25, |i| i.max_damage);
if stack.damage >= max {
self.inventory[self.selected] =
Some(ItemStack::new("minecraft:fishing_rod", 1));
}
stack.damage += wear;
let max = self
.res
.items()
.get(&stack.item)
.map_or(25, |i| i.max_damage);
if stack.damage >= max {
self.inventory[self.selected] = Some(ItemStack::new("minecraft:fishing_rod", 1));
}
}
self.swing_arm();
+3 -230
View File
@@ -6,12 +6,6 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 2 adds the item panel beside container screens and its recipe pages
//! (`native_panel_items`, `native_panel_recipes`, `native_panel_event`),
//! and the game's items and recipes (`native_recipes`,
//! `native_known_recipes`, `native_place_recipe`, `native_give`): what a
//! recipe viewer needs.
//!
//! Names, parameters and call sites are kept from release to release, and
//! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`].
//! A bundle that hooks only these names the API it needs (`hooks`), and the
@@ -19,7 +13,7 @@
//! that would break such a bundle is not made.
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
//! either (`tests::hook_points_stay_hookable`).
use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::RecipePage};
use super::{Game, Screen, execution_role::ExecutionRole};
use horizon_client::{
chat::ChatSource,
extras::Corner,
@@ -29,13 +23,13 @@ use horizon_client::{
mod_list::{self, Installed, NativePage},
text::Text,
};
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
use std::{fs, path::PathBuf, time::Duration};
use winit::{event::MouseButton, keyboard::KeyCode};
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 2;
pub(crate) const NATIVE_HOOKS_API: u32 = 1;
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none),
/// eased through the field of view like the spyglass when `smooth`, and the
@@ -86,164 +80,6 @@ pub(crate) struct NativeMod {
pub(crate) keys: Vec<[String; 3]>,
}
/// An item the panel shows (`native_panel_items`): its id
/// (`minecraft:diamond`), a count (1 draws none) and its components beyond
/// the item's own, in vanilla's item syntax, or "".
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct NativeStack {
pub(crate) item: String,
pub(crate) count: u32,
pub(crate) components: String,
}
/// How the panel lays out a recipe of `native_panel_recipes`.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NativeRecipeLayout {
/// A crafting grid of `width` x `height` slots, an arrow and the result.
Grid,
/// One input over a flame, an arrow and the result (furnaces, smokers,
/// campfires).
Cooking,
/// One input, an arrow and the result (the stonecutter).
Single,
/// Template, base and addition, an arrow and the result.
Smithing,
/// A potion and an ingredient, an arrow and the result.
Brewing,
}
/// A recipe the panel shows (`native_panel_recipes`). Each slot, and the
/// result, cycles through its stacks, one a second; an empty slot shows
/// nothing.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeRecipeView {
pub(crate) layout: NativeRecipeLayout,
/// The grid's size (`Grid` only), at most 3 x 3.
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) slots: Vec<Vec<NativeStack>>,
pub(crate) results: Vec<NativeStack>,
/// A short line under the recipe (a cooking time), or "".
pub(crate) note: String,
/// The action button beside it: none, enabled, or shown greyed.
pub(crate) action: Option<bool>,
}
/// What the player did on a native mod's panel (`native_panel_event`).
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum NativePanelEvent {
/// A click on an item of the panel, by its index in
/// `native_panel_items`' list.
Clicked { index: u32, right: bool },
/// The search box's text, each time the player changes it.
Search(String),
/// A key (`key.keyboard.r`; compare it with `native_mod_key`) went down
/// while the pointer was on an item: a slot of the screen's, or the
/// panel's.
Key { key: String, stack: NativeStack },
/// A click on a slot of a recipe the panel shows: the recipe's index in
/// `native_panel_recipes`' list and the slot's in it (the results come
/// after the slots), and the stack it showed then.
SlotClicked {
recipe: u32,
slot: u32,
stack: NativeStack,
right: bool,
},
/// The action button of the recipe at this index.
Action(u32),
/// The player turned the recipes' page: the index of the first recipe
/// now shown.
Page(u32),
/// The recipe page's back button.
Back,
/// The player picked the tab at this index.
Tab(u32),
}
/// An item of [`NativeRecipes::items`], by its index there, with a count
/// and the components it has beyond the item's own, in vanilla's item
/// syntax (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`),
/// or "".
#[allow(dead_code)]
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct NativeRecipeStack {
pub(crate) item: u32,
pub(crate) count: u32,
pub(crate) components: String,
}
/// The station a recipe is made at.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NativeRecipeKind {
CraftingShaped,
CraftingShapeless,
Smelting,
Blasting,
Smoking,
Campfire,
Stonecutting,
Smithing,
Brewing,
}
/// A recipe of [`NativeRecipes`].
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeRecipe {
/// `minecraft:oak_planks`.
pub(crate) id: String,
pub(crate) kind: NativeRecipeKind,
/// The recipe book's tab (`crafting_building_blocks`, `furnace_food`),
/// or "".
pub(crate) category: String,
/// Recipes the book shows on one button share a group, or "".
pub(crate) group: String,
/// A shaped recipe's grid: `slots` holds its cells row by row. 0 for
/// the other kinds.
pub(crate) width: u32,
pub(crate) height: u32,
/// Each slot's stacks, any one of which fits; an empty list is an empty
/// cell. Cooking and stonecutting: the input. Smithing: the template,
/// the base, the addition. Brewing: the bottle, the reagent.
pub(crate) slots: Vec<Vec<NativeRecipeStack>>,
/// What it makes; several when that varies (a trim on any armour).
pub(crate) results: Vec<NativeRecipeStack>,
/// Cooking: game ticks and experience; 0 for the other kinds.
pub(crate) cooking_time: u32,
pub(crate) experience: f32,
/// The recipe book's id for it (one per way the book shows it), as
/// `native_known_recipes` lists them; -1 for the kinds the book doesn't
/// show (campfire, stonecutting, smithing, brewing).
pub(crate) book_id: i32,
}
/// An item of [`NativeRecipes::items`].
#[allow(dead_code)]
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct NativeItem {
/// `minecraft:diamond`, `<mod>:<path>`.
pub(crate) id: String,
/// In the player's language.
pub(crate) name: String,
/// Its item tags, without the `#`.
pub(crate) tags: Vec<String>,
}
/// Every item and recipe the game knows (`native_recipes`): `items` in the
/// registry's order, which each [`NativeRecipeStack::item`] indexes; the
/// recipes are the book's in its order, then campfire, stonecutting,
/// smithing and brewing.
#[allow(dead_code)]
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct NativeRecipes {
pub(crate) items: Vec<NativeItem>,
pub(crate) recipes: Vec<NativeRecipe>,
}
/// The mappings `native_mapping` answers for, with the key their default
/// binding is (none: a mouse button, attack or use).
#[allow(dead_code)]
@@ -313,11 +149,6 @@ impl Game<'_> {
pub(super) fn native_settings_click(&mut self, mod_id: &str, button: usize) {
let _ = (mod_id, button);
}
/// The player did `event` on mod `mod_id`'s panel (`native_panel_items`):
/// act on it when it is yours. API 2.
pub(super) fn native_panel_event(&mut self, mod_id: &str, event: &NativePanelEvent) {
let _ = (mod_id, event);
}
}
/// Helpers for native mods, kept as stable as the hook points.
@@ -439,61 +270,6 @@ impl Game<'_> {
let dir = self.native_data_dir(mod_id);
let _ = fs::create_dir_all(&dir).and_then(|()| fs::write(dir.join(key), value));
}
/// Shows the panel beside container screens with these items (at most
/// 8192), as the game's own slots in pages, with a search box; the
/// player's clicks, searches and keys over items come back as
/// `native_panel_event`. The panel is the last mod's to show one; empty
/// items and no recipe page take yours away.
pub(super) fn native_panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>) {
self.panel.show_items(mod_id, items);
}
/// Shows these recipes in the panel instead of its items, as many as
/// fit a page, under `title` and a back button, with a row of `tabs`
/// (each an icon; `tab` is the selected one) between them. At most 256
/// recipes and 16 tabs; no recipes shows the items again. The same title
/// and tab again keep the page the player turned to.
pub(super) fn native_panel_recipes(
&mut self,
mod_id: &str,
title: &str,
tabs: Vec<NativeStack>,
tab: u32,
views: Vec<NativeRecipeView>,
) {
let page = RecipePage::new(title, tabs, tab, views);
self.panel.show_recipes(mod_id, page);
}
/// Every item and recipe the game knows, made once (the same for every
/// call); none outside a world. They are the window's own: the world's
/// in single player, vanilla's on a server, which sends a client no
/// others.
pub(super) fn native_recipes(&mut self) -> Option<Arc<NativeRecipes>> {
self.player.as_ref()?;
self.made_recipes()
}
/// The `book_id`s of the recipes the player's recipe book holds now
/// (the server's book on a server); empty outside a world.
pub(super) fn native_known_recipes(&mut self) -> Arc<[i32]> {
if self.player.is_none() {
return Arc::from([]);
}
self.known_displays()
}
/// Places a recipe in the open screen's grid as a click in the recipe
/// book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a
/// crafting table's. `all` places as many as the items allow, as with
/// Shift. Refused while no such screen is open and for a recipe the
/// player's book doesn't hold; one missing items shows as a ghost.
pub(super) fn native_place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> {
self.recipe_place(book_id, all)
}
/// Creative mode only: gives the player `count` of `item`
/// (`minecraft:diamond`) as taking it from the creative inventory does
/// (`ServerboundSetCreativeModeSlot`): the selected slot first, then the
/// hotbar, then the inventory. The count is cut to one stack.
pub(super) fn native_give(&mut self, item: &str, count: u32) -> Result<(), String> {
self.recipe_give(item, count)
}
}
/// Where the game calls the hook points.
@@ -546,9 +322,6 @@ impl Game<'_> {
return;
}
if let Some(name) = keybinds::key_name(key) {
if pressed {
self.native_panel_key(name);
}
self.native_key(name, pressed);
}
}
+2 -4
View File
@@ -67,11 +67,9 @@ impl Game<'_> {
self.nautilus_sound(index, sound);
return true;
}
// AbstractNautilus.mobInteract: shift + use on a tame adult opens its
// saddle and armour screen (openCustomInventoryScreen).
// Shift-use on a tame one opens its (saddle and armour) screen.
if tame && sneaking {
self.open_mount_menu(index);
return true;
return false;
}
if !item.is_empty() {
if !tame && food {
+3 -2
View File
@@ -1,7 +1,7 @@
//! Paintings: `HangingEntityItem.useOn` placement with `Painting.create`'s
//! variant choice, `HangingEntity.survives` checks every 100 ticks,
//! breaking by attack (`dropItem`), saving, and `PaintingRenderer` quads.
use super::{Game, Hit};
use super::{Game, Hit, view_vector};
use horizon_client::{entities::Painting, items::ItemStack, mesher::Vertex};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -171,7 +171,8 @@ impl Game<'_> {
}
/// The painting under the crosshair within reach and its distance.
pub(super) fn painting_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64)> = None;
for (i, p) in self.paintings.iter().enumerate() {
-20
View File
@@ -374,12 +374,6 @@ impl Game<'_> {
}
let riders = self.take_mob_passengers(&mut tag);
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
// `NeutralMob.readPersistentAngerSaveData`: the anger runs until its
// end time, and the mob is angry at the target again.
m.extra.anger = horizon_client::mobs::anger_left(m.extra.anger_end, self.world.game_time);
if m.extra.anger > 0 && m.extra.angry_at.is_some() {
m.ai.target = Some(horizon_client::mobs::Who::Player);
}
m.extra.mount = mount;
m.extra.passenger = mount.is_some();
if finalize {
@@ -537,20 +531,6 @@ impl Game<'_> {
pub(super) fn mob_tree_nbt(&mut self, i: usize) -> Tag {
let id = self.mobs[i].id;
let uuid = self.uuid_for(id);
// `NeutralMob.addPersistentAngerSaveData`: the anger ends at an
// absolute game time, so it goes on while the chunk is unloaded, and
// the target is the entity the mob is angry at.
let anger = self.mobs[i].extra.anger;
self.mobs[i].extra.anger_end = if anger > 0 {
self.world.game_time + i64::from(anger)
} else {
-1
};
self.mobs[i].extra.angry_at = if anger > 0 {
self.mobs[i].ai.target
} else {
None
};
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
self.write_leash(&mut tag, i);
self.put_mob_passengers(&mut tag, id);
+3 -11
View File
@@ -357,13 +357,9 @@ impl Game<'_> {
let off = self.offhand.as_ref().map(|s| s.item.clone());
let riding = self.riding.is_some();
let hurt = p.health.hurt_animation_time;
// LivingEntity.getBedOrientation: the model lies in the bed as
// another player's does (`villager::sleep_pose`).
let bed = self.sleeping.map_or(0, |head| self.bed_facing(head));
let Some(m) = self.player_model.as_mut() else {
return;
};
m.extra.ints[1] = bed;
let at = Vec3::new(
position.x,
position.y - if crouching { 0.125 } else { 0.0 },
@@ -434,15 +430,11 @@ impl Game<'_> {
m.extra.swing_right_arm = swing_right_arm;
m.hurt_time = hurt;
}
/// `LevelExtractor`'s skip of the camera entity: the player's own body
/// is drawn unless the camera is in first person, or while it sleeps.
pub(super) fn own_body_in_view(&self) -> bool {
!self.camera_type.first_person() || self.sleeping.is_some()
}
/// Whether the player's own model shows in the world view.
pub(super) fn player_model_shown(&self) -> bool {
fn player_model_shown(&self) -> bool {
// A shader pack's shadow pass draws the player in first person too (shadowPlayer).
(self.own_body_in_view() || self.pack_shadow_casters.1)
(!self.camera_type.first_person() || self.pack_shadow_casters.1)
&& self.sleeping.is_none()
&& !self.spectator()
&& self.potions.effects.get("minecraft:invisibility").is_none()
&& self
Loaded 100 of 432 files, more files were not shown because too many files have changed in this diff. Show more