Merge branch 'feat/mod-recipe-pages' into feat/mod-recipe-pages-game

This commit is contained in:
Ludovic Marie committed 2026-10-26 21:52:49 +01:00
commit 0cba2edbd4
469 files changed
+18183 -18596

No files matched your search

-1
View File
@@ -2,7 +2,6 @@
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
+1 -1
View File
@@ -2,5 +2,5 @@
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+36
View File
@@ -73,6 +73,42 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request change the launcher or a crate it builds?
if: github.event_name == 'pull_request'
id: launcher
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust and the headers GPUI links against
if: steps.launcher.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false'
with:
workspaces: launcher
shared-key: launcher
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check the launcher's formatting, lint and tests
if: steps.launcher.outputs.required != 'false'
working-directory: launcher
run: |
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
+6 -3
View File
@@ -48,6 +48,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
- name: The harnesses the merges since then can affect
id: harnesses
# A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
continue-on-error: true
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
@@ -55,8 +58,8 @@ jobs:
echo "Merges since ${BASE_SHA}:"
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
- name: The merge that broke them
if: failure()
- name: A flake, or the merge that broke them
if: steps.harnesses.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
PARITY_HOSTS: ''
@@ -65,7 +68,7 @@ jobs:
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
if: failure()
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: parity-main-logs
+2 -1
View File
@@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
+2 -2
View File
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
Generated
+16
View File
@@ -216,6 +216,13 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "beacons"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -1368,6 +1375,7 @@ dependencies = [
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1426,6 +1434,14 @@ dependencies = [
"spake2",
]
[[package]]
name = "horizon-hdr"
version = "0.1.0"
dependencies = [
"ash",
"wgpu",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
resolver = "2"
[workspace.package]
+68 -54
View File
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
</details>
@@ -160,6 +160,66 @@ Translatable components resolved through the selected language for items, contai
</details>
#### Cooldowns drawn on items
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
</details>
#### Chat settings and team prefixes
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
</details>
#### A pointing hand over buttons
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
</details>
#### Ctrl+Backspace and scroll bars in menus
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
</details>
#### Chickens flap while they fall
Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
</details>
### Rendering
#### Shader packs, written again from scratch
@@ -250,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
@@ -540,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
</details>
@@ -876,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
@@ -902,56 +966,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
</details>
#### Cooldowns drawn on items
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
</details>
#### Chat settings and team prefixes
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
</details>
#### A pointing hand over buttons
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
</details>
#### Ctrl+Backspace and scroll bars in menus
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
</details>
#### Chickens flap while they fall
Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
</details>
## Recently shipped
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
+2 -1
View File
@@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
`crates/horizon-theme/icons/pixel/*.svg` are icons from
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
+5
View File
@@ -266,6 +266,11 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::SetBlocks {
dimension, blocks, ..
} => {
format!("set {} block(s) in {dimension}", blocks.len())
}
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::Shake { strength, seconds } => {
+10 -7
View File
@@ -1031,19 +1031,22 @@ pub struct ItemModels {
}
impl ItemModels {
pub fn components(&self, stack: &StackView) -> Components {
let mut out = self
.prototypes
.get(&stack.item)
.cloned()
.unwrap_or_default();
/// The stack's components: its item's defaults with the stack's patch on top. A stack
/// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
static NONE: Components = BTreeMap::new();
let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
if stack.patch.is_empty() {
return std::borrow::Cow::Borrowed(prototype);
}
let mut out = prototype.clone();
for (key, value) in &stack.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
out
std::borrow::Cow::Owned(out)
}
/// `ItemModelResolver.updateForTopItem`.
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
+1
View File
@@ -12,6 +12,7 @@ horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
@@ -0,0 +1,48 @@
use horizon_client::{
Result,
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let items = Items::catalog(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let mut stack = ItemStack::new(
row["item"].as_str().unwrap(),
row["count"].as_u64().unwrap() as u32,
);
match row["tool"].as_str().unwrap() {
"default" => {}
mode => {
let mut data = StackData::default();
let tool = if mode == "implicit" {
Tag::compound([] as [(&str, Tag); 0])
} else {
Tag::compound([(
"can_destroy_blocks_in_creative",
Tag::Byte(i8::from(mode == "allow")),
)])
};
data.set_other("minecraft:tool", Some(tool));
stack.extra = Some(Box::new(data));
}
}
writeln!(
out,
"{}",
items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap())
)?;
}
Ok(())
}
@@ -0,0 +1,89 @@
use horizon_client::{
Result,
cushion::{self, World},
};
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
use serde_json::Value;
use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
struct Cell {
outline: VoxelShape,
collision: VoxelShape,
suffocating: bool,
}
struct Fixture(HashMap<[i32; 3], Cell>);
impl World for Fixture {
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
self.0
.get(&p)?
.outline
.bounds()
.map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)))
}
fn collision_shapes(&self, _: Aabb) -> Vec<VoxelShape> {
self.0
.iter()
.map(|(p, c)| {
c.collision
.moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))
})
.collect()
}
fn suffocating(&self, p: [i32; 3]) -> bool {
self.0.get(&p).is_some_and(|c| c.suffocating)
}
}
fn shape(value: &Value) -> Result<VoxelShape> {
let mut shape = VoxelShape::empty();
for b in value.as_array().unwrap() {
let f = |i: usize| b[i].as_f64().unwrap();
shape = shape.join(
&VoxelShape::from_box(Aabb::new(
Vec3::new(f(0), f(1), f(2)),
Vec3::new(f(3), f(4), f(5)),
))?,
ShapeOp::OR,
)?;
}
Ok(shape)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let mut world = Fixture(HashMap::new());
for c in row["cells"].as_array().unwrap() {
let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32);
world.0.insert(
p,
Cell {
outline: shape(&c[3])?,
collision: shape(&c[4])?,
suffocating: c[5].as_bool().unwrap(),
},
);
}
let p = &row["pos"];
let (x, y, z) = (
p[0].as_f64().unwrap(),
p[1].as_f64().unwrap(),
p[2].as_f64().unwrap(),
);
let bounds = Aabb::new(
Vec3::new(x - 0.5, y, z - 0.5),
Vec3::new(x + 0.5, y + 0.25, z + 0.5),
);
writeln!(
out,
"{} {}",
cushion::can_place(&world, bounds),
cushion::survives(&world, bounds)
)?;
}
Ok(())
}
@@ -9,7 +9,7 @@ use horizon_client::mesher::Vertex;
use horizon_client::mobs::{
Mob, MobData, MobTypes, Part, Species, allay,
anim::{self, Poses, State},
aquatic, body_anim as b_anim, equine, guardian,
aquatic, bat, body_anim as b_anim, equine, guardian,
keyframes::AnimationState,
render, shulker, sniffer, species, tame,
};
@@ -108,6 +108,22 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
match species {
Species::Bat => {
mob.extra.flag = flag("isResting");
mob.anim.states[0].started = case["flyAnimationState"].as_f64().map(|v| v as f32);
mob.anim.states[1].started = case["restAnimationState"].as_f64().map(|v| v as f32);
if let Some(ticks) = case["@batTicks"].as_array() {
for (i, flags) in ticks.iter().enumerate() {
mob.age = i as u32 + 1;
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
if flag("@batInFrame") {
bat::tick(mob);
} else {
bat::setup_animation_states(mob);
}
}
}
}
Species::Sheep => mob.extra.anim_ticks = num("eatTick") as i32,
Species::Guardian | Species::ElderGuardian => {
mob.anim.v[guardian::TAIL] = num("tailAnimation");
@@ -377,7 +393,7 @@ pub fn main() -> horizon_client::Result<()> {
mob.extra.age = -24000;
}
mob.age = f("ageInTicks") as u32;
let state = State {
let mut state = State {
pos: f("walkAnimationPos"),
speed: f("walkAnimationSpeed"),
age: f("ageInTicks"),
@@ -389,6 +405,20 @@ pub fn main() -> horizon_client::Result<()> {
};
let mut poses = Poses::new(&model);
entity_state(&mut mob, def.species, case);
if let Some(ticks) = case["@walkTicks"].as_array() {
for speed in ticks {
mob.previous_limb_speed = mob.limb_speed;
mob.limb_speed += (speed.as_f64().unwrap() as f32 - mob.limb_speed) * 0.4;
mob.limb_position += mob.limb_speed;
}
if !case["@alive"].as_bool().unwrap() {
mob.health = 0.0;
}
let extracted = anim::state(&mob, f("@partial"));
state.pos = extracted.pos;
state.speed = extracted.speed;
}
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
let started = |k: &str| AnimationState {
@@ -456,14 +486,6 @@ pub fn main() -> horizon_client::Result<()> {
roll_amount: num("rollAmount"),
},
),
Species::Bat => b_anim::bat(
p,
s,
flag("isResting"),
&started("flyAnimationState"),
&started("restAnimationState"),
&animations,
),
Species::Armadillo => b_anim::armadillo(
p,
s,
@@ -590,7 +612,11 @@ pub fn main() -> horizon_client::Result<()> {
}
_ => anim::setup(&mob, s, p, &animations),
}
out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
let mut result = emit(&model, &mut poses, case, layer);
if case["@walkTicks"].is_array() {
result["walk"] = json!([state.pos, state.speed]);
}
out.push_str(&format!("{result}\n"));
}
fs::write(&args[2], out)?;
Ok(())
@@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -27,7 +27,9 @@ mod chat_screen_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
mod emitter_particles_probe;
mod enchanting_fixture;
mod entity_anim_fixture;
@@ -83,6 +85,7 @@ mod sound_engine_fixture;
mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
@@ -107,6 +110,9 @@ probes! {
block_transform_fixture,
breaking_fixture,
camera_probe,
squid_client_probe,
creative_mining_probe,
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
cloud_fixture,
@@ -49,6 +49,7 @@ struct Scenario {
name: String,
ticks: i32,
time: i64,
difficulty: u8,
rain: bool,
thunder: bool,
origin: [i32; 2],
@@ -85,11 +86,13 @@ fn parse(text: &str) -> Vec<Scenario> {
"scenario" => {
s.name = f[1].to_owned();
s.time = 6000;
s.difficulty = 2;
for kv in &f[2..] {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
match k {
"ticks" => s.ticks = v.parse().unwrap(),
"time" => s.time = v.parse().unwrap(),
"difficulty" => s.difficulty = v.parse().unwrap(),
"rain" => s.rain = true,
"thunder" => s.thunder = true,
"levelseed" => {}
@@ -954,6 +957,10 @@ pub fn main() -> horizon_client::Result<()> {
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
"named" => {
m.custom_name = Some("Peaceful regression".into());
m.persistent = true;
}
"ridden" => {
m.extra.head = Some("minecraft:red_harness".into());
m.rider = Some(horizon_client::mobs::equine::Rider::default());
@@ -1050,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
effects: Vec::new(),
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
@@ -1090,7 +1098,7 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
@@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
.iter()
.filter_map(|e| match e {
MotionEvent::Fall(e) => Some(e.trace()),
// The oracle's player wears no armor; the game applies this one.
MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None,
MotionEvent::Health(e) => Some(e.trace()),
MotionEvent::Snowflake { position, velocity } => Some(format!(
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
@@ -7,7 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
use horizon_core::biome::BiomeZoom;
@@ -161,7 +161,15 @@ fn scene(
.iter()
.map(|v| {
let ([x, y, z], [u, w]) = (v.pos, v.uv());
json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
json!([
x,
y,
z,
u,
w,
argb(v.color | EMISSION_BITS),
light_coords(v.light)
])
})
.collect();
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
@@ -0,0 +1,51 @@
use horizon_client::{
Result,
mobs::{Mob, MobTypes, aquatic},
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
sync::Arc,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let types = MobTypes::load(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let kind = Arc::new(
types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(),
);
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
mob.fluids.in_water = row["water"].as_bool().unwrap();
mob.velocity = Vec3::new(
row["x"].as_f64().unwrap(),
row["y"].as_f64().unwrap(),
row["z"].as_f64().unwrap(),
);
mob.body_yaw = row["body"].as_f64().unwrap() as f32;
for (i, value) in row["v"].as_array().unwrap().iter().enumerate() {
mob.anim.v[i] = value.as_f64().unwrap() as f32;
}
if row["reset"].as_bool().unwrap() {
aquatic::squid_cycle_event(&mut mob);
}
let velocity = mob.velocity;
aquatic::squid_client_step(&mut mob);
assert_eq!(
velocity, mob.velocity,
"a remote animation must preserve packet velocity"
);
write!(out, "{:08x}", mob.body_yaw.to_bits())?;
for value in &mob.anim.v[..6] {
write!(out, " {:08x}", value.to_bits())?;
}
writeln!(out)?;
}
Ok(())
}
+94 -6
View File
@@ -58,7 +58,10 @@ enum Animate {
BubbleColumn {
drag: bool,
},
Fire,
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
Fire {
soul: bool,
},
/// An active sculk sensor.
SculkSensor,
/// Potent sulfur that is not dry.
@@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"bubble_column" => Animate::BubbleColumn {
drag: prop("drag").as_deref() == Some("true"),
},
"fire" | "soul_fire" => Animate::Fire,
"fire" => Animate::Fire { soul: false },
"soul_fire" => Animate::Fire { soul: true },
"dried_ghast" => Animate::DriedGhast {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
@@ -190,6 +194,8 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
}
@@ -220,6 +226,21 @@ impl AnimateTables {
})
.collect()
};
let behaviour: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
let burnable = (0..registry.len() as u32)
.map(|id| {
// FireBlock.getIgniteOdds: a waterlogged state never burns.
registry.state(BlockStateId(id)).is_some_and(|s| {
s.properties.get("waterlogged").map(String::as_str) != Some("true")
&& ignite_odds
.and_then(|m| m.get(s.name.as_str()))
.and_then(serde_json::Value::as_i64)
.is_some_and(|odds| odds > 0)
})
})
.collect();
let mut ambient = vec![Vec::new(); biomes.list.len()];
for (name, &id) in &biomes.index {
let path = root.join(format!(
@@ -248,6 +269,7 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
burnable,
ambient,
})
}
@@ -1103,7 +1125,7 @@ fn block_animate_tick(
}
}
}
Animate::Fire => {
Animate::Fire { soul } => {
let r = engine.random();
if next_int(r, 24) == 0 {
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
@@ -1115,6 +1137,55 @@ fn block_animate_tick(
pitch,
);
}
// BaseFireBlock.animateTick: three large smoke above the fire, or
// two at each burnable neighbour's face when nothing burnable or
// sturdy supports it (SoulFireBlock.canBurn is always true).
let can_burn = |p: [i32; 3]| {
soul || tables
.burnable
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let below = [pos[0], pos[1] - 1, pos[2]];
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
if !can_burn(below) && !sturdy {
// (neighbour, coordinate axis, face at the axis' high end): the
// west, east, north, south and above faces, in vanilla order.
for (at, axis, high) in [
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
([pos[0] + 1, pos[1], pos[2]], 0, true),
([pos[0], pos[1], pos[2] - 1], 2, false),
([pos[0], pos[1], pos[2] + 1], 2, true),
([pos[0], pos[1] + 1, pos[2]], 1, true),
] {
if !can_burn(at) {
continue;
}
for _ in 0..2 {
let r = engine.random();
let d = [r.next_double(), r.next_double(), r.next_double()];
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
// The face coordinate hugs the edge: `* 0.1F` (float).
p[axis] = if high {
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
} else {
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
};
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
} else {
for _ in 0..3 {
let r = engine.random();
let p = [
fx + r.next_double(),
fy + r.next_double() * 0.5 + 0.5,
fz + r.next_double(),
];
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
}
}
}
@@ -1157,13 +1228,19 @@ pub fn campfire_particles<'a>(
if r.next_float() < 0.11 {
let count = next_int(r, 2) + 2;
for _ in 0..count {
make_campfire_smoke(engine, pos, signal);
make_campfire_smoke(engine, pos, signal, false);
}
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter).
pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: bool) {
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
/// `smoking` adds the plain smoke puff at the logs.
pub fn make_campfire_smoke(
engine: &mut ParticleEngine,
pos: [i32; 3],
signal: bool,
smoking: bool,
) {
let r = engine.random();
let sign = |r: &mut LegacyRandom| if r.next_boolean() { 1.0 } else { -1.0 };
let dx = r.next_double() / 3.0;
@@ -1177,6 +1254,17 @@ pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: b
T::CampfireCosySmoke
};
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
if smoking {
let r = engine.random();
let dx = r.next_double() / 4.0 * sign(r);
let dz = r.next_double() / 4.0 * sign(r);
let at = [
f64::from(pos[0]) + 0.5 + dx,
f64::from(pos[1]) + 0.4,
f64::from(pos[2]) + 0.5 + dz,
];
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
}
}
#[cfg(test)]
+55 -3
View File
@@ -156,7 +156,11 @@ pub struct ArrowState {
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
/// through (cleared when it sticks in a block).
pub pierced: Vec<Who>,
/// `Entity.remainingFireTicks`.
pub fire_ticks: i32,
pub fall_distance: f64,
@@ -199,6 +203,7 @@ impl ArrowState {
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
no_physics: false,
pierce: 0,
pierced: Vec::new(),
fire_ticks: 0,
fall_distance: 0.0,
fluids: EntityFluidInteraction::new([WATER, LAVA]),
@@ -362,6 +367,29 @@ fn bounds(at: Vec3) -> Aabb {
Vec3::new(at.x + w, at.y + f64::from(HEIGHT), at.z + w),
)
}
/// The bounding box of an arrow, or a thrown trident, at `at`.
pub fn arrow_bounds(at: Vec3) -> Aabb {
bounds(at)
}
/// The blocks an arrow that moved from `from` to `to` was inside
/// (`Entity.checkInsideBlocks`: the sweep of its box, 16 steps at most), for
/// the blocks that react to it (`entityInside`).
pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec<BlockPos> {
let deflated = bounds(to).deflate(Vec3::new(
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
));
let mut inside = Vec::new();
let _ = sweep_intersected_blocks(from, to, deflated, 1_000_000, |p, iteration| {
if iteration >= 16 {
return false;
}
inside.push(p);
true
});
inside
}
/// `Projectile.lerpRotation`.
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
while new - old < -180.0 {
@@ -431,10 +459,19 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
match outcome {
EntityHit::Hurt => {
ticker.hit_sound();
ticker.st.removed = true;
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
if ticker.st.pierce == 0 {
ticker.st.removed = true;
}
}
EntityHit::Refused => ticker.turn_back(),
EntityHit::Deflected => {}
EntityHit::Deflected => {
// The deflection comes before `onHitEntity` adds the entity to
// `piercingIgnoreEntityIds`.
if ticker.st.pierce > 0 {
ticker.st.pierced.pop();
}
}
}
ticker.finish();
p.stuck = st.in_ground;
@@ -639,7 +676,8 @@ impl Ticker<'_, '_, '_, '_> {
self.st.life = 0;
}
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
/// block (piercing is not modelled).
/// block. A piercing arrow that hits an entity here goes on from it next
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
let block = self.scene.clip(from, to);
let end = block.map_or(to, |b| b.0);
@@ -653,6 +691,10 @@ impl Ticker<'_, '_, '_, '_> {
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
continue;
}
// canHitEntity: not an entity a piercing arrow has gone through.
if self.st.pierced.contains(who) {
continue;
}
if let Some(at) = self.entity_hit(b, from, end, margin) {
// Sorted by the distance to the entity's position (the
// stable sort keeps the first of equals).
@@ -667,6 +709,15 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position = next;
self.apply_effects_from_blocks(from, next);
if let Some((who, at, _)) = best {
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
// arrow is discarded before it hurts the next one.
if self.st.pierce > 0 {
if self.st.pierced.len() > usize::from(self.st.pierce) {
self.st.removed = true;
return Hit::None;
}
self.st.pierced.push(who);
}
return Hit::Entity { who, at };
}
let Some((at, pos, face)) = block else {
@@ -752,6 +803,7 @@ impl Ticker<'_, '_, '_, '_> {
self.st.shake = 7;
self.p.set_critical(false);
self.st.pierce = 0;
self.st.pierced.clear();
}
/// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`.
fn may_interact(&self) -> bool {
+31
View File
@@ -323,10 +323,15 @@ const QUIET_MESHES_PER_S: f64 = 100.0;
/// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are
/// drawn once no stage has held work for half a second: blocks changing after the load (fluids
/// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet.
///
/// Right after a teleport (and at spawn) the game reports nothing queued and steady counts for over
/// a second before it starts loading: the counts must change once, or stay full for 5 s, and the
/// resident chunks reach [`view_chunks`], before "steady" means loaded.
fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Instant, String> {
let deadline = Instant::now() + LOAD_LIMIT;
let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now());
let mut previous: Option<(Instant, f64)> = None;
let (mut first, mut moved, began) = (None, false, Instant::now());
while Instant::now() < deadline {
let s = control.status();
let now = Instant::now();
@@ -339,6 +344,19 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
(last, changed, previous, meshing) = (None, now, None, now);
} else {
let counts = (s["chunks"].as_u64(), s["generated_chunks"].as_u64());
let resident = counts.0.unwrap_or(0);
let min_chunks = view_chunks(s["render_distance"].as_u64().unwrap_or(0));
let first = *first.get_or_insert(counts);
moved = moved
|| counts != first
|| (min_chunks > 0
&& resident >= min_chunks
&& now - began > Duration::from_secs(5));
if min_chunks > 0 && (!moved || resident < min_chunks) {
(last, changed, previous, meshing) = (None, now, None, now);
thread::sleep(Duration::from_millis(50));
continue;
}
if last != Some(counts) {
(last, changed) = (Some(counts), now);
}
@@ -370,6 +388,12 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
Err(format!("not loaded after {} s", LOAD_LIMIT.as_secs()))
}
/// 80% of the view circle (pi x RD^2): the game keeps 637 columns at RD 12 and 3725 at RD 32
/// (`tools/perf_bench.py`'s `view_chunks`).
fn view_chunks(distance: u64) -> u64 {
(0.8 * std::f64::consts::PI * (distance * distance) as f64) as u64
}
/// Vanilla's yaw facing from `start` to `end`.
fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 {
(-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees()
@@ -420,6 +444,13 @@ mod tests {
assert!(scenes("nether").is_none());
}
#[test]
fn view_chunks_matches_perf_bench() {
assert_eq!(view_chunks(12), 361);
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
+6
View File
@@ -28,6 +28,9 @@ pub struct BiomeInfo {
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
/// mushroom fields).
pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
pub downfall: f32,
@@ -128,6 +131,9 @@ impl Biomes {
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
@@ -648,6 +648,61 @@ impl Conduit {
}
}
/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ConduitCheck {
/// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`.
pub toggled: Option<bool>,
/// The frame blocks counted, while the conduit is active: `applyEffects`
/// and `updateAndAttackTarget` follow (the attack needs 42 or more).
pub frames: Option<usize>,
}
/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks.
#[derive(Clone, Debug, Default)]
pub struct ConduitServer {
shape: Conduit,
/// `destroyTarget`: the mob id of the enemy being struck.
pub target: Option<u32>,
next_ambient_sound: i64,
}
impl ConduitServer {
/// The `gameTime % 40` part of `serverTick`: the shape is checked again.
pub fn check(
&mut self,
pos: [i32; 3],
game_time: i64,
is_water: impl Fn([i32; 3]) -> bool,
is_frame: impl Fn([i32; 3]) -> bool,
) -> Option<ConduitCheck> {
if game_time % 40 != 0 {
return None;
}
let active = self.shape.update_shape(pos, &is_water, &is_frame);
let toggled = (active != self.shape.is_active).then_some(active);
self.shape.is_active = active;
Some(ConduitCheck {
toggled,
frames: active.then_some(self.shape.effect_blocks.len()),
})
}
/// The sounds of an active conduit at the end of `serverTick`: whether
/// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every
/// 60 to 99 ticks) play.
pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) {
if !self.shape.is_active {
return (false, false);
}
let short = game_time > self.next_ambient_sound;
if short {
self.next_ambient_sound =
game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0));
}
(game_time % 80 == 0, short)
}
}
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Skull {
@@ -832,6 +887,8 @@ pub struct BlockEntityAnims {
pub gateways: FxHashMap<[i32; 3], EndGateway>,
/// The integrated server's copies of the gateways (`portalTick`).
pub gateway_servers: FxHashMap<[i32; 3], EndGateway>,
/// The integrated server's copies of the conduits (`serverTick`).
pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>,
/// The client level's random (conduit particles).
pub random: LegacyRandom,
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
@@ -854,6 +911,7 @@ impl Default for BlockEntityAnims {
pots: FxHashMap::default(),
gateways: FxHashMap::default(),
gateway_servers: FxHashMap::default(),
conduit_servers: FxHashMap::default(),
random: LegacyRandom::new(0x6c6576656c),
kinds: Vec::new(),
present: Vec::new(),
@@ -909,6 +967,7 @@ impl BlockEntityAnims {
self.pots.retain(|p, _| keep(*p));
self.gateways.retain(|p, _| keep(*p));
self.gateway_servers.retain(|p, _| keep(*p));
self.conduit_servers.retain(|p, _| keep(*p));
}
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
+18 -7
View File
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:trial_spawner" => {
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
// and defaulted by vanilla; they are kept as loaded.
// and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
// added: an absent one loads as Optional.empty and is not stored, and
// an empty entity would keep the spawner from ever picking a mob.
if let Tag::Compound(c) = tag {
for (k, v) in c {
let key = k.to_string_lossy();
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
}
}
if !f.out.contains_key(&"spawn_data".into()) {
f.put(
"spawn_data",
Tag::compound([("entity", Tag::Compound(Compound::new()))]),
);
}
}
"minecraft:vault" => {
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
@@ -827,6 +823,21 @@ mod tests {
canonical(&snbt(s)).expect("known type")
}
#[test]
fn a_trial_spawner_without_spawn_data_saves_none() {
let fresh = canon(
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
);
assert_eq!(fresh.get("spawn_data"), None);
let picked = canon(
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
);
assert_eq!(
picked.get("spawn_data"),
Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
);
}
#[test]
fn every_type_has_defaults() {
for id in TYPES.iter().chain(NEWEST.iter()) {
@@ -558,12 +558,11 @@ fn frac(x: f32) -> f32 {
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
/// wrapped at the last space that fits or mid-word when none does.
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
let chars: Vec<char> = line.chars().collect();
let mut width = 0.0;
let mut last_space = None;
for (i, &c) in chars.iter().enumerate() {
for (i, c) in line.char_indices() {
if c == '\n' {
return chars[..i].iter().collect();
return line[..i].to_owned();
}
if c == ' ' {
last_space = Some(i);
@@ -572,9 +571,11 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
if width > max_width {
let end = match last_space {
Some(s) if s > 0 => s,
_ => i.max(1),
_ if i > 0 => i,
// The first char alone is too wide: take that one char.
_ => c.len_utf8(),
};
return chars[..end].iter().collect();
return line[..end].to_owned();
}
}
line.to_owned()
@@ -2098,4 +2099,13 @@ mod tests {
assert_eq!(out[1].pos, [2.0, 0.0, 0.0]);
assert_eq!(out[2].uv, [0.5, 0.5]);
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
}
+203
View File
@@ -166,6 +166,38 @@ impl Contents {
Some(stack)
}
/// The stacks inside, in display order (the first is the next out).
pub fn items(&self) -> &[ItemStack] {
&self.items
}
/// `getNumberOfItemsToShow`: a full grid shows 11 and a `+N`, so the last
/// row keeps its fourth cell for the count.
pub fn shown_count(&self) -> usize {
let count = self.items.len();
let available: usize = if count > 12 { 11 } else { 12 };
let on_last_row = count % 4;
let empty = if on_last_row == 0 { 0 } else { 4 - on_last_row };
count.min(available.saturating_sub(empty))
}
/// The items the tooltip shows, and how many sit beyond them.
pub fn shown(&self) -> (&[ItemStack], u32) {
let n = self.shown_count();
let hidden = self.items[n..].iter().map(|s| s.count).sum();
(&self.items[..n], hidden)
}
/// The `Fraction` weight as the float the progress bar reads.
pub fn weight_fraction(&self) -> f32 {
self.weight.n as f32 / self.weight.d as f32
}
/// `Fraction.ONE`: a full bundle draws the full bar and says so.
pub fn is_full(&self) -> bool {
self.weight.n >= self.weight.d
}
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
@@ -190,3 +222,174 @@ mod tests {
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
}
}
// ------------------------------------------------------------- the tooltip
/// `ClientBundleTooltip`'s geometry, relative to the tooltip box's top-left:
/// the 96 pixel column of a 4 wide slot grid and the progress bar under it.
#[derive(Clone, Debug, PartialEq)]
pub struct TooltipLayout {
/// The component's own width (96) and height, the text's excluded.
pub width: f32,
pub height: f32,
/// Each slot's top-left and the stack in it, in reading order.
pub slots: Vec<([f32; 2], ItemStack)>,
/// The `+N` of the items past the grid: its top-left and the count.
pub overflow: Option<([f32; 2], u32)>,
/// The progress bar's top-left, its fill's width out of 94, and whether
/// the weight fills it (the full sprite).
pub bar: [f32; 2],
pub fill: f32,
pub full: bool,
/// `item.minecraft.bundle.empty` / `.full`, centred over the bar.
pub label: Option<&'static str>,
/// An empty bundle's description, wrapped at `width` from here.
pub empty_description: Option<[f32; 2]>,
}
/// The tooltip of a bundle's contents (`ClientBundleTooltip`), or None when
/// the stack is not a bundle. `x`/`y` are the box's top-left, `box_width` the
/// tooltip's width, which the component is centred in.
pub fn tooltip_layout(
items: &Items,
stack: &ItemStack,
x: f32,
y: f32,
box_width: f32,
) -> Option<TooltipLayout> {
let contents = Contents::of(items, stack)?;
let offset = ((box_width - 96.0) / 2.0).floor();
if contents.items().is_empty() {
// The description takes the top; the bar follows it.
let description_height = 9.0 * 2.0;
return Some(TooltipLayout {
width: 96.0,
height: description_height + 13.0 + 8.0,
slots: Vec::new(),
overflow: None,
bar: [x + offset, y + description_height + 4.0],
fill: 0.0,
full: false,
label: Some("item.minecraft.bundle.empty"),
empty_description: Some([x + offset, y]),
});
}
let (shown, hidden) = contents.shown();
// `gridSizeY` counts `slotCount()` (at most twelve), not the slots the
// overflow count leaves room for.
let rows = contents.items().len().min(12).div_ceil(4).max(1);
let grid_height = rows as f32 * 24.0;
let x_start = x + offset + 96.0;
let y_start = y + grid_height;
let mut slots = Vec::with_capacity(shown.len());
let mut overflow = None;
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
let mut slot = 0;
for row in 1..=rows {
for column in 1..=4 {
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
if hidden > 0 && row == 1 && column == 1 {
overflow = Some((at, hidden));
} else if let Some(stack) = shown.get(slot) {
slots.push((at, stack.clone()));
slot += 1;
}
}
}
let weight = contents.weight_fraction();
Some(TooltipLayout {
width: 96.0,
height: grid_height + 13.0 + 8.0,
slots,
overflow,
bar: [x + offset, y + grid_height + 4.0],
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
fill: (weight * 94.0).trunc().clamp(0.0, 94.0),
full: contents.is_full(),
label: if contents.is_full() {
Some("item.minecraft.bundle.full")
} else {
None
},
empty_description: None,
})
}
#[cfg(test)]
mod tooltip_tests {
use super::*;
/// The prepared resources, when they are on this machine: the tooltip
/// asks the item registry whether a stack is a bundle.
fn items() -> Option<Items> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return None;
}
let assets = crate::assets::Assets::load(&root).ok()?;
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
Items::load(&root, assets.registry, &facts).ok()
}
fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
let mut bundle = ItemStack::new("minecraft:bundle", 1);
bundle.data_mut().bundle_contents = counts
.iter()
.map(|(item, n)| ItemStack::new(item, *n))
.collect();
bundle
}
#[test]
fn a_grid_shows_twelve_stacks_and_counts_the_rest() {
let Some(items) = items() else { return };
let twelve: Vec<(&str, u32)> = (0..12).map(|_| ("minecraft:stone", 1)).collect();
let layout = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 96.0).unwrap();
assert_eq!(layout.slots.len(), 12);
assert_eq!(layout.overflow, None);
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
// Rows fill bottom-up and columns right-to-left: the first item is in
// the bottom-right cell, the twelfth in the top-left.
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
// A wider tooltip centres the 96 pixel column in it.
let centred = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 120.0).unwrap();
assert_eq!(centred.slots[0].0[0], 12.0 + 96.0 - 24.0);
// Thirteen stacks: `getNumberOfItemsToShow` leaves the last row room
// for the count, so 8 slots show and `+6` (four stones and two dirt)
// takes the first cell.
let mut counts = twelve;
counts.push(("minecraft:dirt", 2));
let layout = tooltip_layout(&items, &bundle_with(&counts), 0.0, 0.0, 96.0).unwrap();
assert_eq!(layout.slots.len(), 8);
assert_eq!(layout.overflow, Some(([96.0 - 24.0, 48.0], 6)));
}
#[test]
fn the_bar_fills_with_the_weight_and_an_empty_bundle_explains_itself() {
let Some(items) = items() else { return };
let layout = tooltip_layout(&items, &bundle_with(&[]), 4.0, 8.0, 96.0).unwrap();
assert!(layout.slots.is_empty());
assert_eq!(layout.fill, 0.0);
assert_eq!(layout.label, Some("item.minecraft.bundle.empty"));
assert_eq!(layout.empty_description, Some([4.0, 8.0]));
assert_eq!(layout.bar, [4.0, 8.0 + 18.0 + 4.0]);
// A bundle holding one 64 stack is full: the whole bar, the full label.
let layout = tooltip_layout(
&items,
&bundle_with(&[("minecraft:stone", 64)]),
0.0,
0.0,
96.0,
)
.unwrap();
assert!(layout.full);
assert_eq!(layout.fill, 94.0);
assert_eq!(layout.label, Some("item.minecraft.bundle.full"));
// Something that is not a bundle has no component.
let dirt = ItemStack::new("minecraft:dirt", 1);
assert!(tooltip_layout(&items, &dirt, 0.0, 0.0, 96.0).is_none());
}
}
+31
View File
@@ -436,3 +436,34 @@ fn the_chat_option_filters_the_lines_both_ways() {
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
/// The in-frame LAN host's chat: a guest's line is a player message, so the
/// Chat option's player-message permission hides it at Commands Only and
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
/// at Commands Only.
#[test]
fn a_guests_line_follows_the_player_message_permission() {
let font = font();
let shown = |visibility, source| {
let mut chat = Chat::default();
chat.set_abilities(&font, ChatAbilities::new(visibility));
chat.add_message(&font, Text::literal("line"), None, source, 0, false);
chat.messages.len()
};
for (visibility, player, system) in [
(ChatVisibility::Full, 1, 1),
(ChatVisibility::System, 0, 1),
(ChatVisibility::Hidden, 0, 0),
] {
assert_eq!(
shown(visibility, ChatSource::Player),
player,
"{visibility:?}"
);
assert_eq!(
shown(visibility, ChatSource::Server),
system,
"{visibility:?}"
);
}
}
+4
View File
@@ -161,6 +161,9 @@ pub struct Chunk {
/// Structure starts reaching into this chunk (`structures.References`):
/// structure id and the start's chunk.
pub structure_refs: Vec<(Arc<str>, (i32, i32))>,
/// The starts generation made in this chunk (`structures.starts`), saved
/// with the state their placement has reached by then.
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
}
impl Chunk {
pub fn empty(x: i32, z: i32) -> Self {
@@ -178,6 +181,7 @@ impl Chunk {
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
structure_starts: Vec::new(),
postprocess: Vec::new(),
saved: None,
entities: Vec::new(),
+5
View File
@@ -346,6 +346,11 @@ mod tests {
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
// TextFieldHelper.getClipboardContents runs before insertText's filter.
assert_eq!(
filter_text(&helper_clipboard_contents("§aHello"), false),
"Hello"
);
}
struct Field {
@@ -275,4 +275,30 @@ mod tests {
Release::Click(Input::QuickMove)
);
}
/// A press whose release never arrives (the pointer left the window, or
/// the screen closed first) leaves `isQuickCrafting` set; the next screen
/// starts with its own state, as `AbstractContainerScreen` does, so the
/// reset the game runs on open/close makes clicks work again.
#[test]
fn a_lost_release_does_not_wedge_the_next_screen() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release is lost.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
None,
"the stuck drag swallows the next press"
);
// A new screen: fresh state, so the press is a normal click again.
clicks = Clicks::default();
assert!(clicks.quick_crafting().is_none());
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
+130 -6
View File
@@ -49,6 +49,17 @@ pub struct Furnace {
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
/// ("Duplicate entry for key").
pub fn recipe_used_key(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
@@ -83,8 +94,68 @@ pub enum BlockEntity {
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
let Some(slot) = self.items.iter().position(Option::is_none) else {
return false;
};
self.total[slot] = cooking_time;
self.progress[slot] = 0;
self.items[slot] = Some(ItemStack {
count: 1,
..held.clone()
});
held.count = held.count.saturating_sub(1);
true
}
/// `cookTick` (lit): each item's progress rises by one; the slots whose
/// progress reached their cooking time give up their item (the caller
/// drops its result).
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
let mut done = Vec::new();
for i in 0..4 {
if self.items[i].is_none() {
continue;
}
self.progress[i] += 1;
if self.progress[i] >= self.total[i]
&& let Some(item) = self.items[i].take()
{
done.push((i, item));
}
}
done
}
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
/// Whether anything changed (`setChanged`).
pub fn cooldown_tick(&mut self) -> bool {
let mut changed = false;
for i in 0..4 {
if self.progress[i] > 0 {
changed = true;
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
}
}
changed
}
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
@@ -295,6 +366,9 @@ impl BlockEntity {
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -304,6 +378,9 @@ impl BlockEntity {
if name == "minecraft:brewing_stand" {
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
}
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -341,6 +418,8 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Campfire(_) => "minecraft:campfire",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -357,11 +436,13 @@ impl BlockEntity {
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::Skull(_) => Vec::new(),
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -375,14 +456,17 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::Skull(_) => &[],
| Self::Skull(_)
| Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &[],
| Self::CommandBlock(_)
// Not a Container (only Clearable): hoppers don't reach in.
| Self::Campfire(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
@@ -396,14 +480,16 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::Skull(_) => &mut [],
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &mut [],
| Self::CommandBlock(_)
| Self::Campfire(_) => &mut [],
}
}
}
@@ -638,7 +724,10 @@ impl Furnace {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
@@ -692,6 +781,41 @@ mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
let mut beef = ItemStack::new("minecraft:beef", 2);
// placeFood: the first free slot, one item, its recipe's time.
assert!(c.place_food(&mut beef, 600));
assert_eq!(beef.count, 1);
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
assert_eq!((c.progress[0], c.total[0]), (0, 600));
let mut fish = ItemStack::new("minecraft:cod", 1);
assert!(c.place_food(&mut fish, 3));
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
// cookTick: the cod is done on its third tick, the beef keeps cooking.
assert!(c.cook_tick().is_empty());
assert!(c.cook_tick().is_empty());
let done = c.cook_tick();
assert_eq!(done.len(), 1);
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
assert!(c.items[1].is_none());
assert_eq!(c.progress[0], 3);
// cooldownTick: two a tick, never below zero.
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 1);
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 0);
// Full: four slots, no fifth.
for _ in 0..3 {
let mut more = ItemStack::new("minecraft:potato", 1);
assert!(c.place_food(&mut more, 600));
}
let mut fifth = ItemStack::new("minecraft:potato", 1);
assert!(!c.place_food(&mut fifth, 600));
assert_eq!(fifth.count, 1);
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+2
View File
@@ -1612,7 +1612,9 @@ impl Recipes {
}
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
/// pack does not have (a save from another pack can name it).
/// `id` with or without the `minecraft:` namespace (a furnace keeps the full id).
pub fn cooking_experience(&self, id: &str) -> f32 {
let id = id.strip_prefix("minecraft:").unwrap_or(id);
self.cooking
.iter()
.find(|c| c.id == id)
+109 -24
View File
@@ -1,9 +1,14 @@
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
//! contents as built by the game's own display generators
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
use crate::{Result, items::ItemStack};
use crate::{Result, items::ItemStack, ui::EditBox};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::HashMap,
fs,
path::Path,
sync::{Arc, Mutex},
};
#[derive(Clone, Debug)]
pub struct Tab {
@@ -35,13 +40,22 @@ pub struct Creative {
pub scroll: f32,
/// The scrollbar is held (scrolling): dragging moves it.
pub scrolling: bool,
pub search: String,
/// When the search box took the focus (`EditBox.setFocused`): its
/// cursor blinks from then.
pub search_focused: std::time::Instant,
/// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50.
pub search: EditBox,
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
}
/// The grid's stacks for one tab, search text and language.
struct Shown {
tab: usize,
search: String,
/// A display name, so a language change refills the search results.
language: String,
items: Arc<[ItemStack]>,
}
/// Display names from the pinned en_us language file.
#[derive(Default)]
@@ -284,10 +298,16 @@ impl Creative {
selected: 0,
scroll: 0.0,
scrolling: false,
search: String::new(),
search_focused: std::time::Instant::now(),
search: {
let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0);
search.bordered = false;
search.max_length = 50;
search.text_color = u32::MAX;
search
},
search_items,
search_index,
shown: Mutex::new(None),
}
}
/// Where a stack comes in the creative tabs (the search tab lists them
@@ -300,18 +320,35 @@ impl Creative {
.position(|s| s.same_item_same_components(stack))
.or_else(|| items.iter().position(|s| s.item == stack.item))
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
})
.collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
}
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
pub fn select(&mut self, tab: usize) {
if tab != self.selected {
self.search.clear();
self.search.set_value("");
}
self.selected = tab;
self.scroll = 0.0;
if self.tab().search {
self.search_focused = std::time::Instant::now();
self.search.focused = true;
self.search.reset_blink();
}
}
/// The search tab's stacks.
@@ -380,17 +417,44 @@ impl Creative {
})
.collect()
}
/// Items shown by the selected tab (the search tab filters by `search`).
pub fn items(&self, names: &Names) -> Vec<ItemStack> {
let tab = self.tab();
if tab.search {
return self
.search_results(&self.search, names)
/// Items shown by the selected tab (the search tab filters by `search`),
/// worked out again only when the tab, the search or the language changed.
pub fn items(&self, names: &Names) -> Arc<[ItemStack]> {
let tab = self.selected.min(self.tabs.len() - 1);
let search = if self.tab().search {
self.search.value.as_str()
} else {
""
};
let language = names.display_get("itemGroup.search").unwrap_or_default();
let mut shown = self.shown.lock().unwrap_or_else(|e| e.into_inner());
if let Some(s) = shown.as_ref()
&& s.tab == tab
&& s.search == search
&& s.language == language
{
return s.items.clone();
}
let items: Arc<[ItemStack]> = if self.tab().search {
self.search_results(search, names)
.into_iter()
.map(|i| self.search_items[i].clone())
.collect();
}
tab.items.clone()
.collect()
} else {
self.tab().items.as_slice().into()
};
*shown = Some(Shown {
tab,
search: search.to_owned(),
language,
items: items.clone(),
});
items
}
/// Drop the shown items, so the next `items` works them out again: a
/// reload can rename items without changing the Search label.
pub fn forget_items(&self) {
*self.shown.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn rows(&self, names: &Names) -> usize {
self.items(names).len().div_ceil(9)
@@ -754,13 +818,13 @@ mod tests {
(1, false, true),
(2, true, false),
] {
c.search = "anvil".into();
c.search.set_value("anvil");
c.scroll = 0.5;
c.select(selected);
assert_eq!(c.selected, selected);
assert_eq!(c.tab().inventory, inventory);
assert_eq!(c.tab().search, search);
assert!(c.search.is_empty());
assert!(c.search.value.is_empty());
assert_eq!(c.scroll, 0.0);
}
}
@@ -780,12 +844,12 @@ mod tests {
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
let mut c = c;
c.select(1);
c.search = "anvil".into();
c.search.set_value("anvil");
c.scroll = 0.5;
c.select(1);
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0));
c.select(0);
assert_eq!(c.search, "");
assert_eq!(c.search.value, "");
}
#[test]
@@ -899,4 +963,25 @@ mod tests {
c.scroll = 1.0;
assert_eq!(c.scroller_offset(), 95.0);
}
#[test]
fn search_edits_at_its_cursor() {
use winit::keyboard::KeyCode;
let mut c = creative(0);
c.select(1);
c.search.insert_text("hello world");
for _ in 0..5 {
assert!(c.search.key(KeyCode::ArrowLeft, false, false));
}
c.search.insert_text("X");
assert_eq!(c.search.value, "hello Xworld");
c.search.key(KeyCode::Home, false, true);
c.search.insert_text("Y");
assert_eq!(c.search.value, "Yworld");
// Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor.
c.search.key(KeyCode::ArrowLeft, true, false);
c.search.key(KeyCode::ArrowRight, false, false);
c.search.key(KeyCode::Backspace, false, false);
assert_eq!(c.search.value, "world");
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Cushion placement and support checks (`Cushion.canBePlacedAt`/`wouldSurviveAt`).
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
pub trait World {
fn outline_bounds(&self, pos: [i32; 3]) -> Option<Aabb>;
fn collision_shapes(&self, bounds: Aabb) -> Vec<VoxelShape>;
fn suffocating(&self, pos: [i32; 3]) -> bool;
}
pub fn blocks_in(a: Aabb) -> impl Iterator<Item = [i32; 3]> {
let (lo, hi) = (a.min_position(), a.max_position());
let (x0, y0, z0) = (
lo.x.floor() as i32,
lo.y.floor() as i32,
lo.z.floor() as i32,
);
let (x1, y1, z1) = (
hi.x.floor() as i32,
hi.y.floor() as i32,
hi.z.floor() as i32,
);
(y0..=y1).flat_map(move |y| (z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| [x, y, z])))
}
pub fn survives(world: &impl World, bounds: Aabb) -> bool {
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let anchor = Aabb::new(
Vec3::new(lo.x, lo.y - 0.015625, lo.z),
Vec3::new(hi.x.next_down(), lo.y, hi.z.next_down()),
);
// The extra eighth block expands the scan, not the intersection test.
let supported = blocks_in(anchor.expand_towards(Vec3::new(0.0, -0.125, 0.0))).any(|p| {
world
.outline_bounds(p)
.is_some_and(|shape| shape.intersects(anchor))
});
supported && !blocks_in(bounds.next_deflated()).all(|p| world.suffocating(p))
}
pub fn can_place(world: &impl World, bounds: Aabb) -> bool {
if !survives(world, bounds) {
return false;
}
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let slab = Aabb::new(lo, Vec3::new(hi.x, lo.y + 0.015625, hi.z)).next_deflated();
let Ok(mut remaining) = VoxelShape::from_box(slab) else {
return false;
};
for shape in world.collision_shapes(slab) {
let Ok(next) = remaining.join(&shape, ShapeOp::ONLY_FIRST) else {
return false;
};
remaining = next;
if remaining.is_empty() {
return false;
}
}
true
}
+4 -5
View File
@@ -221,18 +221,17 @@ pub fn scale(
pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec<GuiDraw> {
let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])];
for (lines, align_left) in [(columns[0], true), (columns[1], false)] {
let placed: Vec<(&String, i32, i32)> = lines
let placed: Vec<(&String, i32, i32, i32)> = lines
.iter()
.enumerate()
.filter(|(_, l)| !l.is_empty())
.map(|(i, l)| {
let w = font.width(l) as i32;
let left = if align_left { 2 } else { width - 2 - w };
(l, left, 2 + LINE_HEIGHT * i as i32)
(l, left, 2 + LINE_HEIGHT * i as i32, w)
})
.collect();
for (line, left, top) in &placed {
let w = font.width(line) as i32;
for (_, left, top, w) in &placed {
out.push(fill(
left - 1,
top - 1,
@@ -241,7 +240,7 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
BACKGROUND,
));
}
for (line, left, top) in placed {
for (line, left, top, _) in placed {
out.push(GuiDraw::Text {
text: Text::literal(line.clone()),
x: left,
+117 -9
View File
@@ -95,6 +95,15 @@ impl ItemEntity {
self.health -= fire_damage as i32;
}
}
// CactusBlock.entityInside: `entity.hurt(cactus, 1.0)`. An item has no
// hurt cooldown, so it loses 1 health every tick it touches a cactus
// (5 health: gone in 5 ticks). Fire resistance does not help;
// `ItemStack.canBeHurtBy` only skips it for a damage_resistant stack.
if crate::motion::environment::cactus_contact(world, registry, self.bounds())
&& !items.resists_cactus(&self.stack)
{
self.health -= 1;
}
ItemEnvironmentTick {
alive: self.health > 0,
lava_hurt,
@@ -385,8 +394,29 @@ pub struct ExperienceOrb {
pub value: u32,
pub age: u32,
pub on_ground: bool,
/// `ExperienceOrb.count`: orbs merged into this one; each pickup gives
/// `value` once and takes one off.
pub count: u32,
/// `Entity.tickCount`, which paces `ExperienceOrb.scanForMerges`.
pub ticks: u32,
}
impl ExperienceOrb {
pub fn bounds(&self) -> Aabb {
let h = 0.25;
Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 0.5,
self.position.z + h,
),
)
}
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
/// the same value and ids a multiple of 40 apart.
pub fn can_merge(&self, id: u32, value: u32) -> bool {
self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0
}
/// ExperienceOrb.getExperienceValue: the largest orb size not above `value`.
pub fn split(value: u32) -> u32 {
[2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1]
@@ -416,7 +446,9 @@ impl ExperienceOrb {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
next_float: &mut dyn FnMut() -> f32,
) -> bool {
self.ticks += 1;
self.previous = self.position;
let block = world.block(
self.position.x.floor() as i32,
@@ -432,6 +464,14 @@ impl ExperienceOrb {
} else {
self.velocity.y -= 0.03;
}
if facts.get(block).fluid == Fluid::Lava {
// ExperienceOrb.tick: lava throws the orb back out.
self.velocity = Vec3::new(
f64::from((next_float() - next_float()) * 0.2),
f64::from(0.2f32),
f64::from((next_float() - next_float()) * 0.2),
);
}
if let Some(p) = player {
let d = [
(p[0] - self.position.x) / 8.0,
@@ -449,15 +489,7 @@ impl ExperienceOrb {
);
}
}
let h = 0.25;
let bounds = Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 0.5,
self.position.z + h,
),
);
let bounds = self.bounds();
let mut scene = SceneCollisions {
world,
binding: collision,
@@ -494,6 +526,31 @@ impl ExperienceOrb {
self.age < 6000
}
}
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
/// adding their counts and keeping the smaller age.
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
let mut removed = vec![false; orbs.len()];
for i in 0..orbs.len() {
if removed[i] || orbs[i].ticks % 20 != 1 {
continue;
}
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
for j in 0..orbs.len() {
if j != i
&& !removed[j]
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
&& orbs[j].bounds().intersects(area)
{
orbs[i].count += orbs[j].count;
orbs[i].age = orbs[i].age.min(orbs[j].age);
removed[j] = true;
}
}
}
let mut removed = removed.into_iter();
orbs.retain(|_| !removed.next().unwrap_or(false));
}
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
#[derive(Clone, Copy, Debug, Default)]
pub struct Experience {
@@ -591,6 +648,57 @@ impl Painting {
)
}
}
/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when
/// the name is unknown.
pub fn dye_index(name: &str) -> u8 {
crate::mobs::render::DYE_NAMES
.iter()
.position(|n| *n == name)
.unwrap_or(0) as u8
}
/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow
/// that seats a player. It sits on the top face of a block, at the clicked
/// height, and is coloured by its `DyeColor`.
#[derive(Clone, Debug)]
pub struct Cushion {
pub id: u32,
/// `Entity.position`: the bottom centre of the cushion.
pub position: Vec3,
/// `BlockAttachedEntity.pos` (the saved `block_pos`).
pub pos: [i32; 3],
pub yaw: f32,
/// `DyeColor` ordinal: white 0, orange 1, ..., black 15.
pub color: u8,
/// BlockAttachedEntity.ticksSinceLastCheck.
pub check_interval: u32,
}
impl Cushion {
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
let p = self.position;
(
[p.x - 0.5, p.y, p.z - 0.5],
[p.x + 0.5, p.y + 0.25, p.z + 0.5],
)
}
/// The `DyeColor` name of the cushion.
pub fn color_name(&self) -> &'static str {
crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))]
}
/// `Cushion.getCushionItemStackWithData`: the matching coloured item.
pub fn item(&self) -> String {
format!("minecraft:{}_cushion", self.color_name())
}
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
pub fn seat(&self) -> Vec3 {
Vec3::new(self.position.x, self.position.y - 0.35, self.position.z)
}
/// `Entity.getDismountLocationForPassenger`: on top of the cushion.
pub fn dismount(&self) -> Vec3 {
Vec3::new(self.position.x, self.position.y + 0.25, self.position.z)
}
}
/// An item frame or glow item frame (`ItemFrame`).
#[derive(Clone, Debug)]
pub struct ItemFrame {
+12 -1
View File
@@ -166,12 +166,23 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d >= end { return 1.0; }
return (d - start) / (end - start);
}
// CRUMBLING_UV_BEGIN
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
let size = vec2<f32>(dimensions);
// Undo stage_light's 16-bit quantization before adding local texels. Otherwise
// different stages can fall on opposite sides of a nearest-sampling boundary.
let origin = round(packed_origin * size);
let extent = max(round(sprite_size * size), vec2(1.0));
let local = min(floor(fract(uv) * extent), extent - vec2(1.0));
return (origin + local + vec2(0.5)) / size;
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = in.wrap + fract(in.uv) * globals.crumbling_sprite.zw;
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
if in.overlay == 9u || in.overlay == 10u {
+226 -15
View File
@@ -6,7 +6,7 @@
//! survive a Horizon save.
use crate::{
arrows::Pickup,
entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting},
entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting},
mobs::{Mob, MobType, Who},
projectiles::{Projectile, ProjectileKind},
save::{
@@ -265,6 +265,19 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
Tag::Byte(i8::from(m.extra.flag2)),
);
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
if id == "minecraft:piglin" {
// InventoryCarrier.writeInventoryToTag.
map.insert(
"Inventory".into(),
Tag::List(
m.extra
.inventory
.iter()
.map(crate::save::item_to_nbt)
.collect(),
),
);
}
}
"minecraft:horse"
| "minecraft:donkey"
@@ -357,6 +370,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"minecraft:bat" => {
map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag)));
}
"minecraft:turtle" => {
map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag)));
if let Some(home) = m.extra.home {
map.insert("home_pos".into(), Tag::IntArray(home.to_vec()));
}
}
"minecraft:creaking" => {
// home_pos: the heart it is bound to.
if let Some(home) = m.extra.home {
@@ -482,7 +501,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"minecraft:zombie" => {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
for (key, value) in [
("CanBreakDoors", Tag::Byte(0)),
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
] {
@@ -507,6 +525,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
_ => {}
}
// Zombie.addAdditionalSaveData: CanBreakDoors.
if crate::mobs::doors::is_zombie(m.species()) {
map.insert(
"CanBreakDoors".into(),
Tag::Byte(i8::from(m.extra.can_break_doors)),
);
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -706,6 +731,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
) {
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
if m.species() == crate::mobs::Species::Piglin
&& let Some(Tag::List(items)) = tag.get("Inventory")
{
// InventoryCarrier.readInventoryFromTag (eight slots).
m.extra.inventory = items
.iter()
.filter_map(crate::save::item_from_nbt)
.take(8)
.collect();
}
}
if matches!(
m.species(),
@@ -847,6 +882,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
{
crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]);
}
if m.species() == crate::mobs::Species::Turtle {
// Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`.
m.extra.flag = flag(tag, "has_egg");
m.extra.home = Some(match tag.get("home_pos") {
Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]],
_ => [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
});
}
if m.species() == crate::mobs::Species::Wither {
m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32;
}
@@ -925,6 +972,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
crate::mobs::finalize::set_baby(&mut m, true);
}
// Zombie.readAdditionalSaveData: setCanBreakDoors.
if crate::mobs::doors::is_zombie(m.species()) {
crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors"));
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mob saved mid-death finishes dying; one saved dead is dropped.
@@ -1043,18 +1094,18 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
("Health", Tag::Short(5)),
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)),
("Count", Tag::Int(1)),
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
] {
put(&mut tag, key, value);
}
tag
}
/// One Horizon orb per counted orb (vanilla merges orbs of equal value).
/// One orb with its merged `Count`.
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
let Some(position) = position(tag) else {
return Vec::new();
};
let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize;
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
id: 0,
position,
@@ -1063,8 +1114,10 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
value: number(tag.get("Value")).unwrap_or(1).max(1) as u32,
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
on_ground: flag(tag, "OnGround"),
count,
ticks: 0,
};
vec![orb; count]
vec![orb]
}
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
@@ -1313,6 +1366,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
Some((pos, facing, text(tag.get("variant"))?))
}
/// Cushion.addAdditionalSaveData: the block it sits on and its colour.
pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: "minecraft:cushion",
uuid,
position: c.position,
motion: Vec3::ZERO,
rotation: [c.yaw, 0.0],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec()));
put(&mut tag, "color", string(c.color_name()));
tag
}
/// The position, block position, yaw and colour of a saved cushion.
pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option<Cushion> {
let position = position(tag)?;
let pos = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
_ => [
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
],
};
let yaw = match tag.get("Rotation") {
Some(Tag::List(l)) => l
.first()
.and_then(|v| crate::save::float(Some(v)))
.unwrap_or(0.0) as f32,
_ => 0.0,
};
let color =
crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into()));
Some(Cushion {
id,
position,
pos,
yaw,
color,
check_interval: 0,
})
}
#[cfg(test)]
mod cushion_tests {
use super::*;
#[test]
fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() {
let cushion = Cushion {
id: 7,
position: Vec3::new(-1.5, 64.5, 3.5),
pos: [-2, 64, 3],
yaw: 270.0,
color: 14,
check_interval: 55,
};
let raw = Tag::compound([("CustomName", string("Seat"))]);
let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw));
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat"));
let loaded = cushion_from_nbt(&tag, 8).unwrap();
assert_eq!(loaded.id, 8);
assert_eq!(loaded.position, cushion.position);
assert_eq!(loaded.pos, cushion.pos);
assert_eq!(loaded.yaw, cushion.yaw);
assert_eq!(loaded.color_name(), "red");
assert_eq!(loaded.check_interval, 0);
assert_eq!(loaded.bounds(), cushion.bounds());
}
}
/// ItemFrame.addAdditionalSaveData.
pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = f.bounds();
@@ -1840,7 +1966,7 @@ mod tests {
assert_eq!(number(tag.get("Health")), Some(5));
}
#[test]
fn orbs_expand_their_count() {
fn orbs_keep_their_count() {
let o = ExperienceOrb {
id: 0,
position: Vec3::new(1.0, 2.0, 3.0),
@@ -1849,12 +1975,14 @@ mod tests {
value: 7,
age: 3,
on_ground: false,
count: 3,
ticks: 0,
};
let mut tag = orb_to_nbt(&o, [0; 4]);
put(&mut tag, "Count", Tag::Int(3));
let tag = orb_to_nbt(&o, [0; 4]);
let back = orbs_from_nbt(&tag);
assert_eq!(back.len(), 3);
assert_eq!(back.len(), 1);
assert_eq!(back[0].value, 7);
assert_eq!(back[0].count, 3);
}
}
@@ -1919,12 +2047,39 @@ fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
}
}
const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"];
/// `Salmon.Variant` names, by id.
const SALMON_SIZES: [&str; 3] = ["small", "medium", "large"];
/// `TropicalFish.Pattern` names in enum order: the six small patterns, then
/// the six large ones (the order of `extra.variant`).
const TROPICAL_PATTERNS: [&str; 12] = [
"kob",
"sunstreak",
"snooper",
"dasher",
"brinely",
"spotty",
"flopper",
"stripey",
"glitter",
"blockfish",
"betty",
"clayfish",
];
/// `Bucketable.saveDefaultDataToBucketTag`, with the age an axolotl and a
/// tadpole add (`saveToBucketTag`).
pub fn bucket_data(m: &Mob) -> Tag {
let mut data = Tag::compound([
("Age", Tag::Int(m.extra.age)),
("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))),
("Health", Tag::Float(m.health.to_bits())),
]);
let mut data = Tag::compound([("Health", Tag::Float(m.health.to_bits()))]);
if matches!(
m.species(),
crate::mobs::Species::Axolotl | crate::mobs::Species::Tadpole
) {
put(&mut data, "Age", Tag::Int(m.extra.age));
put(
&mut data,
"AgeLocked",
Tag::Byte(i8::from(m.extra.age_locked)),
);
}
for (key, on) in [
("NoAI", m.no_ai),
("Silent", m.silent),
@@ -1954,6 +2109,29 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
.unwrap_or(&"lucy");
data.set_other("minecraft:axolotl/variant", Some(string(variant)));
}
// Salmon.saveToBucketTag: the size.
if m.species() == crate::mobs::Species::Salmon {
let size = SALMON_SIZES
.get(usize::from(m.extra.variant))
.unwrap_or(&"medium");
data.set_other("minecraft:salmon/size", Some(string(size)));
}
// TropicalFish.saveToBucketTag: the pattern and both colours.
if m.species() == crate::mobs::Species::TropicalFish {
let dye = |i: u8| crate::mobs::render::DYE_NAMES[usize::from(i) & 15];
let pattern = TROPICAL_PATTERNS
.get(usize::from(m.extra.variant))
.unwrap_or(&"kob");
data.set_other("minecraft:tropical_fish/pattern", Some(string(pattern)));
data.set_other(
"minecraft:tropical_fish/base_color",
Some(string(dye(m.extra.variant2))),
);
data.set_other(
"minecraft:tropical_fish/pattern_color",
Some(string(dye(m.extra.variant3))),
);
}
data
}
pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
@@ -2004,6 +2182,39 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
{
m.extra.variant = id as u8;
}
// Salmon.applyImplicitComponents: the size.
if m.species() == crate::mobs::Species::Salmon
&& let Some(name) = text(components.other("minecraft:salmon/size"))
&& let Some(id) = SALMON_SIZES
.iter()
.position(|&v| v == name.trim_start_matches("minecraft:"))
{
m.extra.variant = id as u8;
}
// TropicalFish.applyImplicitComponent: the pattern and both colours.
if m.species() == crate::mobs::Species::TropicalFish {
let name = |key| text(components.other(key));
let dye = |key| {
name(key).and_then(|n| {
crate::mobs::render::DYE_NAMES
.iter()
.position(|&v| v == n.trim_start_matches("minecraft:"))
})
};
if let Some(id) = name("minecraft:tropical_fish/pattern").and_then(|n| {
TROPICAL_PATTERNS
.iter()
.position(|&v| v == n.trim_start_matches("minecraft:"))
}) {
m.extra.variant = id as u8;
}
if let Some(id) = dye("minecraft:tropical_fish/base_color") {
m.extra.variant2 = id as u8;
}
if let Some(id) = dye("minecraft:tropical_fish/pattern_color") {
m.extra.variant3 = id as u8;
}
}
}
#[cfg(test)]
+25
View File
@@ -1201,6 +1201,31 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// A renderer rebuilt over the same font (the loading screen's handoff)
/// makes its atlas texture from the textures alone, so the glyphs it had
/// rasterized into the previous texture must be placed into the new one.
#[test]
fn a_rebuilt_atlas_gets_its_glyphs_again() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let default = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"rebuild",
&[
("assets/minecraft/font/default.json", default),
("assets/minecraft/textures/font/a.png", &sheet),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
let first = font.glyph('a');
assert_eq!(font.take_patches().len(), 1);
font.reset_cache();
// Nothing is left over: drawing the character again places its pixels.
assert!(font.take_patches().is_empty());
assert_eq!(font.glyph('a').advance, first.advance);
assert_eq!(font.take_patches().len(), 1);
}
#[test]
fn provider_filters_follow_the_font_options() {
let sheet = png(8, 8, |x, _| x < 4);
File diff suppressed because it is too large. Load diff
@@ -453,6 +453,11 @@ impl Game<'_> {
}
// ArmorStand.swapItem.
match held {
// A player with infinite materials puts a copy of one on an empty
// slot and keeps the stack.
Some(h) if self.creative && current.is_none() => {
self.armor_stands[i].equipment[slot] = Some(ItemStack { count: 1, ..h });
}
Some(h) if h.count > 1 => {
if current.is_some() {
return true;
+1 -2
View File
@@ -515,8 +515,7 @@ impl Game<'_> {
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = stack_tip {
let lines = self.stack_tooltip(&stack);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(key) = tooltip {
let text = self.translate(&key, &key);
self.draw_tooltip_lines(
@@ -5,10 +5,14 @@
//! [`Game::block_anim`] and [`Game::chest_lid`].
use super::Game;
use horizon_client::{
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
block_entity_anim::{
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
containers::BlockEntity,
effects::EffectInstance,
mobs::{Damage, DamageKind},
particles::{ParticleOptions, ParticleType},
save::number,
skins,
@@ -16,6 +20,7 @@ use horizon_client::{
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_world::nbt::Tag;
@@ -218,9 +223,12 @@ impl Game<'_> {
/// The client tickers of the loaded animated block entities, once per
/// client tick (`ClientLevel.tickBlockEntities`).
pub(super) fn tick_block_entity_anims(&mut self) {
// A server has no window, but its gateways and conduits still run
// their server tickers; a client of one only animates what it is sent.
if self.remote.is_none() {
self.tick_conduit_servers();
}
let Some(r) = &self.renderer else {
// A server has no window, but its gateways still run their
// server ticker.
if self.remote.is_none() {
self.tick_gateway_servers();
}
@@ -337,6 +345,7 @@ impl Game<'_> {
}
}
Ticker::Conduit => {
let target = self.conduit_target(pos);
let world = &self.world;
let facts = self.res.facts();
let registry = self.pack.assets.registry;
@@ -356,7 +365,7 @@ impl Game<'_> {
time,
is_water,
is_frame,
None,
target,
self.particles.random(),
);
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
@@ -383,6 +392,248 @@ impl Game<'_> {
}
self.be_anims.present = positions;
}
/// `ConduitBlockEntity.serverTick` of every conduit in the chunks that
/// tick blocks, with or without a renderer. The conduits are looked for
/// when the shape is checked, every 40 ticks (they have no block entity
/// of their own here).
fn tick_conduit_servers(&mut self) {
if self.world.game_time % 40 == 0 {
self.find_conduits();
}
let mut positions: Vec<[i32; 3]> = self.be_anims.conduit_servers.keys().copied().collect();
positions.sort_unstable();
for pos in positions {
self.tick_conduit_server(pos);
}
}
/// Sets `be_anims.conduit_servers` to the conduits in the
/// chunks that tick blocks, keeping the state of those already known.
fn find_conduits(&mut self) {
let levels = self.tickets.levels();
let kinds = &self.be_anims.kinds;
let is_conduit = |id: u32| matches!(kinds.get(id as usize), Some(Some(Ticker::Conduit)));
let mut found = Vec::new();
for (&(cx, cz), chunk) in &self.world.chunks {
if !levels.block_ticking_at(cx * 16, cz * 16) {
continue;
}
for (si, section) in chunk.sections.iter().enumerate() {
if !section.blocks.maybe_has(is_conduit) {
continue;
}
for i in 0..4096 {
if is_conduit(section.get(i)) {
found.push([
cx * 16 + (i & 15) as i32,
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
cz * 16 + ((i >> 4) & 15) as i32,
]);
}
}
}
}
let mut old = std::mem::take(&mut self.be_anims.conduit_servers);
for pos in found {
let known = old.remove(&pos).unwrap_or_default();
self.be_anims.conduit_servers.insert(pos, known);
}
}
/// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk
/// that ticks blocks: the shape check every 40 ticks with its sounds, the
/// power for players in range, the hurt for an enemy, then the ambient
/// sounds.
fn tick_conduit_server(&mut self, pos: [i32; 3]) {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
if self.be_anims.ticker(state) != Some(Ticker::Conduit) {
// Broken since the chunks were searched.
self.be_anims.conduit_servers.remove(&pos);
return;
}
if !self.tickets.levels().block_ticking_at(pos[0], pos[2])
|| !self.border.contains(f64::from(pos[0]), f64::from(pos[2]))
{
return;
}
let time = self.world.game_time;
let mut conduit = self
.be_anims
.conduit_servers
.remove(&pos)
.unwrap_or_default();
let check = {
let world = &self.world;
let facts = self.res.facts();
let registry = self.pack.assets.registry;
let frames = self
.res
.items()
.block_tags
.get("minecraft:conduit_effect_block");
// Level.isWaterAt: the fluid at the position is water.
let is_water = |p: [i32; 3]| {
facts.get(world.block(p[0], p[1], p[2])).fluid
== horizon_client::blocks::Fluid::Water
};
let is_frame = |p: [i32; 3]| {
registry
.state(world.block(p[0], p[1], p[2]))
.is_some_and(|s| frames.is_some_and(|f| f.contains(&s.name)))
};
conduit.check(pos, time, is_water, is_frame)
};
let centre = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
if let Some(check) = check {
if let Some(active) = check.toggled {
let sound = if active {
"minecraft:block.conduit.activate"
} else {
"minecraft:block.conduit.deactivate"
};
self.audio.play(sound, Some(centre), 1.0, 1.0);
}
if let Some(frames) = check.frames {
self.conduit_apply_effects(pos, frames);
self.conduit_attack(&mut conduit, pos, frames >= 42);
}
}
let (ambient, short) = conduit.ambient(time, &mut self.world.random);
if ambient {
self.audio
.play("minecraft:block.conduit.ambient", Some(centre), 1.0, 1.0);
}
if short {
self.audio.play(
"minecraft:block.conduit.ambient_short",
Some(centre),
1.0,
1.0,
);
}
self.be_anims.conduit_servers.insert(pos, conduit);
}
/// `ConduitBlockEntity.applyEffects`: conduit power for every player
/// in water or rain within `frames / 7 * 16` blocks.
fn conduit_apply_effects(&mut self, pos: [i32; 3], frames: usize) {
let range = (frames / 7 * 16) as i64;
self.for_each_player(|g| {
let Some(p) = g.player.as_ref().filter(|p| !p.health.dead_or_dying()) else {
return;
};
if g.spectator() {
return;
}
let at = p.position;
let in_water = p.fluids.was_touching_water;
let top = at.y + f64::from(p.dimensions.height);
// BlockPos.closerThan: the squared distance under range squared.
let d = [at.x, at.y, at.z]
.map(|v| v.floor() as i64)
.iter()
.zip(pos)
.map(|(a, b)| (a - i64::from(b)).pow(2))
.sum::<i64>();
if d >= range * range || !(in_water || g.in_rain(at, top)) {
return;
}
let power = EffectInstance {
ambient: true,
..EffectInstance::new("conduit_power", 260, 0)
};
g.potions.effects.add(power);
});
}
/// `Entity.isInRain`: rain at the block position or at the top of the box.
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
let p = BlockPos::new(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
);
let up = BlockPos::new(p.x, top.floor() as i32, p.z);
self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up))
}
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
/// (42 frame blocks) its target stays until it dies or leaves the 8
/// blocks, else a random enemy in water or rain is chosen; the target
/// takes 4 magic damage.
fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) {
conduit.target = match conduit.target {
_ if !hunting => None,
None => self.conduit_new_target(pos),
Some(id) => self
.mobs
.iter()
.find(|m| m.id == id && !m.removed)
.filter(|m| {
let at = [m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i64);
m.alive()
&& at
.iter()
.zip(pos)
.map(|(a, b)| (a - i64::from(b)).pow(2))
.sum::<i64>()
< 64
})
.map(|m| m.id),
};
let Some(m) = conduit
.target
.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
m.damage(4.0, Damage::of(DamageKind::Magic));
self.audio
.play("minecraft:block.conduit.attack.target", Some(at), 1.0, 1.0);
}
/// `ConduitBlockEntity.selectNewTarget`: one of the enemies in water or
/// rain whose box meets the conduit's block inflated by 8.
fn conduit_new_target(&mut self, pos: [i32; 3]) -> Option<u32> {
let [x, y, z] = pos.map(f64::from);
let area = Aabb::new(
Vec3::new(x - 8.0, y - 8.0, z - 8.0),
Vec3::new(x + 9.0, y + 9.0, z + 9.0),
);
let near: Vec<(u32, bool, Vec3, f64)> = self
.mobs
.iter()
.filter(|m| m.hostile() && !m.removed && m.bounds().intersects(area))
.map(|m| {
let top = m.position.y + f64::from(m.height());
(m.id, m.fluids.in_water, m.position, top)
})
.collect();
let candidates: Vec<u32> = near
.into_iter()
.filter(|&(_, in_water, at, top)| in_water || self.in_rain(at, top))
.map(|(id, ..)| id)
.collect();
if candidates.is_empty() {
return None;
}
let pick = self
.world
.random
.next_int_bound(candidates.len() as i32)
.unwrap_or(0);
Some(candidates[pick as usize])
}
/// The conduit's `destroyTarget` as the client draws it.
fn conduit_target(&self, pos: [i32; 3]) -> Option<ConduitTarget> {
let id = self.be_anims.conduit_servers.get(&pos)?.target?;
let m = self.mobs.iter().find(|m| m.id == id && !m.removed)?;
Some(ConduitTarget {
position: [m.position.x, m.position.y, m.position.z],
eye_y: m.eye_y(),
width: m.width(),
height: m.height(),
})
}
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
/// gateways a server keeps a copy of, in block-ticking chunks
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
@@ -10,6 +10,7 @@ use horizon_client::{
block_entities::{Pos, Ticking},
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
};
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
@@ -56,6 +57,8 @@ impl Game<'_> {
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
_ => {}
}
}
@@ -92,7 +95,11 @@ impl Game<'_> {
| "minecraft:vault"
| "minecraft:beehive"
| "minecraft:bee_nest"
| "minecraft:brewing_stand" => Ticking::Yes,
| "minecraft:brewing_stand"
| "minecraft:daylight_detector"
// Lit, cookTick; unlit, cooldownTick.
| "minecraft:campfire"
| "minecraft:soul_campfire" => Ticking::Yes,
"minecraft:jukebox" => {
let record = registry
.state(state)
@@ -113,6 +120,16 @@ impl Game<'_> {
let levels = self.tickets.levels();
move |x, z| levels.block_ticking_at(x, z)
}
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
fn tick_daylight_detector(&mut self, pos: Pos) {
// It updates every 20 game ticks (the behaviour checks this too).
if self.world.game_time % 20 != 0 {
return;
}
let at = BlockPos::new(pos.0, pos.1, pos.2);
self.with_level(|b, level, ticks| b.tick_daylight_detector(level, ticks, at));
let _ = self.process_level_events();
}
/// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and
/// its block's `lit` following it.
fn tick_furnace(&mut self, pos: Pos) -> Result<()> {
+4 -1
View File
@@ -55,7 +55,7 @@ pub(super) struct Boat {
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
pub(super) const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -587,6 +587,9 @@ impl Game<'_> {
}
/// The player's tick while riding: steer, tick the boat, sit in it.
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
if self.tick_cushion_riding(keys) {
return Ok(());
}
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
return Ok(());
+346 -151
View File
@@ -17,7 +17,6 @@ use horizon_client::{
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::level::flags as level_flags;
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -27,8 +26,24 @@ const BLACK: u32 = 0xFF00_0000;
const PAGE_CHARS: usize = 1024;
const MAX_PAGES: usize = 100;
const LINES: usize = 14;
/// `BookSignScreen`'s title box.
const BOOK_TITLE: horizon_client::clipboard::FieldId = ("book.title", 0);
fn book_title_box() -> ui::EditBox {
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
edit.bordered = false;
edit.max_length = 15;
edit.text_color = BLACK;
edit.focused = true;
edit
}
fn book_page_box() -> ui::EditBox {
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
edit.bordered = false;
edit.multiline = true;
edit.max_length = PAGE_CHARS;
edit.text_color = BLACK;
edit.focused = true;
edit
}
pub(super) enum BookScreen {
Edit {
@@ -38,7 +53,10 @@ pub(super) enum BookScreen {
page: usize,
signing: bool,
title: String,
opened: Instant,
/// BookSignScreen's title box (`EditBox` of 15).
title_edit: ui::EditBox,
/// BookEditScreen's page (`MultiLineEditBox`).
page_edit: ui::EditBox,
},
View {
pages: Vec<String>,
@@ -84,13 +102,16 @@ impl Game<'_> {
if pages.is_empty() {
pages.push(String::new());
}
let mut page_edit = book_page_box();
page_edit.set_value(&pages[0]);
BookScreen::Edit {
slot: self.held_slot(),
pages,
page: 0,
signing: false,
title: String::new(),
opened: Instant::now(),
title_edit: book_title_box(),
page_edit,
}
}
"minecraft:written_book" => BookScreen::View {
@@ -239,34 +260,24 @@ impl Game<'_> {
Some(KeyCode::PageDown) => self.turn_page(true),
_ => {}
},
Some(BookScreen::Edit {
signing: true,
title,
..
}) => match key {
Some(BookScreen::Edit { signing: true, .. }) => match key {
Some(KeyCode::Escape) => {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
}
Some(KeyCode::Backspace) => {
if !self.text_selection.take(BOOK_TITLE, title) {
title.pop();
}
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
if !title.trim().is_empty() {
let blank = self.book.as_ref().is_some_and(
|b| matches!(b, BookScreen::Edit { title, .. } if title.trim().is_empty()),
);
if !blank {
self.save_book(true);
}
}
Some(key) if self.book_title_key(key) => {}
_ => {
if let Some(typed) = text {
self.text_selection.take(BOOK_TITLE, title);
for c in typed.chars().filter(|c| !c.is_control()) {
if title.chars().count() < 15 {
title.push(c);
}
}
self.book_title_insert(typed);
}
}
},
@@ -274,24 +285,13 @@ impl Game<'_> {
Some(KeyCode::Escape) => self.save_book(false),
Some(KeyCode::PageUp) => self.turn_page(false),
Some(KeyCode::PageDown) => self.turn_page(true),
Some(KeyCode::Backspace) => {
if !self.take_page_selection() {
self.edit_page(|p| {
p.pop();
})
}
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
self.take_page_selection();
self.type_into_page("\n")
}
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
Some(KeyCode::ArrowDown) => self.book_move_line(1),
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
Some(key) if self.book_page_key(key) => {}
_ => {
if let Some(typed) = text {
let typed: String = typed.chars().filter(|c| !c.is_control()).collect();
if !typed.is_empty() {
self.take_page_selection();
}
self.type_into_page(&typed);
self.page_insert(typed);
}
}
},
@@ -302,99 +302,258 @@ impl Game<'_> {
pub(super) fn book_type(&mut self, text: &str) {
let text = text.replace("\\n", "\n");
match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) => {
for c in text.chars().filter(|c| !c.is_control()) {
if title.chars().count() < 15 {
title.push(c);
}
}
}
Some(BookScreen::Edit { .. }) => self.type_into_page(&text),
Some(BookScreen::Edit { signing: true, .. }) => self.book_title_insert(&text),
Some(BookScreen::Edit { .. }) => self.page_insert(&text),
_ => {}
}
}
/// Before typing on a page: Select All's selection is cleared (true).
fn take_page_selection(&mut self) -> bool {
match self.book.as_mut() {
Some(BookScreen::Edit { pages, page, .. }) => self
.text_selection
.take(("book.page", *page), &mut pages[*page]),
_ => false,
/// `BookSignScreen.keyPressed` on the title box.
fn book_title_key(&mut self, key: KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return false;
};
if !title_edit.key(key, ctrl, shift) {
return false;
}
title.clone_from(&title_edit.value);
true
}
/// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else
/// the page (`MultilineTextField.insertText`: line breaks kept, cut to
/// the 1024 characters left, rejected when it overflows the 14 lines).
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let font = &self.gui.atlas.font;
match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) => {
Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
BOOK_TITLE,
title,
shortcut,
15,
);
}
Some(BookScreen::Edit { pages, page, .. }) => {
// truncateInsertionText counts the whole value, selection included.
let room = PAGE_CHARS.saturating_sub(pages[*page].chars().count());
self.text_selection.apply(
("book.page", *page),
&mut pages[*page],
shortcut,
&mut self.clipboard,
|value, pasted| {
let text: String = horizon_client::clipboard::filter_text(pasted, true)
.chars()
.take(room)
.collect();
let next = format!("{value}{text}");
font.split(&Text::literal(&next), 114.0).len() <= LINES && {
*value = next;
true
}
},
);
}
_ => {}
}
}
fn edit_page(&mut self, f: impl FnOnce(&mut String)) {
if let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_mut() {
f(&mut pages[*page]);
}
}
/// MultiLineEditBox: characters that keep the page within 14 lines and 1024 characters.
fn type_into_page(&mut self, typed: &str) {
let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_ref() else {
fn book_title_insert(&mut self, text: &str) {
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return;
};
let mut text = pages[*page].clone();
for c in typed.chars() {
let mut next = text.clone();
next.push(c);
if next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES {
text = next;
title_edit.insert_text(text);
title.clone_from(&title_edit.value);
}
/// `BookEditScreen.keyPressed` on the page, apart from the line moves.
fn book_page_key(&mut self, key: KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => return false,
};
let handled = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.key(key, ctrl, shift)
} else {
false
};
if handled {
self.page_keep(before);
}
handled
}
/// One character at a time, so a page stops at the last character that fits.
fn page_insert(&mut self, text: &str) {
for c in text.chars() {
self.page_change(|edit| edit.insert_text(&c.to_string()));
}
}
/// Ctrl+A/C/V/X: the title box while signing, else the page.
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let signing = matches!(self.book, Some(BookScreen::Edit { signing: true, .. }));
if signing {
if let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
{
title_edit.shortcut(shortcut, &mut self.clipboard);
title.clone_from(&title_edit.value);
}
return;
}
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => None,
};
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.shortcut(shortcut, &mut self.clipboard);
}
self.page_keep(before);
}
/// Edit the page box, then keep it only within 14 lines and 1024 characters.
fn page_change(&mut self, f: impl FnOnce(&mut ui::EditBox)) -> bool {
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => return false,
};
let ran = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
f(page_edit);
true
} else {
false
};
ran && self.page_keep(before)
}
/// Write the page, or put the previous text back when it no longer fits.
fn page_keep(&mut self, before: Option<(String, usize, usize)>) -> bool {
let next = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => page_edit.value.clone(),
_ => return false,
};
let ok = next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES;
let Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) = self.book.as_mut()
else {
return false;
};
if ok {
pages[*page].clone_from(&page_edit.value);
} else if let Some((value, cursor, highlight)) = before {
page_edit.value = value;
page_edit.cursor = cursor;
page_edit.highlight = highlight;
}
ok
}
/// ArrowUp and ArrowDown move a visual line, keeping the column.
fn book_move_line(&mut self, dir: isize) {
let shift = self.has_shift_down();
let (text, cursor) = match self.book.as_ref() {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => (pages[*page].clone(), page_edit.cursor),
_ => return,
};
let rows = self.page_rows(&text);
let (x, line) = self.caret_on(&text, &rows, cursor);
let next = (line as isize + dir).clamp(0, rows.len() as isize - 1) as usize;
let (start, end) = rows[next];
let chars: Vec<char> = text.chars().collect();
let slice: String = chars[start..end].iter().collect();
let pos = start + self.gui.atlas.font.fit(&slice, x);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
}
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
let font = &self.gui.atlas.font;
let chars: Vec<char> = page.chars().collect();
let mut rows = Vec::new();
let mut start = 0;
while start < chars.len() {
let mut end = chars.len();
let mut next = chars.len();
let mut last_space = None;
for i in start..chars.len() {
if chars[i] == '\n' {
end = i;
next = i + 1;
break;
}
if chars[i] == ' ' {
last_space = Some(i);
}
let slice: String = chars[start..=i].iter().collect();
if font.width(&slice) > 114.0 {
match last_space {
Some(s) if s > start => {
end = s;
next = s + 1;
}
_ => {
end = i.max(start + 1);
next = end;
}
}
break;
}
}
rows.push((start, end));
start = next;
if start == chars.len() && chars.last() == Some(&'\n') {
rows.push((chars.len(), chars.len()));
}
}
self.edit_page(|p| *p = text);
if rows.is_empty() {
rows.push((0, 0));
}
rows
}
/// GUI x of the caret and the row it sits on.
fn caret_on(&self, page: &str, rows: &[(usize, usize)], cursor: usize) -> (f32, usize) {
let chars: Vec<char> = page.chars().collect();
for (i, &(start, end)) in rows.iter().enumerate() {
if cursor <= end || i + 1 == rows.len() {
let take = cursor.saturating_sub(start).min(end.saturating_sub(start));
let prefix: String = chars.iter().skip(start).take(take).collect();
return (self.gui.atlas.font.width(&prefix), i);
}
}
(0.0, 0)
}
fn page_index_at(&self, page: &str, local_x: f32, local_y: f32) -> usize {
let rows = self.page_rows(page);
let line = (local_y / 9.0).floor().max(0.0) as usize;
let line = line.min(rows.len().saturating_sub(1));
let (start, end) = rows[line];
let chars: Vec<char> = page.chars().collect();
let slice: String = chars[start..end].iter().collect();
start + self.gui.atlas.font.fit(&slice, local_x.max(0.0))
}
/// A click on the centred title (`EditBox.onClick`).
fn book_title_click(&mut self, field_x: f32, mx: f32) {
let shift = self.has_shift_down();
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return;
};
let tx = field_x + ((114.0 - self.gui.atlas.font.width(title)) / 2.0).floor();
let rel = (mx - tx).max(0.0);
let pos = self.gui.atlas.font.fit(title, rel);
title_edit.set_cursor(pos, shift);
}
/// A click on the open page.
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
let shift = self.has_shift_down();
let text = match self.book.as_ref() {
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
_ => return,
};
let pos = self.page_index_at(&text, mx - origin_x, my - origin_y);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
}
/// The page buttons (`pageForward` adds a page while editing).
fn turn_page(&mut self, forward: bool) {
let mut lectern_page = None;
match self.book.as_mut() {
Some(BookScreen::Edit { pages, page, .. }) => {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => {
let before = *page;
if forward {
if *page + 1 >= pages.len() && pages.len() < MAX_PAGES {
pages.push(String::new());
@@ -405,6 +564,10 @@ impl Game<'_> {
} else if *page > 0 {
*page -= 1;
}
if *page != before {
let text = pages[*page].clone();
page_edit.set_value(&text);
}
}
Some(BookScreen::View {
lectern: Some(key), ..
@@ -764,6 +927,8 @@ impl Game<'_> {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
} else if inside(left + 36.0, 50.0, 114.0, 9.0) {
self.book_title_click(left + 36.0, mx);
}
}
Some(BookScreen::Edit { page, .. }) => {
@@ -780,6 +945,8 @@ impl Game<'_> {
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
self.save_book(false);
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
self.book_page_click(left + 36.0, 32.0, mx, my);
}
}
Some(BookScreen::View {
@@ -865,7 +1032,7 @@ impl Game<'_> {
BookScreen::Edit {
signing: true,
title,
opened,
title_edit,
..
} => {
let edit = tr("book.editTitle", "Enter Book Title:");
@@ -881,19 +1048,35 @@ impl Game<'_> {
// The title box: an unbordered, centred EditBox of 114 with
// black text and no shadow; the value alone is centred, the
// cursor follows it.
let value = &title_edit.value;
let x = left + 36.0;
let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor();
horizon_client::ui::append_field(
batch,
atlas,
title,
(tx, 50.0, x + 114.0 - tx),
(BLACK, false),
15,
self.text_selection.covers(BOOK_TITLE, title),
true,
opened.elapsed().as_millis(),
);
let tx = x + ((114.0 - HudBatch::text_width(atlas, value)) / 2.0).floor();
batch.text(atlas, value, tx, 50.0, BLACK, false);
if title_edit.cursor != title_edit.highlight {
let (a, b) = (
title_edit.cursor.min(title_edit.highlight),
title_edit.cursor.max(title_edit.highlight),
);
let pre: String = value.chars().take(a).collect();
let sel: String = value.chars().skip(a).take(b - a).collect();
let sx = tx + HudBatch::text_width(atlas, &pre);
batch.text_highlight(
atlas,
(sx, 49.0, HudBatch::text_width(atlas, &sel), 11.0),
true,
);
}
if title_edit.cursor_visible() {
let pre: String = value.chars().take(title_edit.cursor).collect();
batch.fill(
atlas,
tx + HudBatch::text_width(atlas, &pre),
49.0,
1.0,
9.0,
BLACK,
);
}
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
@@ -941,12 +1124,12 @@ impl Game<'_> {
BookScreen::Edit {
pages,
page,
opened,
page_edit,
..
} => {
indicator(batch, *page, pages.len());
let blink = (opened.elapsed().as_millis() / 300) % 2 == 0;
let lines = self.book_lines(&pages[*page]);
let text = &pages[*page];
let lines = self.book_lines(text);
for (i, line) in lines.iter().take(LINES).enumerate() {
batch.text_styled(
atlas,
@@ -957,27 +1140,39 @@ impl Game<'_> {
false,
);
}
if blink {
// The cursor after the last character.
let last = lines
.last()
.map(|l| atlas.font.width_text(l))
.unwrap_or(0.0);
let row = lines.len().saturating_sub(1) as f32
+ if pages[*page].ends_with('\n') {
1.0
} else {
0.0
};
let x = if pages[*page].ends_with('\n') {
0.0
} else {
last
};
let rows = self.page_rows(text);
let (sel_a, sel_b) = (
page_edit.cursor.min(page_edit.highlight),
page_edit.cursor.max(page_edit.highlight),
);
if sel_a != sel_b {
let chars: Vec<char> = text.chars().collect();
for (i, &(start, end)) in rows.iter().enumerate().take(LINES) {
let from = sel_a.max(start);
let to = sel_b.min(end);
if from >= to {
continue;
}
let pre: String = chars[start..from].iter().collect();
let sel: String = chars[from..to].iter().collect();
batch.text_highlight(
atlas,
(
left + 36.0 + atlas.font.width(&pre),
top + 29.0 + i as f32 * 9.0,
atlas.font.width(&sel),
9.0,
),
true,
);
}
}
if page_edit.cursor_visible() {
let (x, row) = self.caret_on(text, &rows, page_edit.cursor);
batch.fill(
atlas,
left + 36.0 + x,
top + 29.0 + row * 9.0,
top + 29.0 + row as f32 * 9.0,
1.0,
9.0,
BLACK,
+190
View File
@@ -0,0 +1,190 @@
//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`)
//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The
//! items drawn on the fire are `campfire_geometry`'s; a server's clients get
//! them through the block entity's update tag (`lan::chunks::entity_tag`).
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
items::ItemStack, mesher::Vertex, mobs::Who,
};
use horizon_core::position::BlockPos;
/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`).
const KIND: &str = "minecraft:campfire_cooking";
fn is_campfire(name: &str) -> bool {
name == "minecraft:campfire" || name == "minecraft:soul_campfire"
}
impl Game<'_> {
/// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's
/// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food
/// goes into the first free slot (`placeFood`), and the use is consumed
/// whether or not there was room. A client of a server leaves the placing
/// to the server. Whether the food went in; None when this isn't a
/// campfire use, so the item's own use runs.
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !is_campfire(&self.res.facts().get(state).name) {
return Ok(None);
}
let Some(held) = self.held().cloned() else {
return Ok(None);
};
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
return Ok(None);
};
// A client of a server predicts the placing; the server decides.
if self.remote.is_some() {
return Ok(Some(true));
}
let key = (pos[0], pos[1], pos[2]);
let placed = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Campfire(c)) => {
let mut one = held.clone();
c.place_food(&mut one, time)
}
_ => false,
};
if placed {
// ItemStack.consumeAndReturn: creative keeps the item.
self.consume_held();
self.stats.custom("interact_with_campfire", 1);
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Player),
Some(state),
);
self.mark_block_entity_updated(key);
}
Ok(Some(placed))
}
/// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an
/// unlit one. A finished item's result (the recipe's, or the item itself
/// when no recipe takes it any more) drops at the block's corner
/// (`Containers.dropItemStack` at its integer position), then the clients
/// see the slot empty and a `block_change` goes out.
pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> {
let state = self.world.block(pos.0, pos.1, pos.2);
let lit = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("lit"))
.is_some_and(|v| v == "true");
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
// BlockEntity.setChanged: a timer that moved is saved with the
// chunk (no update to the clients, who don't see the timers).
let changed = if lit {
c.items.iter().any(Option::is_some)
} else {
c.cooldown_tick()
};
if changed {
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
if !lit {
return Ok(());
}
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
let done = c.cook_tick();
for (_, item) in done {
let result = self
.res
.recipes()
.cook(KIND, &item)
.map_or_else(|| item.clone(), |r| r.result.clone());
self.campfire_drop(pos, result);
self.mark_block_entity_updated(pos);
self.post_block_game_event(
"minecraft:block_change",
[pos.0, pos.1, pos.2],
None,
Some(state),
);
}
Ok(())
}
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
/// (90° about X), at a corner and three eighths of its size, drawn as a
/// FIXED item seeded by the block's position plus the slot.
pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
for (key, entity) in &self.world.block_entities {
let BlockEntity::Campfire(c) = entity else {
continue;
};
if c.items.iter().all(Option::is_none) {
continue;
}
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
let state = self.world.block(key.0, key.1, key.2);
let facing = match self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing"))
.map(String::as_str)
{
Some("west") => 1,
Some("north") => 2,
Some("east") => 3,
_ => 0,
};
let light = vertex_light(self.world.light(key.0, key.1, key.2));
let packed = BlockPos::new(key.0, key.1, key.2).pack();
for (i, stack) in c.items.iter().enumerate() {
let Some(stack) = stack else {
continue;
};
let seed = (packed as i32).wrapping_add(i as i32);
let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed);
if item.is_empty() {
continue;
}
// Direction.toYRot: 90° per 2D step from south.
let yaw = -(((i + facing) % 4) as f32 * 90.0);
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5),
) * glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_rotation_x(90f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0))
* glam::Mat4::from_scale(glam::Vec3::splat(0.375));
self.submit_item_world(&item, pose, light, out);
}
}
}
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
/// integer position.
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
let at = BlockPos::new(pos.0, pos.1, pos.2);
for piece in
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
{
self.spawn_item(
ItemStack {
count: piece.count,
..stack.clone()
},
piece.pos,
piece.motion,
0,
);
}
}
}
@@ -0,0 +1,53 @@
//! `CampfireBlock.useItemOn`: an item in `#minecraft:douses_campfires` (the
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
impl Game<'_> {
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
pub(super) fn use_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
) -> Result<bool> {
let Some(tool) = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
}) else {
return Ok(false);
};
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
else {
return Ok(false);
};
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
// then the block change game event from the player.
if self.lan_acting.is_none() {
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
for _ in 0..20 {
make_campfire_smoke(&mut self.particles, pos, signal, true);
}
}
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), None);
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = registry.resolve(&state) {
self.level_set(pos, unlit)?;
}
let used = tool.clone();
self.damage_held(1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &used);
Ok(true)
}
}
+30 -4
View File
@@ -464,7 +464,28 @@ impl Game<'_> {
return;
}
// The Chat option's Hidden level takes the server's messages too.
if !self.ui.chat.shows(ChatSource::Server) {
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
}
/// A player's chat line on the window's own chat: a LAN guest's message
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
/// checks `canReceivePlayerMessages` for these, so Commands Only and
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
/// message and takes the system-message permission instead.
pub(super) fn player_message(&mut self, text: Text) {
if let Some(capture) = self.chat_capture.as_mut() {
capture.push(text);
return;
}
// A server's client receives the line as a packet (PlayerChat or
// DisguisedChat) and filters it there.
if self.remote.is_none() {
self.local_chat_line(text, None, ChatSource::Player);
}
}
/// A line on the window's own chat when the Chat option lets `source`
/// through; running mods hear it.
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
if !self.ui.chat.shows(source) {
return;
}
let focused = self.screen == Screen::Chat;
@@ -472,8 +493,8 @@ impl Game<'_> {
self.ui.chat.add(
&self.gui.atlas,
text,
Some(SYSTEM_INDICATOR),
ChatSource::Server,
indicator,
source,
self.ticks,
focused,
);
@@ -602,7 +623,10 @@ impl Game<'_> {
return;
}
if let Some(command) = message.strip_prefix('/') {
self.run_command(command);
// A client mod's command first, then a server mod's, as on a server.
if !self.mod_command(command) && !self.local_server_mod_command(command) {
self.run_command(command);
}
} else {
let line = Text::translate(
"chat.type.text",
@@ -622,6 +646,7 @@ impl Game<'_> {
self.mods_chat(&line);
}
self.lan_host_chat(message);
self.mod_player_chat(super::server_mods::Who::Local, message);
}
}
pub(super) fn chat_scroll(&mut self, amount: f32) {
@@ -757,6 +782,7 @@ impl Game<'_> {
max_snow_accumulation_height: r.int("max_snow_accumulation_height"),
spawn_mobs: r.bool("spawn_mobs"),
spawn_monsters: r.bool("spawn_monsters"),
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
}
}
/// Apply rules mirrored in other state (natural regeneration, difficulty).
+13 -24
View File
@@ -63,8 +63,8 @@ impl Game<'_> {
}
}
}
/// Typing or a backspace in a field of the title screens: in the server
/// screens' append-only fields Select All's selection is replaced first.
/// Typing or a backspace in a title-screen field. A field that still only
/// appends replaces a Select All selection before the edit.
pub(super) fn title_edit(&mut self, typed: Option<&str>, backspace: bool) {
let Some(title) = self.title.as_mut() else {
return;
@@ -86,42 +86,31 @@ impl Game<'_> {
}
/// Shortcuts in the creative inventory's search box (an `EditBox` of 50).
pub(super) fn creative_search_shortcut(&mut self, shortcut: EditShortcut) {
if Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
CREATIVE_SEARCH,
&mut self.creative_screen.search,
shortcut,
CREATIVE_SEARCH_CHARS,
) {
if self
.creative_screen
.search
.shortcut(shortcut, &mut self.clipboard)
{
self.creative_screen.scroll = 0.0;
}
}
/// A backspace in the creative search box: with Ctrl, the whole word
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
/// A selection is what it deletes, as in `EditBox.keyPressed`.
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
let search = &mut self.creative_screen.search;
if !self.text_selection.take(CREATIVE_SEARCH, search) {
if ctrl {
horizon_client::ui::delete_words(search, -1);
} else {
search.pop();
}
}
self.creative_screen
.search
.key(KeyCode::Backspace, ctrl, false);
self.creative_screen.scroll = 0.0;
}
/// Typing in the creative search box.
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
let search = &mut self.creative_screen.search;
self.text_selection.take(CREATIVE_SEARCH, search);
if let Some(text) = typed {
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
self.creative_screen.search.insert_text(text);
self.creative_screen.scroll = 0.0;
}
self.creative_screen.scroll = 0.0;
}
}
/// `CreativeModeInventoryScreen`'s search box: `setMaxLength(50)`.
pub(super) const CREATIVE_SEARCH_CHARS: usize = 50;
pub(super) const CREATIVE_SEARCH: horizon_client::clipboard::FieldId = ("creative.search", 0);
+12 -3
View File
@@ -31,6 +31,10 @@ pub(super) struct CombatTracker {
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// Charged creepers whose blast just went off and that have not dropped
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
/// but the mobs they killed still ask for their loot.
pub(super) charged_exploders: Vec<u32>,
/// The player's fall distance before this tick's movement (as of the
/// end of the last one).
fall_before: f64,
@@ -52,11 +56,16 @@ impl Game<'_> {
/// Remember who deals the next `damage_type` damage to the player.
pub(super) fn note_attacker(&mut self, damage_type: &str, mob: u32) {
if let Some(name) = self.mob_display_name(mob) {
self.combat
.pending
.push((damage_type.to_owned(), (mob, name)));
self.note_named_attacker(damage_type, mob, name);
}
}
/// `note_attacker` for an entity that is not one of `self.mobs` (the
/// ender dragon).
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
self.combat
.pending
.push((damage_type.to_owned(), (id, name)));
}
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
+79 -34
View File
@@ -1697,6 +1697,8 @@ impl Game<'_> {
value: 0,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
});
name = Text::translate("entity.minecraft.experience_orb", &[]);
}
@@ -1885,11 +1887,12 @@ impl Game<'_> {
}
}
}
let Some((_, new)) = horizon_client::save::decode_block_entity(&tag) else {
let Some((_, mut new)) = horizon_client::save::decode_block_entity(&tag) else {
return false;
};
let rolled = self.take_loot_table(key, &tag, &mut new);
let after = horizon_client::save::encode_block_entity(key, &new, &name);
if after == before {
if after == before && !rolled {
return false;
}
self.world.block_entities.insert(key, new);
@@ -1897,6 +1900,79 @@ impl Game<'_> {
let _ = self.world.set(pos[0], pos[1], pos[2], state);
true
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
/// container (`setblock`, `data`): an empty container with a `LootTable`
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
/// the roll is kept with its chunk, so that the container saves, and
/// `data get` shows, its table and seed until its items change. A tag
/// without one drops what the container had. True when it had a table.
pub(super) fn take_loot_table(
&mut self,
key: (i32, i32, i32),
tag: &horizon_world::nbt::Tag,
entity: &mut horizon_client::containers::BlockEntity,
) -> bool {
use horizon_client::{containers::BlockEntity, save, structure_blocks};
let table = save::text(tag.get("LootTable"));
let rolled = match (table, entity) {
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
let loot = structure_blocks::loot_data(&self.options.resources);
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
let biome = self.biome_name_at(at).unwrap_or_default();
Some(structure_blocks::roll_container(
slots,
loot.as_deref(),
table,
structure_blocks::loot_seed(tag),
key,
&biome,
))
}
_ => None,
};
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
return false;
};
let held = |c: &horizon_client::chunk::Chunk| {
c.saved
.as_ref()
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
};
if rolled.is_none() && !held(chunk) {
return false;
}
let chunk = std::sync::Arc::make_mut(chunk);
let mut extras = chunk
.saved
.take()
.map(std::sync::Arc::unwrap_or_clone)
.unwrap_or_default();
extras.rolled_loot.retain(|r| r.pos != key);
let taken = rolled.is_some();
extras.rolled_loot.extend(rolled);
chunk.saved = Some(std::sync::Arc::new(extras));
taken
}
/// The loot table and seed a container still holding its roll saves
/// (`RolledLoot::restore`), in place of its items.
pub(super) fn restore_loot_table(
&self,
key: (i32, i32, i32),
tag: &mut horizon_world::nbt::Tag,
) {
let horizon_world::nbt::Tag::Compound(map) = tag else {
return;
};
let rolled = self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key));
if let Some(r) = rolled {
r.restore(map);
}
}
fn cmd_fill(&mut self, ctx: &Context) -> R<i32> {
let a = self.block_pos_arg(ctx, "from")?;
let b = self.block_pos_arg(ctx, "to")?;
@@ -2593,38 +2669,7 @@ impl Game<'_> {
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
let s = self.pack.assets.registry.state(state)?;
let name = s.name.strip_prefix("minecraft:")?;
Some(match name {
"blast_furnace" => "minecraft:armorer",
"smoker" => "minecraft:butcher",
"cartography_table" => "minecraft:cartographer",
"brewing_stand" => "minecraft:cleric",
"composter" => "minecraft:farmer",
"barrel" => "minecraft:fisherman",
"fletching_table" => "minecraft:fletcher",
"cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => {
"minecraft:leatherworker"
}
"lectern" => "minecraft:librarian",
"stonecutter" => "minecraft:mason",
"loom" => "minecraft:shepherd",
"smithing_table" => "minecraft:toolsmith",
"grindstone" => "minecraft:weaponsmith",
"bell" => "minecraft:meeting",
"beehive" => "minecraft:beehive",
"bee_nest" => "minecraft:bee_nest",
"nether_portal" => "minecraft:nether_portal",
"lodestone" => "minecraft:lodestone",
"test_instance_block" => "minecraft:test_instance",
n if n.ends_with("_bed") => {
if s.properties.get("part").map(String::as_str) != Some("head") {
return None;
}
"minecraft:home"
}
n if n.ends_with("lightning_rod") => "minecraft:lightning_rod",
_ => return None,
})
horizon_client::save::poi_type(s).map(|(kind, _)| kind)
}
/// `LocateCommand.locatePoi`: `PoiManager.findClosestWithType` within
/// 256 blocks of the source, over the loaded chunks.
@@ -286,6 +286,7 @@ impl Game<'_> {
if let Tag::Compound(c) = &mut tag {
c.remove(&"keepPacked".into());
}
self.restore_loot_table(key, &mut tag);
Ok(tag)
}
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
@@ -296,7 +297,8 @@ impl Game<'_> {
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
DataRef::Block(p) => {
let key = (p[0], p[1], p[2]);
if let Some((_, new)) = save::decode_block_entity(&tag) {
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
@@ -49,26 +49,12 @@ impl Game<'_> {
let mut tracker = Tracker::default();
for &t in &targets {
let h = holder(self.target_name(t));
// ServerRecipeBook.addRecipes/removeRecipes count recipe book
// displays (a transmute recipe has one per material count).
let mut n = 0;
// ServerPlayer.awardRecipes / resetRecipes: the displays changed.
let index = &self.res.stations().recipe_index;
n += if give {
let (count, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
self.recipe_unlocked(&unlocked);
count as i32
} else {
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
};
// ServerPlayer.awardRecipes / resetRecipes on the target's own
// ServerRecipeBook: a guest's is swapped in for the call, and the
// packets go to its client.
let n = self
.for_player(t, |g| g.change_recipes(give, &recipes))
.unwrap_or(0);
tracker.track(&h, n);
}
let verb = if give { "give" } else { "take" };
@@ -92,6 +78,27 @@ impl Game<'_> {
Ok(total)
}
/// ServerRecipeBook.addRecipes/removeRecipes for the player in place: how
/// many recipe book displays changed (a transmute recipe has one per
/// material count).
fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 {
let index = &self.res.stations().recipe_index;
if give {
let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
self.recipe_unlocked(&unlocked);
return count as i32;
}
let removed = self.level.recipe_book.remove_recipes(index, recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
}
// ---- /damage ----------------------------------------------------------------
fn damage_kind(ty: &str) -> DamageKind {
@@ -5,6 +5,7 @@
use super::{Game, commands::compute::ComputeContext};
use horizon_client::{
items::ItemStack,
mobs::villager,
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{position::BlockPos, random::RandomSource};
@@ -86,6 +87,82 @@ impl Game<'_> {
self.composter_add(pos, &layers);
(stack.count > 0).then_some(stack)
}
/// `WorkAtComposter.compostItems` for the villager at `mob`: a full
/// composter is emptied, then up to 20 wheat or beetroot seeds go in
/// (keeping 10 of each kind) until the composter reaches level 7. Level
/// event 1500 plays once, at the end.
pub(super) fn villager_compost_items(&mut self, mob: usize, pos: [i32; 3]) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let Some(mut before) = self.composter_level(pos) else {
return;
};
if before == 8 {
self.with_level(|b, level, ticks| b.composter_extract(level, ticks, at));
let _ = self.process_level_events();
before = 0;
}
let mut remaining = 20;
let mut amounts: Vec<(std::sync::Arc<str>, i32)> = Vec::new();
let slots = villager::ext(&self.mobs[mob]).map_or(0, |e| e.inventory.len());
'slots: for slot in (0..slots).rev() {
if remaining <= 0 {
break;
}
let stack = villager::ext_mut(&mut self.mobs[mob]).inventory[slot].clone();
if !matches!(
&*stack.item,
"minecraft:wheat_seeds" | "minecraft:beetroot_seeds"
) {
continue;
}
let known = amounts.iter().position(|(item, _)| *item == stack.item);
let total = known.map_or(0, |k| amounts[k].1) + stack.count as i32;
match known {
Some(k) => amounts[k].1 = total,
None => amounts.push((stack.item.clone(), total)),
}
let uses = (total - 10).min(remaining).min(stack.count as i32);
if uses <= 0 {
continue;
}
remaining -= uses;
for _ in 0..uses {
self.villager_insert_item(mob, slot, &stack, pos);
if self.composter_level(pos) == Some(7) {
break 'slots;
}
}
}
let after = self.composter_level(pos).unwrap_or(before);
self.with_level(|b, level, ticks| {
b.composter_fill_effects(level, ticks, at, after != before)
});
let _ = self.process_level_events();
}
/// `ComposterBlock.insertItem` with a villager's stack: below level 7 a
/// compostable item is used up and may add a layer.
fn villager_insert_item(&mut self, mob: usize, slot: usize, stack: &ItemStack, pos: [i32; 3]) {
if self.composter_level(pos).is_none_or(|level| level >= 7) {
return;
}
let Some(layers) = self.compostable(stack) else {
return;
};
let context = ComputeContext {
block: Some(pos),
target: None,
};
let layers = self.provider_int(&layers, &context, 0).unwrap_or(0);
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.composter_layer(level, ticks, at, layers));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
let _ = self.process_level_events();
let inventory = &mut villager::ext_mut(&mut self.mobs[mob]).inventory;
inventory[slot].count -= 1;
if inventory[slot].count == 0 {
inventory.remove(slot);
}
}
/// `ComposterBlock.handleFill` (level event 1500) on the client: the
/// fill sound and ten composter particles above the compost.
pub(super) fn composter_fill_event(&mut self, pos: BlockPos, success: bool) {
+309 -50
View File
@@ -50,9 +50,9 @@ pub(super) struct Creatures {
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
}
struct ProjWorld<'a, 'w> {
w: &'a MobWorld<'w>,
targets: Vec<(Who, Aabb)>,
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
pub targets: Vec<(Who, Aabb)>,
}
impl ProjectileWorld for ProjWorld<'_, '_> {
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
@@ -193,6 +193,7 @@ impl Game<'_> {
velocity: p.velocity,
yaw: self.yaw,
hero: self.potions.effects.get("hero_of_the_village").is_some(),
effects: self.potions.effects.list.iter().map(|e| e.id).collect(),
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
@@ -373,6 +374,7 @@ impl Game<'_> {
// Drawn seated (Entity.isPassenger); set apart because the
// json! above is at the macro recursion limit.
state["passenger"] = m.extra.passenger.into();
state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0));
state
})
.collect();
@@ -391,6 +393,9 @@ impl Game<'_> {
}
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
let (rain, thunder) = self.world.weather.levels(1.0);
// Level.canHaveWeather: only the overworld's rain and thunder levels
// ever rise, so nothing rains or thunders in the Nether or the End.
let weather = self.dim == Dim::Overworld;
let player = self.player_view();
MobWorld {
world: &self.world,
@@ -407,8 +412,8 @@ impl Game<'_> {
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
game_time: self.world.game_time,
day_time: self.world.day_time,
raining: rain > 0.2,
thundering: thunder > 0.9,
raining: weather && rain > 0.2,
thundering: weather && thunder > 0.9,
nether: self.dim == Dim::Nether,
block_tags: &self.res.items().block_tags,
biomes: self.res.biomes(),
@@ -493,6 +498,8 @@ impl Game<'_> {
self.died.insert(m.id);
self.mob_died(&m);
}
// Their victims have been looted: no charged creeper is waiting any more.
self.combat.charged_exploders.clear();
let uuid = self.level.player_uuid;
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
let mut events = Vec::new();
@@ -775,7 +782,17 @@ impl Game<'_> {
.mobs
.iter()
.find(|o| o.id == id)
.map(|o| drops::loot_entity(o, None)),
.map(|o| drops::loot_entity(o, None))
// A charged creeper's blast kills after the creeper left the list.
.or_else(|| {
self.combat
.charged_exploders
.contains(&id)
.then(|| LootEntity {
powered: true,
..LootEntity::new("minecraft:creeper")
})
}),
None => None,
};
let direct = match d.kind {
@@ -811,12 +828,12 @@ impl Game<'_> {
world_seed: self.level.seed,
};
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
if skulls
&& let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker)
&& let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id)
{
if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) {
// Creeper.droppedSkulls: one head per charged explosion.
creeper.extra.charged = false;
self.combat.charged_exploders.retain(|c| *c != id);
if let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) {
creeper.extra.charged = false;
}
}
let raider = horizon_client::mobs::raider::is_raider(m.species());
for d in items {
@@ -834,9 +851,51 @@ impl Game<'_> {
}
}
}
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
/// sequence with the mob as `this` (a chicken's egg depends on its
/// variant); the loot data of the game's resources decides, and without it
/// the items' table engine (no entity) rolls it.
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
use horizon_client::mobs::drops;
use horizon_world::loot::EntityLootParams;
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
let this = self
.mobs
.iter()
.find(|m| m.id == mob)
.map(|m| drops::loot_entity(m, None));
let stacks = match (loot, this) {
(Some(loot), Some(this)) if loot.has_table(table) => {
let params = EntityLootParams {
this,
..EntityLootParams::default()
};
let seed = self.level.seed;
match loot.random_sequence(table) {
Some(id) => {
let random = self.rand_sequences.get(id, seed);
loot.roll_entity(table, random, &params)
.iter()
.filter(|s| !s.is_empty())
.map(from_loot)
.collect()
}
None => Vec::new(),
}
}
_ => self
.res
.items()
.loot_table(table, false, None, &mut self.world.random),
};
for d in stacks {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
}
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
@@ -898,16 +957,24 @@ impl Game<'_> {
owner,
} => self.add_fangs(at, yaw, delay, owner),
MobEvent::Shoot(..) => {}
MobEvent::ExplosionSource { mob, charged } => {
self.combat.explosion_source = Some(mob);
if charged {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let source_mob = self
.mobs
.iter()
.filter(|m| {
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) < 1.0
})
.map(|m| m.id)
.next();
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
.iter()
.filter(|m| {
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2)
< 1.0
})
.map(|m| m.id)
.next()
});
if let Some(id) = source_mob {
self.note_attacker("minecraft:explosion", id);
}
@@ -938,6 +1005,9 @@ impl Game<'_> {
volume,
pitch,
);
} else {
// Left for the end of the tick.
t.outbox.push(event);
}
}
}
@@ -1040,6 +1110,40 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
self.process_level_events()?;
}
MobEvent::BreakDoor { pos } => {
// BreakDoorGoal.tick: removeBlock, then level events 1021
// and 2001 with the state that is there now.
let air = self.world.air;
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3));
self.process_level_events()?;
self.level_event_at(1021, pos, 0);
let left = self.world.block(pos[0], pos[1], pos[2]);
self.level_event_at(2001, pos, left.0 as i32);
}
MobEvent::DestroyProgress { mob, pos, progress } => {
self.destroy_block_progress(mob as i32, pos, progress);
// ServerLevel.destroyBlockProgress: every player nearby sees it.
if let Some(host) = self.lan.as_ref() {
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
horizon_protocol::packets::play::BlockDestruction {
id: mob as i32,
pos,
progress: if (0..10).contains(&progress) {
progress as u8
} else {
255
},
},
),
None,
);
}
}
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
MobEvent::SetBlockName { pos, name, effect } => {
if effect {
let old = self.world.block(pos[0], pos[1], pos[2]);
@@ -1062,7 +1166,10 @@ impl Game<'_> {
}
MobEvent::BreakBlock { pos, drop } => {
let old = self.world.block(pos[0], pos[1], pos[2]);
self.destroy_particles(pos, old);
// Block.spawnDestroyParticles: fire breaks leave nothing.
if !self.res.facts().get(old).has_class("BaseFireBlock") {
self.destroy_particles(pos, old);
}
if drop {
let drops = self.res.items().block_drops_in(
self.pack.assets.registry,
@@ -1084,6 +1191,9 @@ impl Game<'_> {
self.ensure_item_particle_sprites();
}
let options = horizon_client::particles::ParticleOptions::simple(ty);
if ty == horizon_client::particles::ParticleType::SonicBoom {
self.lan_sonic_boom(&options, at);
}
// Count 0: one particle exactly there, at rest.
if count == 0 {
self.particles.add(&options, at, [0.0; 3]);
@@ -1117,7 +1227,7 @@ impl Game<'_> {
self.particles.add(&options, at, velocity);
}
}
MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount),
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
MobEvent::AxolotlSupportPlayer => {
let previous = self
.potions
@@ -1179,6 +1289,10 @@ impl Game<'_> {
self.mob_vibration_particle(from, to, ticks)
}
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
MobEvent::BlockBreakEffect { pos, block } => {
let state = self.world.block(block[0], block[1], block[2]);
self.destroy_particles(pos, state);
}
MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius),
MobEvent::CreakingHurt {
home,
@@ -1189,12 +1303,14 @@ impl Game<'_> {
self.creaking_tear_down(center, spread)
}
MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob),
MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target),
MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other),
MobEvent::VillagerBirth {
parent,
partner,
child,
} => self.villager_birth(parent, partner, child),
MobEvent::WorkAtComposter { mob, pos } => self.villager_work_at_composter(mob, pos),
MobEvent::GolemCheck { mob, needed } => self.villager_golem_check(mob, needed),
MobEvent::Firework { at, color, flight } => {
self.villager_firework(at, color, flight)
@@ -1215,19 +1331,51 @@ impl Game<'_> {
m.ai.aggressive |= aggressive;
}
}
MobEvent::GiftLoot { table, at } => {
let drops =
self.res
.items()
.loot_table(table, false, None, &mut self.world.random);
for d in drops {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
MobEvent::GiftLoot { mob, table, at } => {
// Turtle.ageBoundaryReached: only with mob loot on.
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
{
continue;
}
self.drop_gift_loot(mob, table, at);
}
}
}
Ok(())
}
/// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle
/// per egg, a day from grown, with the egg as its home (the sound, the
/// crumbs and the block's removal came from the level).
pub(super) fn hatch_turtles(
&mut self,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
) {
let eggs = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("eggs"))
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(1);
for i in 0..eggs {
let at = [
f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2,
f64::from(pos[1]),
f64::from(pos[2]) + 0.3,
];
if let Some(id) = self.spawn_mob("minecraft:turtle", at, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.extra.age = -24000;
m.extra.home = Some(pos);
m.yaw = 0.0;
m.body_yaw = 0.0;
m.head_yaw = 0.0;
}
}
}
fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
@@ -1261,6 +1409,13 @@ impl Game<'_> {
m.extra.egg_time = life;
}
}
/// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the
/// `mob_drops` game rule is on.
fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) {
if self.ui.rules.bool("mob_drops") {
self.spawn_orbs(at, amount);
}
}
/// Animal.spawnChildFromBreeding
fn breed(
&mut self,
@@ -1283,6 +1438,8 @@ impl Game<'_> {
.unwrap_or_else(|| parent.clone());
let mut child = parent.clone();
child.baby = true;
// TurtleBreedGoal.breed triggers it without a child.
let turtle = p.species() == Species::Turtle;
if matches!(
(
p.species(),
@@ -1295,7 +1452,7 @@ impl Game<'_> {
) {
child.kind = "minecraft:mule".into();
}
self.trigger_bred(&parent, &partner_view, Some(&child));
self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
}
if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) {
q.extra.age = 6000;
@@ -1307,6 +1464,19 @@ impl Game<'_> {
self.lay_sniffer_egg(&p);
return;
}
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
// experience is left, and only with mob loot on.
if p.species() == Species::Turtle {
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
// split up as `ExperienceOrb.award` does).
if self.ui.rules.bool("mob_drops")
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
{
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
self.add_orb([p.position.x, p.position.y, p.position.z], xp);
}
return;
}
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// A horse and a donkey have a mule.
@@ -1354,7 +1524,7 @@ impl Game<'_> {
}
self.world.random = r;
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
if p.species() == Species::Cat {
@@ -1375,7 +1545,7 @@ impl Game<'_> {
};
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
if p.species() == Species::Wolf {
@@ -1397,7 +1567,7 @@ impl Game<'_> {
};
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
@@ -1413,7 +1583,7 @@ impl Game<'_> {
}
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
}
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
/// mining fatigue III for five minutes unless a stronger or longer one
@@ -1509,15 +1679,19 @@ impl Game<'_> {
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
}
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
// owner) caused the hit, not a player.
let living_cause = attacker.or(owner).is_some();
let mut outcome = horizon_client::health::BlockOutcome::default();
if let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Ok(o) = player.hurt_blocking(
&& let Ok(o) = player.hurt_caused_by(
&self.motion.health,
self.world.health_rules,
id,
amount,
blocking,
living_cause,
)
&& {
outcome = o;
@@ -1535,8 +1709,10 @@ impl Game<'_> {
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
}
}
self.hurt_armor(amount);
self.thorns(from);
// The thorns hurt the entity that caused the hit, if there is one.
if let Some(a) = attacker.or(owner) {
self.thorns(a);
}
}
if raised {
self.after_block(outcome, amount, attacker, projectile);
@@ -1987,6 +2163,23 @@ impl Game<'_> {
self.creatures.projectiles.push(arrow);
}
}
/// `TargetBlock.onProjectileHit`: a target block takes its power from
/// where the projectile landed and resets after 20 ticks for an arrow, 8
/// for any other projectile. A player's shot counts as a target hit.
fn target_block_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) {
let block = BlockPos::new(pos[0], pos[1], pos[2]);
let arrow = p.arrow.is_some();
let location = [at.x, at.y, at.z];
let hit = self.with_level(|b, level, ticks| {
b.target_projectile_hit(level, ticks, block, location, face, arrow)
});
if hit.is_some() {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
self.stats.custom("target_hit", 1);
}
}
}
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
self.combat.projectile_owner = match p.owner {
Some(Who::Mob(id)) => Some(id),
@@ -1996,6 +2189,13 @@ impl Game<'_> {
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
Hit::None => return Ok(()),
};
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
// projectile (a wind charge out of the world's top has no face).
if let Hit::Block { pos, face, .. } = hit
&& face != [0; 3]
{
self.target_block_hit(p, pos, at, face);
}
if let Hit::Entity {
who: Who::Mob(id),
at,
@@ -2358,12 +2558,14 @@ impl Game<'_> {
// ShulkerBullet.onHitEntity: 4 damage, then levitation for 10 s.
(ProjectileKind::ShulkerBullet { .. }, Hit::Entity { who, .. }) => match who {
Who::Player => {
if !self.creative {
self.mob_hits_player(
// Only a hit that hurt (`wasHurt`) brings the levitation.
if !self.creative
&& self.mob_hits_player(
4.0,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:mob_projectile",
);
)
{
self.give_player_effect("minecraft:levitation", 200, 0);
}
}
@@ -2372,6 +2574,8 @@ impl Game<'_> {
&& m.damage(4.0, source)
{
m.extra.levitation = 200;
// Shulker.hurtServer: a shulker hit by a bullet.
horizon_client::mobs::shulker::hit_by_bullet(m);
}
}
},
@@ -2453,21 +2657,22 @@ impl Game<'_> {
.as_ref()
.map(|h| h.item.to_string())
.unwrap_or_default();
// Bucketable.bucketMobPickup: a water bucket scoops up a fish, a
// tadpole or an axolotl, with the data its bucket carries.
if item == "minecraft:water_bucket"
&& self.mobs[index].species() == Species::Axolotl
&& self.mobs[index].alive()
&& let Some((bucket, sound)) = bucketable(self.mobs[index].species())
{
let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1);
let mut filled = ItemStack::new(bucket, 1);
filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components(
&self.mobs[index],
)));
self.audio.play(
"minecraft:item.bucket.fill_axolotl",
Some(self.mobs[index].center()),
1.0,
1.0,
);
self.audio
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
self.trigger_item("minecraft:filled_bucket", &filled);
// Leashable.dropLeash, then discard.
let id = self.mobs[index].id;
self.break_leash(id, true, false);
self.mobs[index].removed = true;
if self.creative {
if let Some(rest) = self.give(filled) {
@@ -2521,6 +2726,19 @@ impl Game<'_> {
}
return Ok(true);
}
// Creeper.mobInteract: any item in #creeper_igniters, damaged when it
// is damageable and used up otherwise.
let creeper_igniter = self
.res
.items()
.item_tags
.get("minecraft:creeper_igniters")
.is_some_and(|t| t.contains(&*item));
let damageable = self
.res
.items()
.get(&item)
.is_some_and(|i| i.max_damage > 0);
let m = &mut self.mobs[index];
if !m.alive() {
return Ok(false);
@@ -2536,6 +2754,7 @@ impl Game<'_> {
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
let is_food = m.kind.food.contains(&item);
let baby_turtle = species == Species::Turtle && m.is_baby();
match species {
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
@@ -2761,10 +2980,21 @@ impl Game<'_> {
consume = true;
sound = Some("entity.iron_golem.repair");
}
Species::Creeper if item == "minecraft:flint_and_steel" => {
Species::Creeper if creeper_igniter => {
m.extra.flag = true;
sound = Some("item.flintandsteel.use");
self.damage_held_tool();
// FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE,
// at the creeper's feet with a pitch from its own random.
let event = if item == "minecraft:fire_charge" {
"item.firecharge.use"
} else {
"item.flintandsteel.use"
};
eat = Some((event, 1.0, m.random.next_float() * 0.4 + 0.8));
if damageable {
self.damage_held_tool();
} else {
consume = true;
}
}
// Parrot.mobInteract: parrot food may tame a wild one (one time in
// ten); a parrot never takes it as Animal food (isFood is false).
@@ -2814,6 +3044,14 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
if baby_turtle && !self.mobs[index].is_baby() {
self.handle_mob_events(vec![MobEvent::GiftLoot {
mob: self.mobs[index].id,
table: "minecraft:gameplay/turtle_grow",
at: feet,
}])?;
}
self.swing_arm();
if let Some(s) = sound {
let pitch =
@@ -3247,6 +3485,26 @@ impl Game<'_> {
}
}
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
/// bucket picks up.
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
let fish = "minecraft:item.bucket.fill_fish";
Some(match species {
Species::Cod => ("minecraft:cod_bucket", fish),
Species::Salmon => ("minecraft:salmon_bucket", fish),
Species::Pufferfish => ("minecraft:pufferfish_bucket", fish),
Species::TropicalFish => ("minecraft:tropical_fish_bucket", fish),
Species::Tadpole => (
"minecraft:tadpole_bucket",
"minecraft:item.bucket.fill_tadpole",
),
Species::Axolotl => (
"minecraft:axolotl_bucket",
"minecraft:item.bucket.fill_axolotl",
),
_ => return None,
})
}
/// Mob.getBaseExperienceReward
/// The damage type id of a Horizon damage kind.
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
@@ -3291,6 +3549,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
}
}
/// A generated loot stack as a Horizon item stack.
@@ -6,6 +6,7 @@
//! finds the ammo for bows and crossbows.
use super::{Game, shield::OFFHAND_SLOT, view_vector};
use horizon_client::{
enchant::EffectContext,
items::ItemStack,
mobs::Who,
projectiles::{Projectile, ProjectileKind},
@@ -152,6 +153,20 @@ impl Game<'_> {
};
let charged = std::mem::take(&mut bow.data_mut().charged);
bow.tidy();
let weapon = bow.clone();
// AbstractArrow's constructor: `getPiercingCount` of the weapon.
let pierce = self
.res
.stations()
.enchantments
.modify_value(
Some(&weapon),
"minecraft:projectile_piercing",
&EffectContext::default(),
&mut self.world.random,
0.0,
)
.clamp(0.0, 127.0) as u8;
let count = charged.len();
// getShootingPower: a rocket among them slows every shot.
let power = if charged.iter().any(|s| &*s.item == FIREWORK_ROCKET) {
@@ -204,6 +219,7 @@ impl Game<'_> {
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(ammo);
a.set_owner(Some(Who::Player));
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.creatures.projectiles.push(arrow);
+494
View File
@@ -0,0 +1,494 @@
//! Cushions: `CushionItem.useOn` placement on the top face of a block,
//! `Cushion.canBePlacedAt`/`survives`, `Cushion.interact` (sit),
//! `BlockAttachedEntity.hurtServer` (break, dropping the matching colour),
//! `BlockAttachedEntity.tick` and `CushionRenderer`.
//!
//! 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, view_vector};
use horizon_client::entities::{Cushion, dye_index};
use horizon_client::items::ItemStack;
use horizon_client::mobs::anim::Poses;
use horizon_core::geometry::{Aabb, Vec3};
use winit::keyboard::KeyCode;
/// `Direction.fromYRot(yaw).toYRot()`: the nearest 90 degree step.
pub(crate) fn snap_yaw(yaw: f32) -> f32 {
(((f64::from(yaw) / 90.0 + 0.5).floor() as i64) & 3) as f32 * 90.0
}
fn aabb(bounds: ([f64; 3], [f64; 3])) -> Aabb {
Aabb::new(
Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]),
Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]),
)
}
fn overlaps(a: Aabb, b: Aabb) -> bool {
a.intersects(b)
}
use horizon_client::cushion::blocks_in;
struct PlacementWorld<'a, 'r>(&'a Game<'r>);
impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
let boxes = self.0.outline_boxes(p);
let mut shape = *boxes.first()?;
for b in &boxes[1..] {
for k in 0..3 {
shape[k] = shape[k].min(b[k]);
shape[k + 3] = shape[k + 3].max(b[k + 3]);
}
}
Some(Aabb::new(
Vec3::new(shape[0], shape[1], shape[2]),
Vec3::new(shape[3], shape[4], shape[5]),
))
}
fn collision_shapes(&self, bounds: Aabb) -> Vec<horizon_core::geometry::VoxelShape> {
let binding = self.0.res.collision();
let mut world = horizon_client::collisions::SceneCollisions {
world: &self.0.world,
binding,
subject: None,
};
// Include neighbour blocks whose collision shape can reach outside their cell.
blocks_in(bounds.inflate(Vec3::new(1.0, 1.0, 1.0)))
.filter_map(|p| {
let state = self.0.world.block(p[0], p[1], p[2]);
binding
.collision_shape(&mut world, state, p)
.ok()
.map(|s| s.moved(Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))))
})
.collect()
}
fn suffocating(&self, p: [i32; 3]) -> bool {
let state = self.0.world.block(p[0], p[1], p[2]);
let facts = self.0.res.facts().get(state);
let name = facts.name.as_ref();
// Blocks' explicit predicates, then the default causes_suffocation/full-shape rule.
match name {
"minecraft:dirt_path"
| "minecraft:farmland"
| "minecraft:mud"
| "minecraft:soul_sand" => true,
"minecraft:piston" | "minecraft:sticky_piston" => self
.0
.pack
.assets
.registry
.state(state)
.is_some_and(|s| s.properties.get("extended").is_some_and(|p| p == "false")),
"minecraft:mangrove_roots" | "minecraft:moving_piston" => false,
_ if name.ends_with("_leaves")
|| name.ends_with("glass")
|| name.ends_with("copper_grate") =>
{
false
}
// ShulkerBoxBlock: suffocating while closed; a box seen open
// here is rare enough to count as closed.
_ if name.ends_with("shulker_box") => true,
_ => {
facts.full_collision
&& self
.0
.res
.items()
.block_in_tag("minecraft:causes_suffocation", name)
}
}
}
}
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 = 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() {
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
continue;
};
if best.is_none_or(|b| d < b.1) {
best = Some((i, d, std::array::from_fn(|k| eye[k] + dir[k] * d)));
}
}
best
}
pub(super) fn cushion_survives(&self, c: &Cushion) -> bool {
horizon_client::cushion::survives(&PlacementWorld(self), aabb(c.bounds()))
}
/// `Cushion.destroyIfInFire`: a fire-tagged block inside the deflated box.
fn cushion_in_fire(&self, c: &Cushion) -> bool {
let bounds = aabb(c.bounds()).next_deflated();
blocks_in(bounds).any(|p| {
let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2]));
self.res.items().block_in_tag("minecraft:fire", &facts.name)
})
}
/// `CushionItem.useOn`: on the top face only, spawning at the click's
/// height above the clicked block (or the replaceable block itself).
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let Some(info) = self.res.items().get(&stack.item).cloned() else {
return false;
};
let Some(default_color) = info.cushion_color.as_deref() else {
return false;
};
let color_override = stack
.data()
.and_then(|d| d.other("minecraft:cushion/color"))
.and_then(|tag| match tag {
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
});
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
// CushionItem.recalculateContextForSpecialCollisionShapes: open containers
// (cauldrons, hoppers and composters) use the collision ray's actual height.
let mut adjusted = Hit {
pos: hit.pos,
face: hit.face,
point: hit.point,
};
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if self.res.items().block_in_tag(
"minecraft:cushion_uses_collision_shape",
&self.res.facts().get(state).name,
) && let Some(eye) = self.eye_position()
{
let start = Vec3::new(eye[0], eye[1], eye[2]);
let point = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
let end = point.add_vector(point.subtract(start).normalize().scale(0.001));
let binding = self.res.collision();
let mut world = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding,
subject: None,
};
if let Ok(shape) = binding.collision_shape(&mut world, state, hit.pos)
&& let Some(result) = shape.clip(
start,
end,
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
)
{
adjusted.face = result.direction.normal();
adjusted.point = [result.location.x, result.location.y, result.location.z];
}
}
let hit = &adjusted;
if hit.face != [0, 1, 0] {
return false;
}
// BlockPlaceContext: a replaceable clicked block is replaced.
let clicked = self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]));
let pos = if clicked.replaceable {
hit.pos
} else {
std::array::from_fn(|i| hit.pos[i] + hit.face[i])
};
// `Vec3.atCenterOfWithY(clickedPos, clickLocation.y)`.
let y = hit.point[1];
let position = Vec3::new(f64::from(pos[0]) + 0.5, y, f64::from(pos[2]) + 0.5);
let bounds = aabb((
[position.x - 0.5, position.y, position.z - 0.5],
[position.x + 0.5, position.y + 0.25, position.z + 0.5],
));
if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) {
return false;
}
// `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`.
if self
.cushions
.iter()
.any(|c| overlaps(aabb(c.bounds()), bounds))
{
return false;
}
self.next_entity += 1;
let id = self.next_entity;
// `BlockAttachedEntity.setPos`: `BlockPos.containing(position)`.
let attached = [
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
];
self.cushions.push(Cushion {
id,
position,
pos: attached,
yaw: snap_yaw(self.yaw),
color,
check_interval: 0,
});
self.world
.modified
.insert((attached[0] >> 4, attached[2] >> 4));
// `destroyIfInFire`: placed into fire, it breaks at once.
let placed = self.cushions.len() - 1;
if self.cushion_in_fire(&self.cushions[placed]) {
self.break_cushion(placed, false);
}
self.audio.play(
"minecraft:entity.cushion.place",
Some([position.x, position.y, position.z]),
0.75,
0.8,
);
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event(
"minecraft:entity_place",
[position.x, position.y, position.z],
ctx,
);
if !self.creative {
self.consume_held();
}
true
}
/// `Cushion.interact`: sit down unless sneaking or already occupied.
pub(super) fn interact_cushion(&mut self) -> bool {
let Some((i, d, _)) = self.cushion_hit() else {
return false;
};
if !self.before_block(d) {
return false;
}
// isSecondaryUseActive.
if self.keys.contains(&KeyCode::ShiftLeft) {
return false;
}
let id = self.cushions[i].id;
let occupied = self.riding == Some(id)
|| self
.lan
.as_ref()
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(id)));
// `Cushion.interact`: an occupied cushion (`isVehicle`) passes.
if occupied {
return false;
}
// `Player.startRiding` gives up the vehicle it already has; the
// client never sends that click, so pass instead.
if self.riding.is_some() {
return false;
}
self.riding = Some(id);
let p = self.cushions[i].position;
self.audio.play(
"minecraft:entity.cushion.sit",
Some([p.x, p.y, p.z]),
1.0,
1.0,
);
self.swing_arm();
true
}
/// `Cushion.hurtServer` from a player: killed, then the item dropped.
pub(super) fn attack_cushion(&mut self) -> bool {
let Some((i, d, _)) = self.cushion_hit() else {
return false;
};
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
// `skipAttackInteraction` when `mayInteract` is false.
if !self.may_build() {
return true;
}
self.break_cushion(i, true);
true
}
/// `Cushion.dropItem`: the break sound and particles, then the coloured
/// item unless drops are off or the breaker has infinite materials.
pub(super) fn break_cushion(&mut self, index: usize, by_player: bool) {
let c = self.cushions.remove(index);
let p = c.position;
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
if self.riding == Some(c.id) {
self.riding = None;
}
self.audio.play(
"minecraft:entity.cushion.break",
Some([p.x, p.y, p.z]),
1.0,
1.0,
);
self.cushion_break_particles(&c);
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
return;
}
let stack = ItemStack {
item: std::sync::Arc::from(c.item()),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10);
}
/// `Cushion.showBreakingParticles`: ten of the matching wool block.
fn cushion_break_particles(&mut self, c: &Cushion) {
let name = format!("minecraft:{}_wool", c.color_name());
let Some(state) = self.pack.assets.registry.default_state(&name) else {
return;
};
let at = [
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
];
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
return;
};
let options = horizon_client::particles::ParticleOptions::block(
horizon_client::particles::ParticleType::Block,
sprite,
tint,
);
let p = c.position;
self.particles.send_particles(
&options,
[p.x, p.y + 0.666_666_7, p.z],
[0.25, 0.0625, 0.25],
0.05,
10,
);
}
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
pub(super) fn tick_cushions(&mut self) {
let ticking = self.entity_ticking_area();
let mut i = 0;
while i < self.cushions.len() {
let p = self.cushions[i].position;
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
i += 1;
continue;
}
let previous = self.cushions[i].check_interval;
self.cushions[i].check_interval += 1;
if previous < 100 {
i += 1;
continue;
}
self.cushions[i].check_interval = 0;
let c = self.cushions[i].clone();
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
continue;
}
i += 1;
}
}
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
let Some(c) = self
.cushions
.iter()
.find(|c| Some(c.id) == self.riding)
.cloned()
else {
return false;
};
if keys.shift {
let at = c.dismount();
self.riding = None;
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = at;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
// `Cushion.removePassenger`: the get-up sound for a rider that
// left rather than one whose cushion was killed.
self.audio.play(
"minecraft:entity.cushion.get_up",
Some([c.position.x, c.position.y, c.position.z]),
1.0,
1.0,
);
return true;
}
let seat = c.seat();
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = seat;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
true
}
/// `CushionRenderer`: the coloured model, rotated to the nearest
/// direction, with its bottom on the cushion's position.
pub(super) fn cushion_geometry(
&self,
camera: glam::DVec3,
out: &mut Vec<horizon_client::mesher::Vertex>,
) {
let Some(model) = self.cushion_model.as_ref() else {
return;
};
let sprites = &self.gui.atlas.sprites;
for c in &self.cushions {
let entity_start = out.len();
let a = aabb(c.bounds());
let (lo, hi) = (a.min_position(), a.max_position());
let (lo, hi) = (
glam::DVec3::new(lo.x, lo.y, lo.z),
glam::DVec3::new(hi.x, hi.y, hi.z),
);
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
let Some(uv) = sprites
.get(&format!("entity/cushion/{}_cushion", c.color_name()))
.map(|r| [r.u0, r.v0, r.u1, r.v1])
else {
continue;
};
let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
let base = glam::Mat4::from_translation(p.as_vec3())
* glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians())
* glam::Mat4::from_rotation_x(std::f32::consts::PI)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0));
// `EntityRenderer.getPackedLightCoords` probes at the eye height.
let l = self.world.light(
c.position.x.floor() as i32,
(c.position.y + 0.2125).floor() as i32,
c.position.z.floor() as i32,
);
let packed = (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8);
horizon_client::mobs::render::draw(
model,
base,
&Poses::new(model),
uv,
0xFFFF_FFFF,
packed,
0,
out,
);
self.cull_entity_vertices(out, entity_start);
}
}
}
@@ -725,6 +725,11 @@ impl Game<'_> {
match self.world.block_entities.get_mut(&p) {
Some(BlockEntity::Jukebox(j)) => j.record = stack,
Some(BlockEntity::Lectern(l)) => l.book = stack,
Some(BlockEntity::Campfire(c)) => {
if let Some(cell) = c.items.get_mut(slot) {
*cell = stack;
}
}
Some(e) => {
if let Some(cell) = e.slots_mut().get_mut(slot) {
*cell = stack;
@@ -1126,14 +1131,18 @@ impl Game<'_> {
let (p, v) = (b.position, b.velocity);
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
// A merged orb prints once per count, as the oracle does: whether orbs
// merge depends on entity ids, which differ from vanilla's.
for o in &self.orbs {
let (p, v) = (o.position, o.velocity);
out.push(line(
"minecraft:experience_orb",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
));
for _ in 0..o.count.max(1) {
out.push(line(
"minecraft:experience_orb",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
));
}
}
for c in &self.carts {
let (p, v) = (c.position, c.velocity);
+279 -30
View File
@@ -12,11 +12,13 @@ use horizon_client::{
hud_layout, hud_overlays,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
};
use horizon_protocol::entity_data::Value;
use horizon_world::nbt::Tag;
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
@@ -1558,6 +1560,7 @@ impl Game<'_> {
y -= 1.0;
}
self.breath_clouds.push(Breath {
id: 0,
at: [x, y.floor() + 1.0, z],
radius: 5.0,
age: 0,
@@ -1684,32 +1687,17 @@ impl Game<'_> {
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
}
}
// checkCrystals: heal 1 every 10 ticks from the nearest crystal within 32.
// checkCrystals: heal 1 every 10 ticks from the nearest crystal, found
// among those touching the dragon's box grown by 32.
let d = self.dragon.as_mut().expect("dragon");
if d.nearest_crystal.is_some() && d.ticks % 10 == 0 && d.health < MAX_HEALTH {
d.health += 1.0;
// LivingEntity.setHealth clamps to the maximum.
d.health = (d.health + 1.0).min(MAX_HEALTH);
}
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
let p = d.position;
d.nearest_crystal = self
.crystals
.iter()
.enumerate()
.filter(|(_, c)| {
(c.position.x - p.x).abs() <= 32.0
&& (c.position.y - p.y).abs() <= 32.0
&& (c.position.z - p.z).abs() <= 32.0
})
.min_by(|a, b| {
let da = (a.1.position.x - p.x).powi(2)
+ (a.1.position.y - p.y).powi(2)
+ (a.1.position.z - p.z).powi(2);
let db = (b.1.position.x - p.x).powi(2)
+ (b.1.position.y - p.y).powi(2)
+ (b.1.position.z - p.z).powi(2);
da.total_cmp(&db)
})
.map(|(i, _)| i);
let nearest = self.nearest_crystal_to(p);
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
}
let d = self.dragon.as_mut().expect("dragon");
d.yaw = wrap(d.yaw);
@@ -1870,7 +1858,7 @@ impl Game<'_> {
pl.velocity.z += zd / dd * 4.0;
}
if !sitting {
g.hurt_player_by("minecraft:mob_attack", 5.0);
g.dragon_hurts_player("minecraft:mob_attack", 5.0);
}
});
}
@@ -1879,7 +1867,7 @@ impl Game<'_> {
for h in heads {
if h.inflate(Vec3::new(1.0, 1.0, 1.0)).intersects(p.bounds) {
self.as_fight_player(p.who, |g| {
g.hurt_player_by("minecraft:mob_attack", 10.0);
g.dragon_hurts_player("minecraft:mob_attack", 10.0);
});
}
}
@@ -2037,6 +2025,7 @@ impl Game<'_> {
// assignDirectionalMovement: the direction times the acceleration power 0.1.
let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1);
self.dragon_fireballs.push(DragonFireball {
id: 0,
position: at,
previous: at,
velocity,
@@ -2083,6 +2072,7 @@ impl Game<'_> {
});
if hit_block || hit_player || b.age > 600 {
self.breath_clouds.push(Breath {
id: 0,
at: [next.x, next.y, next.z],
radius: 3.0,
age: 0,
@@ -2161,10 +2151,20 @@ impl Game<'_> {
self.breath_clouds.append(&mut clouds);
for (who, amount) in harmed {
self.as_fight_player(who, |g| {
g.hurt_player_by("minecraft:indirect_magic", amount);
g.dragon_hurts_player("minecraft:indirect_magic", amount);
});
}
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
/// death message names it.
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
self.note_named_attacker(damage_type, id, name);
}
self.hurt_player_by(damage_type, amount);
}
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
/// (or a server's packets) put on this client's overlay.
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
@@ -2202,15 +2202,58 @@ impl Game<'_> {
}
}
pub(super) struct DragonFireball {
position: Vec3,
previous: Vec3,
velocity: Vec3,
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
age: i32,
}
impl DragonFireball {
/// One a server sent (`handleAddEntity`).
pub(super) fn remote(id: u32, at: Vec3, velocity: Vec3) -> Self {
Self {
id,
position: at,
previous: at,
velocity,
age: 0,
}
}
/// `AbstractHurtingProjectile.tick` on a server's client: `applyInertia`
/// first (power 0.1, inertia 0.95 or 0.8 in water), then the move; only
/// the server decides what it hits.
pub(super) fn remote_step(&mut self, water: bool) {
self.previous = self.position;
self.age += 1;
let v = self.velocity;
// Vec3.normalize: a vector shorter than 1e-5 stays zero.
let l = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
let n = if l < 1.0e-5 {
Vec3::new(0.0, 0.0, 0.0)
} else {
Vec3::new(v.x / l, v.y / l, v.z / l)
};
let inertia = f64::from(if water { 0.8f32 } else { 0.95f32 });
let v = Vec3::new(
(v.x + n.x * 0.1) * inertia,
(v.y + n.y * 0.1) * inertia,
(v.z + n.z * 0.1) * inertia,
);
self.velocity = v;
self.position = Vec3::new(
self.position.x + v.x,
self.position.y + v.y,
self.position.z + v.z,
);
}
}
pub(super) struct Breath {
at: [f64; 3],
radius: f32,
age: i32,
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
duration: i32,
from_dragon: bool,
}
@@ -3132,6 +3175,212 @@ impl Game<'_> {
let _ = self.activate_exit_portal();
self.fight.dirty = true;
}
/// `EnderDragon.checkCrystals`' search: the nearest crystal whose box
/// meets the dragon's box grown by 32.
fn nearest_crystal_to(&self, p: Vec3) -> Option<usize> {
// The dragon's 16 by 8 box.
let reach = Aabb::new(
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
)
.inflate(Vec3::new(32.0, 32.0, 32.0));
self.crystals
.iter()
.enumerate()
.filter(|(_, c)| {
// An end crystal's 2 by 2 box.
reach.intersects(Aabb::new(
Vec3::new(c.position.x - 1.0, c.position.y, c.position.z - 1.0),
Vec3::new(c.position.x + 1.0, c.position.y + 2.0, c.position.z + 1.0),
))
})
.min_by(|a, b| {
let da = (a.1.position.x - p.x).powi(2)
+ (a.1.position.y - p.y).powi(2)
+ (a.1.position.z - p.z).powi(2);
let db = (b.1.position.x - p.x).powi(2)
+ (b.1.position.y - p.y).powi(2)
+ (b.1.position.z - p.z).powi(2);
da.total_cmp(&db)
})
.map(|(i, _)| i)
}
}
/// The dragon on a server's client (`ClientPacketListener.handleAddEntity`
/// and the client side of `EnderDragon.aiStep`): the server flies it and
/// sends its moves, health and phase; the client flaps its wings, keeps
/// its flight history and places its parts, so it can be drawn and hit.
impl Game<'_> {
/// `handleAddEntity` for `minecraft:ender_dragon`. Its constructor
/// sets the full health and the hovering phase until the server's
/// synched data says otherwise.
pub(super) fn spawn_remote_dragon(&mut self, id: i32, position: [f64; 3], yaw: f32) {
let at = Vec3::new(position[0], position[1], position[2]);
let mut d = Dragon::new(at, yaw, i64::from(id));
d.id = id as u32;
d.phase = Phase::from_id(10);
self.dragon = Some(d);
self.place_dragon_parts();
if let Some(d) = self.dragon.as_mut() {
for part in &mut d.parts {
part.1 = part.0;
}
}
}
/// The remote dragon, if `id` is it.
fn remote_dragon_mut(&mut self, id: i32) -> Option<&mut Dragon> {
self.dragon.as_mut().filter(|d| d.id as i32 == id)
}
/// `handleSetEntityData`: LivingEntity.DATA_HEALTH_ID and
/// EnderDragon.DATA_PHASE (`onSyncedDataUpdated` sets the phase).
pub(super) fn remote_dragon_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let Some(d) = self.remote_dragon_mut(id) else {
return;
};
for (index, _, value) in values {
match (index, value) {
(9, Value::Float(health)) => d.health = *health,
(16, Value::Int(phase)) => d.set_phase(Phase::from_id(*phase)),
_ => {}
}
}
}
/// `handleSetEntityMotion`: the velocity its wings flap by.
pub(super) fn remote_dragon_motion(&mut self, id: i32, velocity: [f64; 3]) {
if let Some(d) = self.remote_dragon_mut(id) {
d.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
}
}
/// `handleHurtAnimation` and `handleDamageEvent`: the red flash.
pub(super) fn remote_dragon_hurt(&mut self, id: i32) {
if let Some(d) = self.remote_dragon_mut(id) {
d.hurt_time = 10;
}
}
/// `handleRemoveEntities`, or a crystal removed: the healing beam
/// keeps pointing at the same crystal.
pub(super) fn remove_remote_dragon(&mut self, id: i32) {
if self.remote_dragon_mut(id).is_some() {
self.dragon = None;
return;
}
let Some(index) = self.crystals.iter().position(|c| c.id as i32 == id) else {
return;
};
if let Some(d) = self.dragon.as_mut() {
match d.nearest_crystal {
Some(n) if n == index => d.nearest_crystal = None,
Some(n) if n > index => d.nearest_crystal = Some(n - 1),
_ => {}
}
}
}
/// One client tick of the dragon. `lerp`: the step toward its last
/// sent position and yaw (LivingEntity's interpolation), and the
/// steps left including this one.
pub(super) fn tick_remote_dragon(&mut self, lerp: Option<([f64; 3], Option<f32>, u32)>) {
let Some(d) = self.dragon.as_mut() else {
return;
};
d.previous = d.position;
d.previous_yaw = d.yaw;
d.old_flap = d.flap;
d.ticks += 1;
d.hurt_time = (d.hurt_time - 1).max(0);
for part in &mut d.parts {
part.1 = part.0;
}
if let Some((target, yaw, steps)) = lerp {
let n = f64::from(steps.max(1));
d.position = Vec3::new(
d.position.x + (target[0] - d.position.x) / n,
d.position.y + (target[1] - d.position.y) / n,
d.position.z + (target[2] - d.position.z) / n,
);
if let Some(yaw) = yaw {
d.yaw += wrap(yaw - d.yaw) / steps.max(1) as f32;
}
}
if !d.alive() {
// EnderDragon.tickDeath on a client: it rises, and bursts
// from tick 180 to 200; the server removes it.
d.death_time += 1;
let (t, p) = (d.death_time, d.position);
if (180..=200).contains(&t) {
let r = &mut d.random;
let o = [
f64::from((r.next_float() - 0.5) * 8.0),
f64::from((r.next_float() - 0.5) * 4.0),
f64::from((r.next_float() - 0.5) * 8.0),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::ExplosionEmitter),
[p.x + o[0], p.y + 2.0 + o[1], p.z + o[2]],
[0.0; 3],
);
}
if let Some(d) = self.dragon.as_mut() {
d.position.y += 0.1;
for part in &mut d.parts {
part.0.y += 0.1;
}
}
return;
}
// processFlappingMovement and onFlap: its wing beats, and their sound.
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
let v = d.velocity;
let mut flap_speed = 0.2 / ((v.x.hypot(v.z) as f32) * 10.0 + 1.0);
flap_speed *= 2f32.powf(v.y as f32);
d.flap += if d.phase.sitting() { 0.1 } else { flap_speed };
let flap_now = (d.flap * std::f32::consts::TAU).cos();
let at = [d.position.x, d.position.y, d.position.z];
if flap_was <= -0.3 && flap_now >= -0.3 {
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
.play("minecraft:entity.ender_dragon.flap", Some(at), 5.0, pitch);
}
let d = self.dragon.as_mut().expect("dragon");
if !d.phase.sitting() {
d.growl -= 1;
if d.growl < 0 {
d.growl = 200 + d.random.next_int_bound(200).unwrap_or(0);
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
}
}
// checkCrystals' search runs on a client too (the healing beam).
let d = self.dragon.as_mut().expect("dragon");
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
let p = d.position;
let nearest = self.nearest_crystal_to(p);
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
}
let d = self.dragon.as_mut().expect("dragon");
d.yaw = wrap(d.yaw);
d.record();
self.place_dragon_parts();
}
/// The dragon's parts as a server's client attacks them: the server's
/// ids for `EnderDragonPart`s follow the dragon's.
pub(super) fn remote_dragon_part_hit(
&self,
eye: [f64; 3],
dir: [f64; 3],
reach: f64,
) -> Option<(i32, f64, [f64; 3])> {
let d = self.dragon.as_ref().filter(|d| d.alive() && d.id != 0)?;
(0..8)
.filter_map(|i| {
let b = d.part_box(i);
let p = d.parts[i].0;
super::ray_box(eye, dir, reach, b)
.map(|t| ((d.id + 1 + i as u32) as i32, t, [p.x, p.y, p.z]))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
}
}
#[cfg(test)]
@@ -255,23 +255,26 @@ impl Game<'_> {
let _ = self.wind_burst(feet, radius, knockback, false, false);
}
}
/// Thorns on worn armor when a mob at `from` hits the player.
pub(super) fn thorns(&mut self, from: [f64; 3]) {
let attacker = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2)
+ (m.position.y - from[1]).powi(2)
+ (m.position.z - from[2]).powi(2)
};
d(a.1).total_cmp(&d(b.1))
})
.map(|(i, _)| i);
let Some(attacker) = attacker else { return };
/// Thorns on worn armor when the mob `attacker` hits the player: the
/// `affected: attacker` effect of `Enchantment.doPostAttack` hurts the
/// entity that caused the hit.
pub(super) fn thorns(&mut self, attacker: u32) {
let Some(index) = self.mobs.iter().position(|m| m.id == attacker && m.alive()) else {
return;
};
for damage in self.thorns_damage() {
let m = &mut self.mobs[index];
m.hurt(damage, None, &mut self.world.random);
let center = [m.position.x, m.position.y, m.position.z];
self.audio
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
}
}
/// The victim's side of thorns: for each worn piece whose chance comes up,
/// the damage to deal the attacker (`DamageEntity` min 1, max 5) and the
/// piece's wear (`ChangeItemDamage` 2).
pub(super) fn thorns_damage(&mut self) -> Vec<f32> {
let mut hits = Vec::new();
for slot in 0..4 {
let Some(stack) = self.armor[slot].clone() else {
continue;
@@ -299,15 +302,7 @@ impl Game<'_> {
if !ok {
continue;
}
// DamageEntity(min 1, max 5) and ChangeItemDamage(2).
let damage = 1.0 + self.world.random.next_float() * 4.0;
let center = {
let m = &mut self.mobs[attacker];
m.hurt(damage, None, &mut self.world.random);
[m.position.x, m.position.y, m.position.z]
};
self.audio
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
hits.push(1.0 + self.world.random.next_float() * 4.0);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += 2;
if self
@@ -320,6 +315,7 @@ impl Game<'_> {
}
}
}
hits
}
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
pub(super) fn mending(&mut self, amount: u32) -> u32 {
@@ -388,7 +384,9 @@ impl Game<'_> {
}
}
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
/// `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.inventory[self.selected].clone() else {
return (0.0, false);
};
@@ -407,7 +405,7 @@ impl Game<'_> {
0.0,
);
let ammo_ctx = EffectContext {
tool: Some("minecraft:arrow"),
tool: Some(ammo),
items: Some(self.res.items()),
..Default::default()
};
+56 -4
View File
@@ -129,15 +129,32 @@ impl EndState {
survive: false,
});
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
/// `EyeOfEnder.tick` on a server's client: it moves by its last known
/// motion; only the server steers it.
pub(super) fn step_remote_eyes(&mut self) {
for e in &mut self.eyes {
e.previous = e.position;
e.life += 1;
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
e.position += e.velocity;
}
}
/// A server's move: an eye has no interpolation handler, so
/// `moveOrInterpolateTo` puts it there at once. False when `id` isn't one.
pub(super) fn move_remote_eye(&mut self, id: u32, at: [f64; 3]) -> bool {
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
return false;
};
e.position = glam::DVec3::from_array(at);
true
}
/// `handleSetEntityMotion` for an eye. False when `id` isn't one.
pub(super) fn remote_eye_motion(&mut self, id: u32, velocity: [f64; 3]) -> bool {
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
return false;
};
e.velocity = glam::DVec3::from_array(velocity);
true
}
pub(super) fn remove_remote_eye(&mut self, id: u32) {
self.eyes.retain(|e| e.id != id);
}
@@ -1099,6 +1116,7 @@ impl Game<'_> {
/// EyeOfEnder.tick: fly, then drop (4 in 5) or shatter after 80 ticks.
fn tick_eyes(&mut self) {
let mut eyes = std::mem::take(&mut self.end.eyes);
let mut shattered = Vec::new();
eyes.retain_mut(|eye| {
eye.previous = eye.position;
let next = eye.position + eye.velocity;
@@ -1122,10 +1140,44 @@ impl Game<'_> {
extra: None,
};
self.spawn_item(stack, at, [vx, 0.2, vz], 0);
} else {
shattered.push(at.map(|v| v.floor() as i32));
}
false
});
self.end.eyes = eyes;
for pos in shattered {
self.level_event_at(2003, pos, 0);
}
}
/// `LevelEventHandler` 2003: a shattered eye's item crumbs and two rings
/// of portal particles drawn in towards it.
pub(super) fn eye_break_particles(&mut self, pos: [i32; 3]) {
let (x, y, z) = (
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
);
let crumbs = self.item_particle(&ItemStack::new("minecraft:ender_eye", 1));
for _ in 0..8 {
let r = self.particles.random();
let v = [
r.next_gaussian() * 0.15,
r.next_double() * 0.2,
r.next_gaussian() * 0.15,
];
self.particles.add(&crumbs, [x, y, z], v);
}
let portal = ParticleOptions::simple(ParticleType::Portal);
let mut angle = 0.0f64;
while angle < std::f64::consts::PI * 2.0 {
let (c, s) = (angle.cos(), angle.sin());
let at = [x + c * 5.0, y - 0.4, z + s * 5.0];
self.particles.add(&portal, at, [c * -5.0, 0.0, s * -5.0]);
self.particles.add(&portal, at, [c * -7.0, 0.0, s * -7.0]);
angle += std::f64::consts::PI / 20.0;
}
}
/// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last
+7 -5
View File
@@ -296,12 +296,14 @@ impl Game<'_> {
if !creative
&& !spectator
&& hurtable
&& let Ok(o) = p.hurt_blocking(
&& let Ok(o) = p.hurt_caused_by(
&self.motion.health,
self.world.health_rules,
id,
amount,
blocking,
// The exploding mob is the causing entity.
exploding.is_some(),
)
{
outcome = o;
@@ -314,9 +316,6 @@ impl Game<'_> {
);
}
}
if outcome.hurt && !creative {
self.hurt_armor(amount);
}
if raised {
self.after_block(outcome, amount, None, false);
}
@@ -391,9 +390,11 @@ impl Game<'_> {
[feet[0] - w, feet[1], feet[2] - w],
[feet[0] + w, feet[1] + h, feet[2] + w],
);
// The exploding mob is the damage source's entity, so victims
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
attacker: None,
attacker: exploding.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
if hurtable && !m.damage(damage(dist, exposure), source) {
@@ -620,6 +621,7 @@ impl Game<'_> {
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
decay,
false,
);
for mut stack in drops {
if &*stack.item == "minecraft:air" || stack.count == 0 {
@@ -108,16 +108,10 @@ impl Game<'_> {
} else {
amount
};
let hurt = if let Some(p) = self.player.as_mut()
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(kind)
{
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false)
} else {
false
};
if hurt {
self.hurt_armor(amount);
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
}
}
}
+21 -9
View File
@@ -81,16 +81,28 @@ impl Game<'_> {
};
bite(self);
self.each_guest_hit(bite);
// `AbstractIllager.considersEntityAsAlly`: with no teams, the illager
// tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose
// owner is allied (itself or another illager). Teams are not modelled.
let illager = |s: Species| {
matches!(
s,
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
)
};
let illagers: Vec<u32> = self
.mobs
.iter()
.filter(|m| illager(m.species()))
.map(|m| m.id)
.collect();
// A missing owner hurts everyone (`currentOwner == null`).
let owner_known = illagers.contains(&f.owner);
for m in &mut self.mobs {
let ally = matches!(
m.species(),
Species::Evoker
| Species::Illusioner
| Species::Vex
| Species::Pillager
| Species::Vindicator
| Species::Witch
);
let ally = owner_known
&& (illager(m.species())
|| m.species() == Species::Vex
&& illagers.contains(&(m.extra.counter2 as u32)));
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
m.damage(
6.0,
+22 -8
View File
@@ -17,6 +17,8 @@ use horizon_core::{
random::RandomSource,
};
pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17;
pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
@@ -261,6 +263,14 @@ impl Game<'_> {
/// `FireworkRocketEntity.explode` and `dealExplosionDamage`.
fn explode_rocket(&mut self, r: &Rocket) {
let at = [r.position.x, r.position.y, r.position.z];
// FireworkRocketEntity.explode broadcasts before discard. Send now:
// tick_lan removes missing tracked entities before flushing effects.
if r.id != 0
&& let Some(host) = &self.lan
{
host.hub
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
}
if !r.explosions.is_empty() {
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
let from = r.position;
@@ -322,6 +332,16 @@ impl Game<'_> {
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
}
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
let camera = self.eye_position();
if let Some(r) = self.rockets.iter().find(|r| r.id == id as u32) {
let at = [r.position.x, r.position.y, r.position.z];
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
self.particles
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
}
}
/// `dealExplosionDamage` on a player that is not the rocket's
/// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or
/// its middle (`ClipContext.Block.COLLIDER`, no fluids).
@@ -352,17 +372,11 @@ impl Game<'_> {
if self.creative || self.spectator() {
return;
}
let hurt = if let Some(p) = self.player.as_mut()
if let Some(p) = self.player.as_mut()
&& !p.health.dead_or_dying()
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
{
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false)
} else {
false
};
if hurt {
self.hurt_armor(amount);
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
}
}
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
+65 -36
View File
@@ -154,37 +154,54 @@ impl Game<'_> {
let mut damage = 0;
if let State::Hooked(id) = hook.state {
// pullEntity: 0.1 of the way to the owner.
let d = Vec3::new(
owner.x - hook.position.x,
owner.y - hook.position.y,
owner.z - hook.position.z,
);
let pulled = |v: Vec3| Vec3::new(v.x + d.x * 0.1, v.y + d.y * 0.1, v.z + d.z * 0.1);
let mut hooked_item = false;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
let d = Vec3::new(
owner.x - hook.position.x,
owner.y - hook.position.y,
owner.z - hook.position.z,
);
m.velocity = Vec3::new(
m.velocity.x + d.x * 0.1,
m.velocity.y + d.y * 0.1,
m.velocity.z + d.z * 0.1,
);
m.velocity = pulled(m.velocity);
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
hooked_item = true;
}
damage = 5;
// `hookedIn instanceof ItemEntity ? 3 : 5`.
damage = if hooked_item { 3 } else { 5 };
} else if hook.nibble > 0 {
let biome = self.biome_name_at(hook.position);
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
else {
return damage;
};
// `withLuck(this.luck + owner.getLuck())`: the rod's Luck of the Sea
// plus the owner's luck attribute (the Luck and Bad Luck effects).
let owner_luck = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:luck").ok())
.unwrap_or(0.0) as f32;
let mut random = self.world.random.clone();
let items = loot.roll(
"minecraft:gameplay/fishing",
&mut random,
hook.luck as f32,
hook.luck as f32 + owner_luck,
biome.as_deref(),
Some(hook.open_water),
);
self.world.random = random;
// FishingHook.retrieve: fish_caught for a catch.
self.stats.custom("fish_caught", 1);
for stack in items {
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
if self
.res
.items()
.item_tags
.get("minecraft:fishes")
.is_some_and(|t| t.contains(stack.item.as_str()))
{
self.stats.custom("fish_caught", 1);
}
let mut item = ItemStack {
item: Arc::from(stack.item.as_str()),
count: stack.count.max(1) as u32,
@@ -231,7 +248,7 @@ impl Game<'_> {
}
damage
}
fn biome_name_at(&self, p: Vec3) -> Option<String> {
pub(super) fn biome_name_at(&self, p: Vec3) -> Option<String> {
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let c = self.world.chunks.get(&(x >> 4, z >> 4))?;
Some(
@@ -457,35 +474,47 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: hook the first living mob it touches.
// checkCollision: hook the first living mob it touches, or (via
// `canHitEntity`) a dropped item.
let bb = bounds(h.position);
let (lo, hi) = (bb.min_position(), bb.max_position());
if let Some(m) = self.mobs.iter().find(|m| {
let mb = m.bounds();
let (a, z) = (mb.min_position(), mb.max_position());
m.alive()
&& a.x < hi.x
&& lo.x < z.x
&& a.y < hi.y
&& lo.y < z.y
&& a.z < hi.z
&& lo.z < z.z
}) {
h.state = State::Hooked(m.id);
let touches = |b: Aabb| {
let (a, z) = (b.min_position(), b.max_position());
a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z
};
let hit = self
.mobs
.iter()
.find(|m| m.alive() && touches(m.bounds()))
.map(|m| m.id)
.or_else(|| {
self.dropped
.iter()
.find(|i| touches(i.bounds()))
.map(|i| i.id)
});
if let Some(id) = hit {
h.state = State::Hooked(id);
h.velocity = Vec3::ZERO;
self.hook = Some(h);
return;
}
}
State::Hooked(id) => {
match self.mobs.iter().find(|m| m.id == id && m.alive()) {
Some(m) => {
h.position = Vec3::new(
m.position.x,
m.position.y + f64::from(m.height()) * 0.8,
m.position.z,
)
}
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
let hooked = self
.mobs
.iter()
.find(|m| m.id == id && m.alive())
.map(|m| (m.position, f64::from(m.height())))
.or_else(|| {
self.dropped
.iter()
.find(|i| i.id == id)
.map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH))
});
match hooked {
Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z),
None => h.state = State::Flying,
}
self.hook = Some(h);
+15 -4
View File
@@ -355,12 +355,23 @@ impl Game<'_> {
* glam::DMat4::from_rotation_y(y_rot.to_radians());
let light = self.world.light(f.pos[0], f.pos[1], f.pos[2]);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// ItemFrameRenderer.getBlockLightLevel: a glow frame's model is at least 5 bright.
let frame_light = if f.glow {
(light & 0xF0) | (light & 15).max(5)
} else {
light
};
let frame_packed =
(u32::from(frame_light >> 4) * 16) | ((u32::from(frame_light & 15) * 16) << 8);
let mut quad =
|m: &glam::DMat4, corners: [[f64; 3]; 4], uv: [[f32; 2]; 4], light: u32| {
for (c, uv) in corners.iter().zip(uv) {
let p = m.transform_point3(glam::DVec3::from_array(*c));
// The frame model is drawn with the entity shader, lit by its face normal.
let at = corners.map(|c| m.transform_point3(glam::DVec3::from_array(c)));
let n = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
let normal = [0, 1, 2].map(|k| (n[k].clamp(-1.0, 1.0) * 127.0) as i8);
for (p, uv) in at.into_iter().zip(uv) {
out.push(Vertex {
normal: [0; 4],
normal: [normal[0], normal[1], normal[2], -127],
pos: p.as_vec3().to_array(),
uv,
layer: 0,
@@ -393,7 +404,7 @@ impl Game<'_> {
]
}),
map_uv(r, *uv),
packed,
frame_packed,
);
}
}
+1
View File
@@ -151,6 +151,7 @@ impl Game<'_> {
before(self.painting_hit().map(|h| h.1))
|| before(self.frame_hit().map(|h| h.1))
|| before(self.armor_stand_hit().map(|h| h.1))
|| before(self.cushion_hit().map(|h| h.1))
|| before(self.boat_hit().map(|h| h.1))
|| before(self.cart_hit().map(|h| h.1))
|| before(self.dragon_hit().map(|h| h.1))
+3 -1
View File
@@ -222,7 +222,6 @@ impl Game<'_> {
options.report = None;
options.server = None;
options.world = None;
options.dev_mods.clear();
self.join_world_rest()?;
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
let flags = Arc::new(Flags::default());
@@ -292,6 +291,9 @@ impl Game<'_> {
launch.create.as_ref(),
)?)?;
let saved = Box::new(self.level.clone());
// IntegratedServer runs the server mods; the window the client mods.
self.mods.dev = self.options.dev_mods.clone();
self.start_mods();
// Math.max(2, options.renderDistance): past vanilla's 32 when the
// window's render distance is (Horizon's options go to 64).
let view_distance = self.settings.render_distance.max(2);
+19 -1
View File
@@ -196,13 +196,31 @@ impl Game<'_> {
skins: &self.skin_downloads,
}
}
/// The client level as item models read it.
/// The client level as item models read it: worked out once a frame (a
/// screen of items read the same level, its sky sampled once).
pub(super) fn item_level(&self) -> LevelState {
let dimension = match self.dim {
Dim::Overworld => "minecraft:overworld",
Dim::Nether => "minecraft:the_nether",
Dim::End => "minecraft:the_end",
};
let key = (
self.frames,
self.world.game_time,
self.world.day_time,
dimension,
);
let mut cached = self.item_level_cache.borrow_mut();
if let Some((at, level)) = cached.as_ref()
&& *at == key
{
return level.clone();
}
let level = self.level_state_now(dimension);
*cached = Some((key, level.clone()));
level
}
fn level_state_now(&self, dimension: &'static str) -> LevelState {
let sky = self
.dim_environment()
.sample(self.world.day_time, None, None);
+104 -19
View File
@@ -14,7 +14,7 @@ use horizon_client::{
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, leash::Holder},
mobs::{Mob, Who, leash::Holder},
text::Text,
};
use horizon_core::geometry::Vec3;
@@ -147,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
fn mob_meta(
m: &Mob,
hub: &Hub,
player_entity: Option<i32>,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
@@ -196,6 +197,32 @@ fn mob_meta(
let e = &m.extra;
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
match kind {
"minecraft:guardian" | "minecraft:elder_guardian" => {
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
if e.flag2 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
let target = if e.flag {
m.ai.target.and_then(|who| match who {
Who::Player => player_entity,
Who::Mob(id) => Some(id as i32),
})
} else {
None
};
if let Some(target) = target {
meta.push((17, serializer::INT, Value::Int(target)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Bat.DATA_ID_FLAGS: resting in bit 0, flying by default.
"minecraft:bat" => {
if e.flag {
meta.push((16, serializer::BYTE, Value::Byte(1)));
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
"minecraft:sheep" => {
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
@@ -637,6 +664,7 @@ impl Game<'_> {
self.world.light_log = None;
self.audio.capture = None;
self.lan_events.clear();
self.lan_critical_hits.clear();
self.streaming.extra.clear();
if announce {
self.system_message(Text::translate(
@@ -823,12 +851,13 @@ impl Game<'_> {
for m in self.mobs.iter().filter(|m| !m.removed) {
// The lead's holder: the player the mob sees (a guest, else the
// host) or another mob. A fence knot isn't an entity here.
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity);
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity),
Holder::Player => player_entity,
Holder::Mob(id) => Some(id as i32),
Holder::Knot(_) => None,
});
@@ -838,7 +867,7 @@ impl Game<'_> {
e.on_ground = m.on_ground;
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
e.needs_sync = m.needs_sync;
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
@@ -915,6 +944,13 @@ impl Game<'_> {
for m in &mut self.mobs {
m.needs_sync = false;
}
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
// has the blocks as they were before the move.
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
host.hub.block_event(pos, b0, b1, state);
}
let entities = states.as_slice();
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
@@ -963,6 +999,9 @@ impl Game<'_> {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::PlayerChat { sender, text } => {
self.mod_player_chat(super::server_mods::Who::Uuid(sender), &text);
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
@@ -1145,17 +1184,29 @@ impl Game<'_> {
return;
}
for e in events {
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
None
} else {
self.lan_acting
};
self.lan_events
.push((*id, [pos.x, pos.y, pos.z], *data, except));
match e {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
None
} else {
self.lan_acting
};
self.lan_events
.push((*id, [pos.x, pos.y, pos.z], *data, except));
}
// `ServerLevel.runBlockEvents` broadcasts a handled block
// event (`ClientboundBlockEventPacket`): the guest re-runs
// the block's `triggerEvent` — a piston's `moveBlocks` is
// what animates the move there. `state` is the block the
// event named, read before it ran (the packet's `block`).
horizon_world::level::LevelEvent::BlockEntityEvent { pos, state, a, b } => {
self.lan_block_events
.push(([pos.x, pos.y, pos.z], *a, *b, state.0));
}
_ => {}
}
}
}
@@ -1173,8 +1224,30 @@ impl Game<'_> {
self.lan_entity_events.push((id, event, at));
}
}
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
/// attacker's client and observers once the hub is back in place.
pub(super) fn critical_hit(
&mut self,
attacker: Option<u32>,
id: i32,
feet: [f64; 3],
width: f64,
height: f64,
) {
use horizon_client::particles::{ParticleOptions, ParticleType};
self.particles.tracking_emitter(
ParticleOptions::simple(ParticleType::Crit),
feet,
width,
height,
);
if self.audio.capture.is_some() {
self.lan_critical_hits.push((attacker, id));
}
}
/// Send the tick's level events, sounds and mob animations.
fn lan_flush_effects(&mut self, host: &mut LanHost) {
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
if kind == 2001 {
host.broken
@@ -1247,6 +1320,14 @@ impl Game<'_> {
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
for (attacker, target) in critical_hits {
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
host.hub.guest(conn).map(|g| g.entity_id)
}) else {
continue;
};
host.hub.critical_hit(attacker, target);
}
}
/// Keep the chunks around every guest loaded.
@@ -1414,7 +1495,11 @@ impl Game<'_> {
let tag = if secure { "" } else { "[Not Secure] " };
super::dedicated::log("INFO", &format!("{tag}{line}"));
}
HostEvent::Chat { line, .. } => self.system_message(Text::literal(line)),
// Player chat (and `/say`'s or `/me`'s, which are chat messages
// too): the host's Chat option decides, as `ChatListener` does
// for the guests' packets. Command feedback is a system message
// and takes the system-message permission instead.
HostEvent::Chat { line, .. } => self.player_message(Text::literal(line)),
HostEvent::Warn(line) if dedicated => super::dedicated::log("WARN", &line),
HostEvent::Warn(line) => log::warn!("{line}"),
HostEvent::Error(line) if dedicated => super::dedicated::log("ERROR", &line),
+63 -4
View File
@@ -77,6 +77,10 @@ pub(super) struct GuestPlay {
last_inventory: u64,
/// `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
/// its client's PERFORM_RESPAWN (`respawn_requested`).
pub(super) won_game: bool,
pub(super) respawn_requested: bool,
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
xp_delay: u32,
/// What last killed the guest outright (`/kill`): the death message's key.
@@ -875,7 +879,11 @@ impl Game<'_> {
.filter(|g| host.hub.here(g))
.filter(|g| {
g.menu.as_ref().is_some_and(|m| {
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|| m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y
&& !self.chest_vehicle_still_valid(k.2, g)
})
})
})
.map(|g| g.conn)
@@ -889,6 +897,35 @@ impl Game<'_> {
}
}
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
/// boat is still there and within the guest's entity interaction range
/// plus 4 of its box.
fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool {
let bounds = self
.carts
.iter()
.find(|c| c.id as i32 == id)
.map(|c| c.bounds())
.or_else(|| {
self.boats
.iter()
.find(|b| b.id as i32 == id)
.map(|b| b.bounds())
});
let Some(b) = bounds else {
return false;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let (lo, hi) = (b.min_position(), b.max_position());
let d = [
eye[0] - eye[0].clamp(lo.x, hi.x),
eye[1] - eye[1].clamp(lo.y, hi.y),
eye[2] - eye[2].clamp(lo.z, hi.z),
];
let range = if g.creative() { 5.0 } else { 3.0 } + 4.0;
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
}
/// A packet from a guest that the hub does not handle itself.
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -1314,6 +1351,13 @@ impl Game<'_> {
self.guest_suggestions(host, conn, &payload);
}
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
// handleClientCommand: PERFORM_RESPAWN after the poem leaves the End
// (the next server tick sends the player on).
S::ClientCommand(c)
if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) =>
{
host.play.entry(conn).or_default().respawn_requested = true;
}
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
// ServerStatsCounter.sendStats.
S::ClientCommand(c) if c.action == 1 => {
@@ -1539,6 +1583,10 @@ impl Game<'_> {
);
return;
}
// A server mod's command, for every player whatever their level.
if self.guest_server_mod_command(host, conn, command.trim_start_matches('/')) {
return;
}
// An integrated server runs every player's commands at their own
// permission level (`CommandSourceStack.permissions`); a LAN world
// follows its Allow Commands, and a dedicated server leaves it to
@@ -2253,6 +2301,7 @@ impl Game<'_> {
let marked = std::mem::take(&mut player.health.sync_velocity);
let velocity = marked.then_some(player.velocity);
game.record_combat(&events);
game.wear_armor(&events);
(events, velocity)
});
let Some((events, marked)) = events else {
@@ -2346,9 +2395,13 @@ impl Game<'_> {
}
if (next / 10) % 2 == 0
&& !creative
&& let Some(elytra) = game.armor[1].as_mut()
&& let Some(stack) = game.armor[1].clone()
{
elytra.damage += 1;
// `ItemStack.hurtAndBreak`: Unbreaking can save the wear.
let loss = game.durability_loss(&stack, 1);
if let Some(elytra) = game.armor[1].as_mut() {
elytra.damage += loss;
}
}
let ctx = game.player_context(None);
game.post_game_event("minecraft:elytra_glide", at, ctx);
@@ -2611,7 +2664,13 @@ impl Game<'_> {
.random
.next_int_bound(touching.len() as i32)
.unwrap_or(0) as usize;
let orb = self.orbs.remove(touching[pick]);
// A merged orb gives its value once per count and goes at zero.
let orb = self.orbs[touching[pick]].clone();
if orb.count <= 1 {
self.orbs.remove(touching[pick]);
} else {
self.orbs[touching[pick]].count -= 1;
}
play.xp_delay = 2;
host.hub.take_item(orb.id as i32, id, 1, at);
// ClientPacketListener.handleTakeItemEntity's sound, at the orb.
@@ -245,6 +245,16 @@ impl Game<'_> {
}
out.push(e);
}
for c in &self.cushions {
let p = c.position;
let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false);
// Cushion.DATA_COLOR, set unless it is the default white.
if c.color != 0 {
e.meta
.push((8, serializer::DYE_COLOR, Value::Id(i32::from(c.color))));
}
out.push(e);
}
self.lan_projectile_states(host_entity, hub, out);
for o in &self.others {
let p = o.position;
@@ -279,6 +289,10 @@ impl Game<'_> {
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.potions.thrown_ids().for_each(&mut give);
self.end.eye_ids().for_each(&mut give);
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
self.dragon_fireballs
.iter_mut()
.for_each(|b| give(&mut b.id));
}
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
@@ -435,6 +449,35 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::DragonBreath,
),
None,
);
for b in self.dragon_fireballs.iter().filter(|b| b.id != 0) {
let p = b.position;
let mut e = state(b.id, "minecraft:dragon_fireball", at(p), 0.0, false);
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
out.push(e);
}
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.age < 20 {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
if let Some(p) = &breath {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
e.defaults
.push((9, serializer::BOOLEAN, Value::Bool(false)));
out.push(e);
}
out.extend(self.lan_dragon_state());
}
}
@@ -370,6 +370,7 @@ mod tests {
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
effects: Vec::new(),
uuid: [0; 4],
allies: Default::default(),
remote: false,
@@ -25,6 +25,33 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
}
impl Game<'_> {
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
/// Keep the local event's rendering; connected players receive one at rest.
pub(super) fn lan_sonic_boom(&self, options: &ParticleOptions, at: [f64; 3]) {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: [0.0; 3],
max_speed: [0.0; 3],
count: 0,
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if distance < 32.0 * 32.0 {
g.send(&packet);
}
}
}
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
/// network id and the data it carries (`state` for the block particles).
/// None while not serving, or for a particle whose data isn't sent from
@@ -68,7 +95,11 @@ impl Game<'_> {
/// The options a server's particle stands for (None for a type or data
/// this client doesn't draw).
fn received_particle(&mut self, p: &Particle, at: [f64; 3]) -> Option<ParticleOptions> {
pub(super) fn received_particle(
&mut self,
p: &Particle,
at: [f64; 3],
) -> Option<ParticleOptions> {
let name = self
.remote
.as_ref()?
+40 -5
View File
@@ -32,6 +32,8 @@ pub(super) enum Victim {
/// The attacker's side of the hit.
pub(super) struct Strike {
/// The attacking guest, kept after their turn through `as_guest` ends.
attacker: Option<u32>,
amount: f32,
scale: f32,
crit: bool,
@@ -119,6 +121,7 @@ impl Game<'_> {
}
self.swing_arm();
Some(Strike {
attacker: self.lan_acting,
amount,
scale,
crit,
@@ -242,14 +245,46 @@ impl Game<'_> {
if hurt && strength > 0.0 {
g.knock_back_player(f64::from(strength), xd, zd);
}
hurt
// The victim's thorns, dealt to the attacker below.
let thorns = if hurt { g.thorns_damage() } else { Vec::new() };
(hurt, thorns)
};
let hurt = self.ui.rules.bool("pvp")
&& harm
&& match victim {
let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm {
match victim {
Victim::Host => land(self),
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(),
}
} else {
Default::default()
};
if hurt && s.crit {
let target = match victim {
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
Victim::Guest(conn) => host
.hub
.guest(conn)
.and_then(|g| g.motion.as_deref().map(|p| (g.entity_id, p))),
};
if let Some((id, p)) = target {
let d = p.dimensions;
self.critical_hit(
s.attacker,
id,
[p.position.x, p.position.y, p.position.z],
f64::from(d.width),
f64::from(d.height),
);
}
}
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
for damage in thorns {
match s.attacker {
None => self.hurt_player_by("minecraft:thorns", damage),
Some(conn) => {
self.guest_hit_by(host, conn, |g| g.hurt_player_by("minecraft:thorns", damage));
}
}
}
let sound = match (hurt, s.crit, s.scale > 0.9) {
(false, ..) => "minecraft:entity.player.attack.nodamage",
(true, true, _) => "minecraft:entity.player.attack.crit",
@@ -144,15 +144,16 @@ impl Game<'_> {
.offers
.iter()
.filter_map(|o| {
let cost =
|st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost {
item: hub.item_id(&st.item),
count: st.count as i32,
};
let cost = |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| {
hub.stack(&Some(st.clone())).map(play::NetCost::from)
};
Some(play::NetOffer {
cost_a: cost(&o.base_cost_a.stack, &mut host.hub),
cost_a: cost(&o.base_cost_a.stack, &mut host.hub)?,
result: host.hub.stack(&Some(o.result.clone()))?,
cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)),
cost_b: match &o.cost_b {
Some(b) => Some(cost(&b.stack, &mut host.hub)?),
None => None,
},
out_of_stock: o.out_of_stock(),
uses: o.uses,
max_uses: o.max_uses,
+26 -1
View File
@@ -56,6 +56,13 @@ impl Game<'_> {
let (lo, hi) = corners(a.bounds());
return Some((lo, hi, at(a.position)));
}
if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
let (lo, hi) = corners(Aabb::new(
Vec3::new(c.position.x - 0.5, c.position.y, c.position.z - 0.5),
Vec3::new(c.position.x + 0.5, c.position.y + 0.25, c.position.z + 0.5),
));
return Some((lo, hi, at(c.position)));
}
if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
let (lo, hi) = f.bounds();
return Some((lo, hi, centre(f.pos)));
@@ -98,9 +105,10 @@ impl Game<'_> {
let boat = self.boats.iter().position(|b| b.id == vehicle);
let cart = self.carts.iter().any(|c| c.id == vehicle);
let mount = self.mobs.iter().any(|m| m.id == vehicle);
let cushion = self.cushions.iter().any(|c| c.id == vehicle);
// The vehicle is gone (broken, in another level) or the rider died:
// its client drops it with the entity or the death screen.
if dead || (boat.is_none() && !cart && !mount) {
if dead || (boat.is_none() && !cart && !mount && !cushion) {
let g = &mut host.hub.guests[gi];
(g.vehicle, g.vehicle_good) = (None, None);
return;
@@ -115,6 +123,17 @@ impl Game<'_> {
// Getting out goes through leave_guest, which tells the client and
// teleports it (ServerPlayer.dismountTo).
let seat = self.as_guest(host, conn, |game| {
// A cushion does not move: the rider sits on it until it sneaks
// (`Cushion.removePassenger` plays the get-up sound).
if game.cushions.iter().any(|c| Some(c.id) == game.riding) {
if keys.shift {
game.tick_cushion_riding(keys);
return None;
}
let c = game.cushions.iter().find(|c| Some(c.id) == game.riding)?;
let p = c.seat();
return Some([p.x, p.y, p.z]);
}
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
if keys.shift {
game.dismount(i);
@@ -168,6 +187,12 @@ impl Game<'_> {
.iter()
.find(|c| c.id == vehicle)
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
})
.or_else(|| {
self.cushions
.iter()
.find(|c| c.id == vehicle)
.map(|c| (c.position, c.seat()))
});
if let Some((p, seat)) = at {
let g = &mut host.hub.guests[gi];
+38 -6
View File
@@ -3,8 +3,8 @@
//! The game's tick code reads one level: the one in place, held in `Game`'s
//! own fields (the world, its streaming and save, block ticks, entities).
//! A server (the dedicated server and single player's server thread) keeps
//! the other dimensions' levels here and ticks each one that has players by
//! swapping it into place (`enter_level`), the way `as_guest` swaps a player
//! the other dimensions' levels here and ticks each one with players or forced
//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player
//! in, so the tick code runs unchanged, one level at a time. The in-frame
//! game holds one level, which follows its player (`enter_dimension`).
//!
@@ -50,6 +50,7 @@ pub(super) struct Parked {
paintings: Vec<horizon_client::entities::Painting>,
item_frames: Vec<horizon_client::entities::ItemFrame>,
armor_stands: Vec<horizon_client::entities::ArmorStand>,
cushions: Vec<horizon_client::entities::Cushion>,
others: Vec<commands::OtherEntity>,
boats: Vec<boats::Boat>,
carts: Vec<minecarts::Minecart>,
@@ -74,6 +75,7 @@ pub(super) struct Parked {
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32)>,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
@@ -107,6 +109,19 @@ pub(super) struct Levels {
parked: HashMap<Dim, Parked>,
}
impl Levels {
/// The chunks of each level not in place (capability `world` lends
/// them to server mods for a call).
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) fn parked_chunks(
&mut self,
) -> impl Iterator<Item = (Dim, &mut horizon_client::chunk_map::ChunkMap)> {
self.parked
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
}
impl Game<'_> {
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
fn open_level(&self, dim: Dim) -> Result<Parked> {
@@ -135,6 +150,7 @@ impl Game<'_> {
paintings: Vec::new(),
item_frames: Vec::new(),
armor_stands: Vec::new(),
cushions: Vec::new(),
others: Vec::new(),
boats: Vec::new(),
carts: Vec::new(),
@@ -159,6 +175,7 @@ impl Game<'_> {
lan_events: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
anims: Default::default(),
raiding: Default::default(),
sculk: Default::default(),
@@ -185,6 +202,7 @@ impl Game<'_> {
swap(&mut self.paintings, &mut p.paintings);
swap(&mut self.item_frames, &mut p.item_frames);
swap(&mut self.armor_stands, &mut p.armor_stands);
swap(&mut self.cushions, &mut p.cushions);
swap(&mut self.others, &mut p.others);
swap(&mut self.boats, &mut p.boats);
swap(&mut self.carts, &mut p.carts);
@@ -209,6 +227,7 @@ impl Game<'_> {
swap(&mut self.lan_events, &mut p.lan_events);
swap(&mut self.lan_block_events, &mut p.lan_block_events);
swap(&mut self.lan_entity_events, &mut p.lan_entity_events);
swap(&mut self.lan_critical_hits, &mut p.lan_critical_hits);
let (a, b) = (&mut self.be_anims, &mut p.anims);
swap(&mut a.chests, &mut b.chests);
swap(&mut a.books, &mut b.books);
@@ -219,6 +238,7 @@ impl Game<'_> {
swap(&mut a.pots, &mut b.pots);
swap(&mut a.gateways, &mut b.gateways);
swap(&mut a.gateway_servers, &mut b.gateway_servers);
swap(&mut a.conduit_servers, &mut b.conduit_servers);
self.raiding.swap_level(&mut p.raiding);
self.sculk.swap_level(&mut p.sculk);
self.creaking.swap_level(&mut p.creaking);
@@ -287,8 +307,8 @@ impl Game<'_> {
}
/// One server tick (`MinecraftServer.tickServer`): each level with
/// players in turn (`tickChildren`), its chunks streamed and loaded and
/// its world ticked. The first pass reads the network and runs the
/// players or forced chunks in turn (`tickChildren`), its chunks streamed
/// and loaded and its world ticked. The first pass reads the network and runs the
/// tick's shared parts (the clock, the weather, functions, the autosave);
/// the next ones only their level. While `paused`, only the connections
/// run.
@@ -307,6 +327,8 @@ impl Game<'_> {
};
self.update_dedicated_streaming();
self.update_world();
// What server mods set in this level since its last tick.
self.apply_mod_block_writes();
result = if paused {
self.tick_lan(true);
Ok(())
@@ -390,7 +412,7 @@ impl Game<'_> {
/// The levels this server tick runs, in `MinecraftServer.levels` order
/// (the Overworld, the Nether, the End): every level with a player in
/// it or a `/pregen` task, or the Overworld while nobody is online.
/// it, forced chunks or a `/pregen` task, or the Overworld while idle.
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
let guests = self
.lan
@@ -399,7 +421,17 @@ impl Game<'_> {
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
.into_iter()
.filter(|d| {
guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d)
let forced = if *d == self.dim {
!self.streaming.forced.is_empty()
} else {
self.levels
.parked
.get(d)
.is_some_and(|p| !p.streaming.forced.is_empty())
};
guests.iter().any(|g| g.dimension == d.id())
|| forced
|| self.pregen_level() == Some(*d)
})
.collect();
if dims.is_empty() {
+75 -19
View File
@@ -74,14 +74,74 @@ impl Game<'_> {
self.spawn_bolt(at, trap);
}
}
/// `findLightningTargetAround`: a living entity under open sky in the
/// column (3 blocks around), else the top of the column.
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
/// lightning rod points of interest within 128 blocks of `center` whose
/// block is the top (`WORLD_SURFACE`) of its column; the strike lands one
/// above it. Horizon keeps no point-of-interest index, so this scans the
/// loaded chunks the way `/locate poi` does.
fn find_lightning_rod(&self, center: [i32; 3]) -> Option<[i32; 3]> {
const RANGE: i32 = 128;
let facts = self.res.facts();
let mut is_rod: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
let mut best: Option<(i64, [i32; 3])> = None;
let (cx0, cz0) = ((center[0] - RANGE) >> 4, (center[2] - RANGE) >> 4);
let (cx1, cz1) = ((center[0] + RANGE) >> 4, (center[2] + RANGE) >> 4);
for cx in cx0..=cx1 {
for cz in cz0..=cz1 {
let Some(chunk) = self.world.chunks.get(&(cx, cz)) else {
continue;
};
for (i, section) in chunk.sections.iter().enumerate() {
let mut rod = |state: u32| {
*is_rod.entry(state).or_insert_with(|| {
facts
.get(horizon_world::registry::BlockStateId(state))
.name
.ends_with("lightning_rod")
})
};
if !section.blocks.maybe_has(&mut rod) {
continue;
}
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
for j in 0..4096 {
if !rod(section.get(j)) {
continue;
}
let p = [
cx * 16 + (j & 15) as i32,
base_y + (j >> 8) as i32,
cz * 16 + ((j >> 4) & 15) as i32,
];
let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]
.map(|d| i64::from(d) * i64::from(d))
.iter()
.sum::<i64>();
if d > i64::from(RANGE * RANGE) || best.is_some_and(|(b, _)| d >= b) {
continue;
}
let surface = self.world.world_surface(p[0], p[2], |s| facts.is_air(s));
if p[1] == surface - 1 {
best = Some((d, p));
}
}
}
}
}
best.map(|(_, p)| [p[0], p[1] + 1, p[2]])
}
/// `findLightningTargetAround`: the closest lightning rod, else a living
/// entity under open sky in the column (3 blocks around), else the top of
/// the column.
fn lightning_target(&mut self, x: i32, z: i32) -> Option<[i32; 3]> {
if !self.world.loaded(x, z) {
return None;
}
let top =
self.with_level(|b, level, _| b.motion_blocking_top(level, BlockPos::new(x, 0, z)));
if let Some(rod) = self.find_lightning_rod([x, top.y, z]) {
return Some(rod);
}
let sees_sky = |g: &Self, p: [i32; 3]| g.world.light(p[0], p[1], p[2]) >> 4 == 15;
let mut candidates: Vec<[i32; 3]> = self
.mobs
@@ -162,11 +222,9 @@ impl Game<'_> {
converted: Vec::new(),
});
}
/// `spawnFire`: fire at the strike point and `extra` random neighbours.
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
/// wherever `BaseFireBlock.getState` fire can survive.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
if self.ui.difficulty < 2 {
return;
}
let base = [
at[0].floor() as i32,
at[1].floor() as i32,
@@ -183,18 +241,13 @@ impl Game<'_> {
if !self.res.facts().is_air(state) {
continue;
}
// BaseFireBlock.getState: soul fire on soul soil and sand.
let below = self.res.facts().get(self.world.block(p[0], p[1] - 1, p[2]));
let name = if matches!(&*below.name, "minecraft:soul_soil" | "minecraft:soul_sand") {
"minecraft:soul_fire"
} else {
"minecraft:fire"
};
// FireBlock.canSurvive: a sturdy floor (flammable neighbours aside).
if below.sturdy_full >> 1 & 1 == 0 {
continue;
}
if let Some(fire) = self.pack.assets.registry.default_state(name) {
// BaseFireBlock.getState and FireBlock/SoulFireBlock.canSurvive.
let at = BlockPos::new(p[0], p[1], p[2]);
let fire = self.with_level(|b, level, ticks| {
let fire = b.fire_state_for(level, ticks, at);
b.can_survive(level, ticks, at, fire).then_some(fire)
});
if let Some(fire) = fire {
let _ = self.level_set(p, fire);
}
}
@@ -219,7 +272,10 @@ impl Game<'_> {
pitch,
);
if !b.visual_only {
self.bolt_fire(b.at, 4);
// Only Normal and Hard start fires with the strike itself.
if self.ui.difficulty >= 2 {
self.bolt_fire(b.at, 4);
}
self.strike_rod(b.at);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ const KNOCKBACK_POWER: f32 = 0.7;
/// `Entity.isAlliedTo` between the player (`player` its UUID) and a mob
/// without teams: `TamableAnimal.considersEntityAsAlly` (a pet it tamed) and
/// `Allay.considersEntityAsAlly` (an allay that likes it).
fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
let tamable = matches!(
m.species(),
Species::Wolf
+4 -2
View File
@@ -159,8 +159,10 @@ impl Containers<'_> {
});
}
let entity = self.world.block_entities.get(&pos)?;
// A beehive keeps bees, not items: it is no `Container`.
if matches!(entity, BlockEntity::Beehive(_)) {
// A beehive keeps bees, not items, and a campfire cooks its own: neither
// is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops
// in front of them and a hopper leaves them alone.
if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) {
return None;
}
let kind = match (entity, name) {
+13 -3
View File
@@ -87,9 +87,12 @@ pub(super) struct Minecart {
pub fuse: i32,
/// Hopper minecarts: an unpowered activator rail enables them.
pub enabled: bool,
/// Chest minecarts: the loot table rolled into `items` on load, saved
/// again while the items are still that roll.
pub loot: Option<horizon_client::save::RolledLoot>,
}
const WIDTH: f64 = 0.98;
const HEIGHT: f64 = 0.7;
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
fn display_offset(kind: CartKind) -> f64 {
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
@@ -97,7 +100,7 @@ fn display_offset(kind: CartKind) -> f64 {
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
pub(super) const SEAT: f64 = 0.1875 - 0.6;
/// The placeholder container key under which an open cart's items live.
pub(super) const CART_KEY_Y: i32 = -100_000;
pub(super) const CART_KEY_Y: i32 = horizon_client::lan::menu::VEHICLE_KEY_Y;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Shape {
@@ -182,6 +185,7 @@ impl Minecart {
push: [0.0; 2],
fuse: -1,
enabled: true,
loot: None,
}
}
pub fn bounds(&self) -> Aabb {
@@ -1527,6 +1531,12 @@ impl Minecart {
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if !self.loot.as_ref().is_some_and(|l| l.restore(map)) {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
+98 -31
View File
@@ -17,6 +17,7 @@ use std::{path::PathBuf, time::Duration};
#[cfg(feature = "mods")]
use {
super::Screen,
super::{execution_role::ExecutionRole, lan::LanHost, server_mods::ModEvent},
horizon_client::{
extras::Corner,
extras_hud,
@@ -28,7 +29,8 @@ use {
ui,
},
horizon_mods::{
Clock, ModKey, Mods, Output, Player, Runs, Snapshot, Status, UiScreen, UiWidget,
Clock, ModKey, Mods, Output, Player, Runs, ServerPlayer, Snapshot, Status, UiScreen,
UiWidget,
},
std::time::Instant,
winit::{event::MouseButton, keyboard::KeyCode},
@@ -52,6 +54,23 @@ pub(super) struct ModsState {
/// The chat's command tree while a running mod owns commands.
#[cfg(feature = "mods")]
tree: Option<Dispatcher>,
/// A server mod runs here: what players do is queued for it.
#[cfg(feature = "mods")]
pub(super) server: bool,
/// The players server mods saw last tick, for joins and leaves.
#[cfg(feature = "mods")]
pub(super) players: Vec<ServerPlayer>,
/// What players did this tick, for server mods at its end.
#[cfg(feature = "mods")]
pub(super) events: Vec<ModEvent>,
/// A running server mod has capability `world`: its calls get the
/// levels' chunks to read.
#[cfg(feature = "mods")]
pub(super) world: bool,
/// Blocks server mods set, by dimension, made before that level's next
/// tick.
#[cfg(feature = "mods")]
pub(super) block_writes: Vec<(String, horizon_mods::BlockChange)>,
/// `command.run` lines for this window's own world, run on the next tick:
/// run at once, their chat lines would reach the mods while their output
/// is still being shown, and a mod answering its own lines would recurse.
@@ -285,7 +304,7 @@ impl Game<'_> {
.collect();
let enabled = &self.settings.mods.enabled;
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
let mods = Mods::load_for(Runs::Client, &paths, on, vanilla_command);
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
self.mods.mods = mods;
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
@@ -297,8 +316,19 @@ impl Game<'_> {
self.sync_mod_commands();
self.show_mod_output();
}
/// Which mods this game runs: a server the server mods, the window
/// client mods, and both when it simulates the world itself (in-frame).
fn mod_runs(&self) -> Runs {
match self.options.execution_role {
ExecutionRole::IntegratedServer | ExecutionRole::DedicatedServer => Runs::Server,
ExecutionRole::Client if self.options.server_thread => Runs::Client,
ExecutionRole::Client => Runs::Both,
}
}
/// The Mods page's rows, from what loaded.
fn list_mods(&mut self) {
self.mods.server = self.mods.mods.runs_server();
self.mods.world = self.mods.mods.wants_world();
let dev = &self.mods.dev;
self.settings.mods.installed = self
.mods
@@ -384,8 +414,18 @@ impl Game<'_> {
self.start_mods();
}
}
/// What mods read this tick (or frame, for mods with a `frame` hook).
/// What mods read this tick (or frame, for mods with a `frame` hook): the
/// server's players as they were at the last tick's end.
fn mod_snapshot(&mut self) -> Snapshot {
let players = if self.mods.server {
self.mods.players.clone()
} else {
Vec::new()
};
self.mod_snapshot_with(players)
}
/// What mods read, with these players on the server.
pub(super) fn mod_snapshot_with(&mut self, players: Vec<ServerPlayer>) -> Snapshot {
let player = self.player.as_ref().map(|p| Player {
x: p.position.x,
y: p.position.y,
@@ -430,7 +470,7 @@ impl Game<'_> {
mappings,
cps,
font_widths: self.mods.font_widths.clone(),
players: Vec::new(),
players,
view_hit: self.mod_view_hit(),
recipes,
known_recipes,
@@ -556,23 +596,32 @@ impl Game<'_> {
self.mods.mods.tick(self.ticks);
self.show_mod_output();
}
/// Once per frame: the frame hook, and dev mods' rebuilds.
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
if !self.mods.dev.is_empty()
&& self
/// Restarts the dev mods rebuilt since the last check, once a second.
pub(super) fn reload_dev_mods(&mut self) {
if self.mods.dev.is_empty()
|| self
.mods
.checked
.is_none_or(|t| t.elapsed() >= RELOAD_CHECK)
.is_some_and(|t| t.elapsed() < RELOAD_CHECK)
{
self.mods.checked = Some(Instant::now());
if self.mods.mods.reload_changed(vanilla_command) {
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
self.list_mods();
self.sync_mod_commands();
return;
}
self.mods.checked = Some(Instant::now());
if self.mods.mods.reload_changed(vanilla_command) {
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
self.list_mods();
self.sync_mod_commands();
if self.options.execution_role.is_client() {
self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY));
} else {
log::info!("Horizon: mods reloaded");
}
}
}
/// Once per frame: the frame hook, and dev mods' rebuilds.
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
self.reload_dev_mods();
if !self.mods.mods.is_running() {
return;
}
@@ -603,8 +652,13 @@ impl Game<'_> {
self.mods.mods.chat(line);
self.show_mod_output();
}
/// Chat lines, command feedback and stopped mods, into the chat.
fn show_mod_output(&mut self) {
/// What mods showed: client mods' lines into the chat, server mods'
/// to their players.
pub(super) fn show_mod_output(&mut self) {
self.route_mod_output(None);
}
/// [`Game::show_mod_output`], with the hub when it is out of `self.lan`.
pub(super) fn route_mod_output(&mut self, mut host: Option<&mut LanHost>) {
for output in self.mods.mods.drain() {
let line = match output {
Output::Chat { name, text } => {
@@ -636,7 +690,18 @@ impl Game<'_> {
// `mod_ui_view` finds no widgets and the screen closes.
self.list_mods();
self.sync_mod_commands();
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
let line = format!("Mod {name} stopped: {reason}");
if self.options.dedicated.is_some() {
super::dedicated::log("WARN", &line);
}
Text::literal(line).with_color(text::RED)
}
server @ (Output::Tell { .. }
| Output::Broadcast { .. }
| Output::Reply { .. }
| Output::SetBlocks { .. }) => {
self.route_server_output(host.as_deref_mut(), server);
continue;
}
Output::OpenScreen { owner } => {
self.open_mod_screen(owner, false);
@@ -652,20 +717,8 @@ impl Game<'_> {
}
continue;
}
// Server mods' lines: this game runs no server mod yet.
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
};
// Not through system_message: a mod must not hear its own lines.
// Written by this client, so the Chat option doesn't hide it.
let focused = self.screen == Screen::Chat;
self.ui.chat.add(
&self.gui.atlas,
line,
None,
horizon_client::chat::ChatSource::Client,
self.ticks,
focused,
);
self.add_mod_chat_line(line);
}
// A mod may set or clear its settings page in any call, not only
// while loading: its row's Settings button follows.
@@ -674,6 +727,20 @@ impl Game<'_> {
row.has_settings = e.has_ui_settings();
}
}
/// A mod's line in the window's chat; not through system_message: a mod
/// must not hear its own lines.
/// Written by this client, so the Chat option doesn't hide it.
pub(super) fn add_mod_chat_line(&mut self, line: Text) {
let focused = self.screen == Screen::Chat;
self.ui.chat.add(
&self.gui.atlas,
line,
None,
horizon_client::chat::ChatSource::Client,
self.ticks,
focused,
);
}
/// The mods' HUD panels, stacked with the extras in their corners.
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
for panel in self.mods.mods.panels() {
+1 -1
View File
@@ -91,7 +91,7 @@ impl Game<'_> {
})
}
/// `isEyeInFluid(WATER)`: the eye inside the water of its block column.
fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
pub(super) fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32);
let f = self.res.facts().get(self.world.block(bx, by, bz));
if f.fluid != Fluid::Water {
+7 -3
View File
@@ -86,6 +86,10 @@ impl Game<'_> {
.iter()
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
let players = players.into_iter().map(|(m, name)| (m, name, true));
// Who is riding whom: asked once per tag below, and rescanning every mob
// for each tag made this quadratic in the number of loaded mobs.
let ridden: std::collections::HashSet<u32> =
self.mobs.iter().filter_map(|o| o.extra.mount).collect();
for (m, name, player) in named.chain(players) {
if !m.alive() {
continue;
@@ -134,7 +138,7 @@ impl Game<'_> {
invisible_to_viewer: invisible,
hud_hidden: self.ui.hide_gui,
is_camera: false,
is_vehicle: self.mobs.iter().any(|o| o.extra.mount == Some(m.id)),
is_vehicle: ridden.contains(&m.id),
};
// MobRenderer.shouldShowName: a visible name, or the mob under the
// crosshair; players (LivingEntityRenderer) need neither.
@@ -310,7 +314,7 @@ impl Game<'_> {
}
/// The lines of one name tag, `o` from the camera: the text at
/// `text_alpha` in its layer, on the translucent black background
/// (unlit, a hair behind) when `background`.
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
#[allow(clippy::too_many_arguments)]
fn name_tag_quads(
&self,
@@ -382,7 +386,7 @@ impl Game<'_> {
[white.u0, white.v0, white.u1, white.v1],
BACKGROUND,
true,
2,
text_layer,
);
}
let mut x = x0;
@@ -454,11 +454,24 @@ impl Game<'_> {
for m in &self.mobs {
self.portals.before.insert(m.id, m.position);
}
for b in &self.boats {
self.portals.before.insert(b.id, b.position);
}
for c in &self.carts {
self.portals.before.insert(c.id, c.position);
}
let ids: Vec<(u32, bool)> = self
.dropped
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.boats
.iter()
.map(|b| b.id)
.chain(self.carts.iter().map(|c| c.id))
.map(|id| (id, !self.vehicle_occupied(id))),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
@@ -501,8 +514,15 @@ impl Game<'_> {
&& self.riding != Some(m.id)
&& m.species() != Species::Wither
}
/// `NetherPortalBlock.entityInside` for the items and mobs that moved this
/// tick: the portal blocks their box swept through.
/// Whether the player, a server player or a mob rides vehicle `id`.
fn vehicle_occupied(&self, id: u32) -> bool {
self.riding == Some(id)
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
/// and minecarts that moved this tick: the portal blocks their box swept
/// through.
pub(super) fn find_entity_portals(&mut self) {
if self.remote.is_some() {
return;
@@ -519,8 +539,23 @@ impl Game<'_> {
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
.boats
.iter()
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
.chain(self.carts.iter().map(|c| {
(
c.id,
c.position,
super::minecarts::WIDTH,
super::minecarts::HEIGHT,
)
}))
.filter(|(id, ..)| !self.vehicle_occupied(*id))
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
.collect();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
for (id, to, width, height) in items.into_iter().chain(mobs) {
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
@@ -678,6 +713,44 @@ impl Game<'_> {
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
let plan = self.portal_plan(entry, b.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let b = &self.boats[i];
let tag = horizon_client::entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist_raw(id),
b.chest.then_some(&b.items[..]),
);
self.boats.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = &self.carts[i];
let (width, height) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
let plan = self.portal_plan(entry, c.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
self.carts.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
}
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
@@ -257,6 +257,7 @@ impl Game<'_> {
lang::set(lang::Language::load(&[&root], code));
self.refresh_remote_language();
self.ui.chat.rescale(&self.gui.atlas);
self.creative_screen.forget_items();
}
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
pub(super) fn apply_ui_settings(&mut self) {
@@ -268,6 +269,11 @@ impl Game<'_> {
r.animated_textures = s.horizon.animated_textures;
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa);
r.set_hdr(
s.horizon.hdr,
s.horizon.hdr_paper_white as f32,
s.horizon.hdr_peak as f32,
);
}
// Window.updateFullscreenIfChanged: only a changed option asks the
// window to change, so applying settings never undoes a fullscreen
+3 -1
View File
@@ -163,7 +163,7 @@ impl Game<'_> {
self.pack.context.biomes.clone(),
self.pack.context.light.clone(),
)?;
// Keep the working GPU resources, but release its exclusive window surface.
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
renderer.release_surface();
}
@@ -221,6 +221,8 @@ impl Game<'_> {
self.apply_ui_settings();
}
self.ui.chat.rescale(&self.gui.atlas);
// The creative screen survives the reload; item names may not.
self.creative_screen.forget_items();
Ok(())
}
}
+53 -1
View File
@@ -279,7 +279,8 @@ impl Game<'_> {
k if super::minecarts::CartKind::from_id(k).is_some() => {
let kind = super::minecarts::CartKind::from_id(k).unwrap();
let id = self.next_entity + 1;
if let Some(c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) {
if let Some(mut c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) {
self.roll_cart_loot(&mut c, &tag);
self.next_entity = id;
self.remember(id, uuid, tag);
self.carts.push(c);
@@ -314,6 +315,13 @@ impl Game<'_> {
self.item_frames.push(f);
}
}
"minecraft:cushion" => {
self.next_entity += 1;
if let Some(c) = entity_nbt::cushion_from_nbt(&tag, self.next_entity) {
self.remember(c.id, uuid, tag);
self.cushions.push(c);
}
}
"minecraft:painting" => {
if let Some((pos, facing, variant)) = entity_nbt::painting_from_nbt(&tag)
&& let Some(v) = self.painting_variants.get(&variant).cloned()
@@ -527,6 +535,30 @@ impl Game<'_> {
pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> {
self.persist.raw.get(&id)
}
/// A loaded chest minecart with a loot table and no items: the table
/// rolled now, as generated containers are (Horizon does not unpack on
/// first open), and kept to save it again while unchanged.
fn roll_cart_loot(&self, cart: &mut super::minecarts::Minecart, tag: &Tag) {
let Some(table) = horizon_client::save::text(tag.get("LootTable")) else {
return;
};
if cart.items.is_empty() || cart.items.iter().any(Option::is_some) {
return;
}
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
let p = cart.position;
let pos = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let biome = self.biome_name_at(p).unwrap_or_default();
let seed = horizon_client::structure_blocks::loot_seed(tag);
cart.loot = Some(horizon_client::structure_blocks::roll_container(
&mut cart.items,
loot.as_deref(),
table,
seed,
pos,
&biome,
));
}
fn remember(&mut self, id: u32, uuid: Option<[i32; 4]>, tag: Tag) {
self.restore_portal_cooldown(id, &tag);
if let Some(u) = uuid {
@@ -776,6 +808,18 @@ impl Game<'_> {
out.entry(chunk_of(at)).or_default().push(tag);
}
}
for c in &self.cushions {
if keys.contains(&chunk_of(c.position)) {
let uuid = self
.persist
.uuids
.get(&c.id)
.copied()
.unwrap_or_else(random_uuid);
let tag = entity_nbt::cushion_to_nbt(c, uuid, self.persist.raw.get(&c.id));
out.entry(chunk_of(c.position)).or_default().push(tag);
}
}
for p in &self.paintings {
let at = painting_center(p);
if keys.contains(&chunk_of(at)) {
@@ -868,6 +912,13 @@ impl Game<'_> {
}
keep
});
self.cushions.retain(|c| {
let keep = !gone(c.position);
if !keep {
removed.push(c.id);
}
keep
});
// Riders leave with their root vehicle, a boat's with the boat.
let leaving: HashSet<u32> = (0..self.mobs.len())
.filter(|&i| {
@@ -934,6 +985,7 @@ impl Game<'_> {
.chain(self.boats.iter().map(|e| e.id))
.chain(self.carts.iter().map(|e| e.id))
.chain(self.armor_stands.iter().map(|e| e.id))
.chain(self.cushions.iter().map(|e| e.id))
.chain(self.item_frames.iter().map(|e| e.id))
.chain(self.paintings.iter().map(|e| e.id))
.chain(self.others.iter().map(|e| e.id))
@@ -155,6 +155,8 @@ mod tests {
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
}))
}
+192 -33
View File
@@ -4,18 +4,172 @@
use super::Game;
use horizon_client::{
items::ItemStack,
mobs::{Species, Who, piglin::ADMIRE},
mobs::{
Mob, Species, Who,
finalize::{
SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
stored_drop_chance,
},
piglin::{ADMIRE, ATE_RECENTLY},
},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
fn piglin_loved(&self, item: &str) -> bool {
fn item_in_tag(&self, tag: &str, item: &str) -> bool {
self.res
.items()
.item_tags
.get("minecraft:piglin_loved")
.get(tag)
.is_some_and(|t| t.contains(item))
}
fn piglin_loved(&self, item: &str) -> bool {
self.item_in_tag("minecraft:piglin_loved", item)
}
/// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`).
fn piglin_wanted(&self, item: &str) -> bool {
self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item)
}
/// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes
/// to its slot, anything else to the main hand.
fn piglin_slot(&self, item: &str) -> usize {
let slot = self
.res
.items()
.get(item)
.and_then(|i| i.equip_slot.as_deref())
.filter(|s| matches!(*s, "head" | "chest" | "legs" | "feet"))
.unwrap_or("mainhand");
SLOT_NAMES.iter().position(|n| *n == slot).unwrap_or(0)
}
/// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a wanted
/// item replaces an unwanted one and never the other way round. Vanilla's
/// armour and weapon comparison for two equal-interest items is left out
/// (the piglin keeps what it has).
fn piglin_can_replace(&self, m: &Mob, item: &str, slot: usize) -> bool {
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
return true;
};
self.piglin_wanted(item) && !self.piglin_wanted(&current)
}
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
m.extra.inventory.len() < 8
|| m.extra.inventory.iter().any(|s| {
s.same_item_same_components(stack) && s.count < self.piglin_max_stack(&s.item)
})
}
fn piglin_max_stack(&self, item: &str) -> u32 {
self.res.items().get(item).map_or(64, |i| i.max_stack)
}
/// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without
/// the baby rule, which babies skip entirely here.
fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool {
let item = &*stack.item;
if self.item_in_tag("minecraft:piglin_repellents", item) {
return false;
}
let holds_loved = m
.extra
.off_hand
.as_deref()
.is_some_and(|o| self.piglin_loved(o));
if item == "minecraft:gold_ingot" {
return !holds_loved;
}
let space = self.piglin_can_add(m, stack);
if item == "minecraft:gold_nugget" {
space
} else if self.item_in_tag("minecraft:piglin_food", item) {
m.extra.ints[0] <= 0 && space
} else if !self.piglin_loved(item) {
self.piglin_can_replace(m, item, self.piglin_slot(item))
} else {
!holds_loved && space
}
}
/// `Mob.equipItemIfPossible` for a piglin: put it in its slot when that
/// slot takes it (an armour piece that cannot replace what is worn goes
/// to an empty main hand), dropping what it replaces by drop chance.
fn piglin_equip(&mut self, index: usize, stack: &ItemStack) -> bool {
let item = &*stack.item;
let mut slot = self.piglin_slot(item);
let m = &self.mobs[index];
let mut replace = self.piglin_can_replace(m, item, slot);
if slot > 1 && !replace {
slot = 0;
replace = m.extra.main_hand.is_none();
}
if !replace {
return false;
}
let m = &mut self.mobs[index];
let old = horizon_client::mobs::finalize::item(m, slot);
let chance = stored_drop_chance(m, slot);
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
let old_enchantments = slot_enchantments(m, slot);
set_item(m, slot, item);
// The piglin keeps what the stack carried (Protection IV stays on).
set_slot_enchantments(m, slot, stack.enchantments().to_vec());
// setItemSlotAndDropWhenKilled, then persistenceRequired.
set_drop_chance(m, slot, 2.0);
m.persistent = true;
let at = [m.position.x, m.position.y, m.position.z];
if let (true, Some(old)) = (drop_old, old) {
let mut dropped = ItemStack::new(&old, 1);
dropped.set_crafting_enchantments(old_enchantments);
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
}
true
}
/// `PiglinAi.putInInventory`: what does not fit is thrown at a random
/// spot nearby.
fn piglin_stash(&mut self, index: usize, mut stack: ItemStack) {
let max = self.piglin_max_stack(&stack.item);
let m = &mut self.mobs[index];
for s in &mut m.extra.inventory {
if stack.count > 0 && s.same_item_same_components(&stack) && s.count < max {
let n = (max - s.count).min(stack.count);
s.count += n;
stack.count -= n;
}
}
while stack.count > 0 && m.extra.inventory.len() < 8 {
let n = stack.count.min(max);
m.extra.inventory.push(ItemStack {
count: n,
..stack.clone()
});
stack.count -= n;
}
if stack.count == 0 {
return;
}
let from = [m.position.x, m.position.y + 1.3, m.position.z];
let r = &mut self.world.random;
let to = [
from[0] + f64::from(r.next_float() * 8.0 - 4.0),
from[1],
from[2] + f64::from(r.next_float() * 8.0 - 4.0),
];
self.piglin_throw(from, to, stack);
}
/// `PiglinAi.stopHoldingOffHandItem` for something that is not
/// bartering currency: wear it if it can, else keep it in the inventory.
fn piglin_keep(&mut self, index: usize, stack: ItemStack) {
// Nuggets keep their whole stack in the inventory (a hand slot here
// holds one item id only).
if &*stack.item == "minecraft:gold_nugget" || !self.piglin_equip(index, &stack) {
self.piglin_stash(index, stack);
}
}
/// `BehaviorUtils.throwItem`: toss a stack from a point toward another.
fn piglin_throw(&mut self, from: [f64; 3], to: [f64; 3], stack: ItemStack) {
let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3];
self.spawn_item(stack, from, v, 40);
}
/// `PiglinAi.isWearingSafeArmor`.
pub(super) fn wearing_gold(&self) -> bool {
let tag = self
@@ -28,15 +182,13 @@ impl Game<'_> {
.flatten()
.any(|s| tag.is_some_and(|t| t.contains(&*s.item)))
}
/// Piglins notice gold on the ground within 9 blocks, walk to it and
/// take it (`GoToWantedItem`, `Piglin.pickUpItem`).
/// Piglins notice wanted items on the ground within 9 blocks (`PiglinAi.wantsToPickup`:
/// gold and other loved items, nuggets, food, gear), walk to them and
/// take them (`GoToWantedItem`, `Piglin.pickUpItem`).
pub(super) fn piglin_pickups(&mut self) {
if !self.ui.rules.bool("mob_griefing") {
return;
}
// Whether each item is piglin-loved, kept until an item is added or
// removed (added ones are pushed at the end under a new id).
let mut loved: (Option<(usize, Option<u32>)>, Vec<bool>) = (None, Vec::new());
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Piglin
@@ -44,27 +196,17 @@ impl Game<'_> {
|| m.is_baby()
|| m.extra.using_item > 0
|| m.extra.egg_time > 0
|| m.extra.off_hand.is_some()
{
continue;
}
let pos = m.position;
let key = (self.dropped.len(), self.dropped.last().map(|e| e.id));
if loved.0 != Some(key) {
loved.0 = Some(key);
loved.1 = self
.dropped
.iter()
.map(|e| self.piglin_loved(&e.stack.item))
.collect();
}
let best = self
.dropped
.iter()
.enumerate()
.filter(|&(j, _)| loved.1[j])
.map(|(j, e)| (j, e.position.distance_squared(pos)))
.filter(|(_, d)| *d <= 81.0)
.filter(|&(j, _)| self.piglin_wants(m, &self.dropped[j].stack))
.min_by(|a, b| a.1.total_cmp(&b.1));
let Some((j, _)) = best else {
self.mobs[i].extra.point = None;
@@ -79,24 +221,38 @@ impl Game<'_> {
continue;
}
// Nuggets come whole, anything else one at a time.
let item = e.stack.item.clone();
let (taken_item, id) = (e.clone(), self.mobs[i].id);
let item = e.stack.item.clone();
self.add_pickup(
super::pickups::Taken::Item(Box::new(taken_item)),
super::pickups::Collector::Mob(id),
);
let taken = if &*item == "minecraft:gold_nugget" {
self.dropped.remove(j).stack.count
let stack = if &*item == "minecraft:gold_nugget" {
self.dropped.remove(j).stack
} else {
let e = &mut self.dropped[j];
e.stack.count -= 1;
let one = ItemStack {
count: 1,
..e.stack.clone()
};
if e.stack.count == 0 {
self.dropped.remove(j);
}
1
one
};
let _ = taken;
self.start_admiring(i, item);
if self.piglin_loved(&item) {
self.start_admiring(i, item);
} else if self.item_in_tag("minecraft:piglin_food", &item)
&& self.mobs[i].extra.ints[0] <= 0
{
// PiglinAi.eat: the food is gone and it will not eat for a while.
self.mobs[i].extra.ints[0] = ATE_RECENTLY;
self.mobs[i].nav_stop();
} else {
self.mobs[i].nav_stop();
self.piglin_keep(i, stack);
}
}
}
fn start_admiring(&mut self, index: usize, item: std::sync::Arc<str>) {
@@ -109,14 +265,15 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.piglin.admiring_item", Some(at), 1.0, 1.0);
}
/// `PiglinAi.mobInteract`: hand a piglin something it loves.
/// `PiglinAi.mobInteract`: hand a piglin a gold ingot (only piglin
/// currency is accepted from the hand; other loved items are passed over).
pub(super) fn give_piglin(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
if m.species() != Species::Piglin
|| m.is_baby()
|| m.extra.using_item > 0
|| m.extra.egg_time > 0
|| !self.piglin_loved(item)
|| item != "minecraft:gold_ingot"
{
return false;
}
@@ -124,7 +281,8 @@ impl Game<'_> {
true
}
/// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a
/// roll of the bartering table, thrown toward the player.
/// roll of the bartering table, thrown toward the player; any other loved
/// item it picked up is kept (worn if it can be, else stored).
pub(super) fn finish_admiring(&mut self, mob: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
@@ -132,9 +290,13 @@ impl Game<'_> {
let Some(item) = self.mobs[i].extra.off_hand.take() else {
return;
};
if &*item != "minecraft:gold_ingot" {
self.piglin_keep(i, ItemStack::new(&item, 1));
return;
}
let m = &self.mobs[i];
let from = [m.position.x, m.position.y + 1.3, m.position.z];
let items = if &*item == "minecraft:gold_ingot" && !m.is_baby() {
let items = if !m.is_baby() {
self.res.items().loot_table(
"minecraft:gameplay/piglin_bartering",
false,
@@ -176,10 +338,7 @@ impl Game<'_> {
from[2] + f64::from(r.next_float() * 8.0 - 4.0),
]
});
let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3];
self.spawn_item(stack, from, v, 40);
self.piglin_throw(from, to, stack);
}
}
/// `PiglinAi.angerNearbyPiglins`.
+95 -21
View File
@@ -71,6 +71,8 @@ pub(super) struct BodyPotions {
pub(super) struct PotionState {
pub(super) effects: PlayerEffects,
thrown: Vec<ThrownPotion>,
/// The area effect clouds a server tracks (game/remote_projectiles.rs).
pub(super) clouds: Vec<super::remote_projectiles::RemoteCloud>,
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
/// Armor the protection table was computed for.
protection_for: Option<Vec<Option<ItemStack>>>,
@@ -141,14 +143,22 @@ impl PotionState {
t.stack = stack;
}
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
for t in &mut self.thrown {
t.previous = t.position;
t.age += 1;
t.position = step(t.id, t.position);
}
/// A server's move: thrown potions have no interpolation handler, so
/// `moveOrInterpolateTo` puts them there at once. False when `id` isn't one.
pub(super) fn move_remote_thrown(&mut self, id: u32, at: Vec3) -> bool {
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
return false;
};
t.position = at;
true
}
/// `handleSetEntityMotion` for a thrown potion. False when `id` isn't one.
pub(super) fn remote_thrown_motion(&mut self, id: u32, velocity: Vec3) -> bool {
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
return false;
};
t.velocity = velocity;
true
}
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
self.thrown.retain(|t| t.id != id);
@@ -266,7 +276,8 @@ impl Game<'_> {
} else if let Some(rest) = self.give(filled)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
// `player.drop(stack, false)`.
self.throw_from_eyes(rest, eye, false);
}
}
/// Drinking a potion while right-click is held; true when handled.
@@ -390,8 +401,28 @@ impl Game<'_> {
for action in actions {
self.apply_effect_action(action);
}
self.turtle_helmet_tick();
self.apply_effect_status();
}
/// `Player.tick` -> `turtleHelmetTick`: with a turtle helmet on the head
/// and the eyes out of the water, Water Breathing for 200 ticks (no
/// particles, an icon).
fn turtle_helmet_tick(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let eye_y = p.position.y + f64::from(p.dimensions.eye_height);
if !p.health.dead_or_dying()
&& self.armor[0]
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:turtle_helmet")
&& !self.eye_in_water(p.position.x, eye_y, p.position.z)
{
let mut effect = EffectInstance::new("water_breathing", 200, 0);
effect.visible = false;
self.potions.effects.add(effect);
}
}
/// A client's own effects (`LivingEntity.tickEffects` on the client,
/// `MobEffectInstance.tickClient`): they count down, and the server
/// removes them. Their modifiers and flags move the predicted player.
@@ -484,6 +515,7 @@ impl Game<'_> {
thrown.retain_mut(|t| {
t.age += 1;
t.previous = t.position;
self.thrown_potion_motion(t);
let next = Vec3::new(
t.position.x + t.velocity.x,
t.position.y + t.velocity.y,
@@ -520,18 +552,6 @@ impl Game<'_> {
return false;
}
t.position = next;
// ThrowableItemProjectile: drag 0.99 then gravity 0.05.
// ThrownExperienceBottle.getDefaultGravity is 0.07.
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
0.07
} else {
0.05
};
t.velocity = Vec3::new(
t.velocity.x * 0.99,
t.velocity.y * 0.99 - gravity,
t.velocity.z * 0.99,
);
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
@@ -539,6 +559,60 @@ impl Game<'_> {
self.splash(&stack, at);
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
/// come before the move: gravity 0.05 (0.07 for an experience bottle),
/// then drag 0.99 (0.8 in water).
fn thrown_potion_motion(&self, t: &mut ThrownPotion) {
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
0.07
} else {
0.05
};
let at = t.position;
let water = self
.res
.facts()
.get(self.world.block(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water;
let inertia = if water { 0.8 } else { 0.99 };
t.velocity = Vec3::new(
t.velocity.x * inertia,
(t.velocity.y - gravity) * inertia,
t.velocity.z * inertia,
);
}
/// `ThrowableProjectile.tick` on a server's client: it falls and moves
/// by itself between the server's updates and stops at a block; only
/// the server breaks it.
pub(super) fn step_remote_thrown(&mut self) {
let mut thrown = std::mem::take(&mut self.potions.thrown);
for t in &mut thrown {
t.previous = t.position;
t.age += 1;
self.thrown_potion_motion(t);
let next = Vec3::new(
t.position.x + t.velocity.x,
t.position.y + t.velocity.y,
t.position.z + t.velocity.z,
);
let state = self.world.block(
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
);
if self.res.facts().get(state).full_collision {
t.velocity = Vec3::ZERO;
} else {
t.position = next;
}
}
self.potions.thrown = thrown;
}
/// `ThrownItemRenderer` for thrown splash and lingering potions and
/// experience bottles: the item (GROUND) facing the camera at scale 1,
/// lit at the light probe (the eye, 0.85 of the 0.25 height).
+2
View File
@@ -796,6 +796,8 @@ impl Game<'_> {
let s = m.extra.raider.get_or_insert_with(Box::default);
s.rider = has_rider;
s.in_village = in_village;
// Vindicator.customServerAiStep: `ServerLevel.isRaided`.
s.raided = species == Species::Vindicator && raids.nearby(block).is_some();
if s.raid_id.is_some_and(|id| raids.get(id).is_none()) {
s.raid_id = None;
s.ticks_outside_raid = 0;
+167 -46
View File
@@ -847,7 +847,10 @@ impl Game<'_> {
remote.quiet_commands = true;
}
}
Event::Reconfigured(registries) => self.set_registries(registries),
Event::Reconfigured(registries) => {
self.boss_overlay.reset();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
@@ -1266,6 +1269,9 @@ impl Game<'_> {
} else {
self.res.mob_types().get(kind).cloned()
};
if kind == "minecraft:ender_dragon" {
self.spawn_remote_dragon(id, position, yaw);
}
if kind == "minecraft:experience_orb" {
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
self.orbs.retain(|o| o.id != id as u32);
@@ -1277,6 +1283,8 @@ impl Game<'_> {
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
});
}
if let Some(t) = mob_type {
@@ -1372,13 +1380,17 @@ impl Game<'_> {
m.on_ground = on_ground;
return;
}
if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| {
p.id == id as u32
&& matches!(
p.kind,
horizon_client::projectiles::ProjectileKind::LargeFireball { .. }
)
}) {
// Projectiles keep Entity's NO_OP InterpolationHandler too: the
// server's position replaces theirs at once and their own tick
// flies them on (`tick_remote_projectiles`). Eased towards it
// instead, they only moved when an update came, every 10 to 20
// ticks.
if let Some(p) = self
.creatures
.projectiles
.iter_mut()
.find(|p| p.id == id as u32)
{
if path.is_some() {
p.position = Vec3::new(position[0], position[1], position[2]);
}
@@ -1388,6 +1400,22 @@ impl Game<'_> {
}
return;
}
if path.is_some()
&& (self.potions.move_remote_thrown(
id as u32,
Vec3::new(position[0], position[1], position[2]),
) || self.end.move_remote_eye(id as u32, position))
{
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
if path.is_some() {
b.position = Vec3::new(position[0], position[1], position[2]);
}
return;
}
if let Some(p) = r.pending_items.get_mut(&id) {
p.0 = position;
}
@@ -1434,6 +1462,8 @@ impl Game<'_> {
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
// Before the crystals go: the dragon's beam index follows them.
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
if let Some(r) = self.remote.as_mut() {
@@ -1669,19 +1699,30 @@ impl Game<'_> {
}
}
C::BlockChangedAck(p) => self.end_predictions(p.sequence),
// ClientLevel.blockEvent: BlockEntity.triggerEvent (chest lids,
// shulker boxes, bells) when the block is still the one it names.
// Level.blockEvent on the client level: the block's triggerEvent
// runs on the client's copy of whatever is at pos (a piston's
// moveBlocks builds the moving block entities it animates, a note
// block plays); what it doesn't handle reaches the client copy of
// the block entity (chest lids, shulker boxes).
C::BlockEvent(p) => {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let block = &self.res.facts().get(state).name;
let named = self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:block")?
.name(p.block)
.map(str::to_owned)
let pos = horizon_core::position::BlockPos::new(p.pos[0], p.pos[1], p.pos[2]);
let triggered = self.with_level(|b, level, ticks| {
b.client_block_event(level, ticks, pos, i32::from(p.b0), i32::from(p.b1))
});
if named.as_deref() == Some(&**block) {
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
self.sync_moving_pistons();
self.process_level_events()?;
if !triggered {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let block = &self.res.facts().get(state).name;
let named = self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:block")?
.name(p.block)
.map(str::to_owned)
});
if named.as_deref() == Some(&**block) {
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
}
}
}
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
@@ -1966,6 +2007,7 @@ impl Game<'_> {
}
self.remote_object_data(p.id, &p.values);
self.remote_projectile_data(p.id, &p.values);
self.remote_dragon_data(p.id, &p.values);
}
// ClientPacketListener.handleSetEquipment: what a mob or another
// player holds and wears (EquipmentSlot ordinals).
@@ -2024,7 +2066,10 @@ impl Game<'_> {
{
self.add_pickup(super::pickups::Taken::Orb(Box::new(orb)), collector);
}
self.remove_entity(p.item);
// An orb stays: the server may still hold merged ones in it.
if !orb {
self.remove_entity(p.item);
}
}
// ClientPacketListener.handleSetEntityMotion: knockback on this player.
C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
@@ -2047,6 +2092,8 @@ impl Game<'_> {
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
}
}
C::HurtAnimation(p) => {
@@ -2056,9 +2103,14 @@ impl Game<'_> {
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
}
}
C::EntityEvent(p) => {
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
self.remote_firework_explosion(p.id);
}
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
@@ -2072,6 +2124,11 @@ impl Game<'_> {
{
horizon_client::mobs::rabbit::hop_event(m);
}
if p.event == 19
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
@@ -2944,8 +3001,12 @@ impl Game<'_> {
/// entities whose own tick moves them on a client: dropped items, falling
/// blocks and primed TNT.
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.velocity = v;
@@ -2958,12 +3019,16 @@ impl Game<'_> {
.find(|p| p.id == id as u32)
{
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
.and_then(|r| r.pending_items.get_mut(&id))
{
p.1 = v;
} else if !self.potions.remote_thrown_motion(id as u32, v) {
self.end.remote_eye_motion(id as u32, velocity);
}
}
fn apply_remote_health(&mut self) {
@@ -3070,6 +3135,12 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
self.track_experience_display();
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
// for a server's client (multiplayer and the default single player),
// so the entries advance here or the slot stays shaded until the
// server says the entry ended. `onCooldownEnded` is the plain
// ItemCooldowns' no-op on a client.
self.cooldowns.tick();
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
let view = client_view_distance(self.settings.render_distance);
@@ -3107,8 +3178,14 @@ impl Game<'_> {
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells.
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
// and the moving pistons a block event's client-side moveBlocks made
// (their progress advances between the server's block updates).
self.tick_block_entity_anims();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
self.sync_moving_pistons();
self.tick_hand();
self.tick_particles();
self.tick_client_ambient();
@@ -3433,6 +3510,13 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Creeper {
horizon_client::mobs::hostile::creeper_client_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Guardian
| horizon_client::mobs::Species::ElderGuardian
) {
horizon_client::mobs::guardian::client_tick(m);
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
@@ -3492,6 +3576,8 @@ impl Game<'_> {
} else if m.alive() {
m.body_rotation_tick();
}
// Squid.aiStep follows LivingEntity's interpolation and head turn.
horizon_client::mobs::aquatic::squid_client_step(m);
// WalkAnimationState.update from the horizontal distance moved.
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0);
@@ -3500,6 +3586,9 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::post_tick(m);
}
if m.species() == horizon_client::mobs::Species::Bat {
horizon_client::mobs::bat::setup_animation_states(m);
}
}
for o in self.orbs.iter_mut().filter(|_| running) {
o.previous = o.position;
@@ -3528,6 +3617,18 @@ impl Game<'_> {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
.as_ref()
.and_then(|d| lerps.get_mut(&(d.id as i32)))
.filter(|l| l.steps > 0)
.map(|l| {
let steps = l.steps;
l.steps -= 1;
(l.target, l.rotation.map(|r| r.0), steps)
});
self.tick_remote_dragon(dragon_lerp);
// Boats, minecarts, armor stands, projectiles and the rest
// interpolate like mobs.
let mut step = |id: u32, at: Vec3| match lerps.get_mut(&(id as i32)) {
@@ -3690,6 +3791,15 @@ impl Game<'_> {
/// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock.
fn remote_mining(&mut self, start: bool) {
if !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
self.stop_remote_mining();
self.mining = None;
return;
}
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3829,19 +3939,25 @@ impl Game<'_> {
.and_modify(|p| p.0 = sequence)
.or_insert((sequence, state.0 as i32));
}
let s = self.res.facts().get(state).sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&s.break_sound,
Some(at),
(s.volume + 1.0) / 2.0,
s.pitch * 0.8,
);
self.destroy_particles(pos, state);
// Block.playerWillDestroy's destroy event (particles and the break
// sound), which BaseFireBlock skips: the server's extinguish sound
// is all a player hears.
let facts = self.res.facts().get(state);
if !facts.has_class("BaseFireBlock") {
let s = facts.sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&s.break_sound,
Some(at),
(s.volume + 1.0) / 2.0,
s.pitch * 0.8,
);
self.destroy_particles(pos, state);
}
// The server's block update follows (waterlogged blocks leave water).
let air = self.world.air;
if self.world.set(pos[0], pos[1], pos[2], air).is_ok() {
@@ -3940,15 +4056,11 @@ impl Game<'_> {
world_border: false,
sequence: seq,
}));
let target = [
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
];
let ticks = self.ticks;
// Only a predicted placement (predict_place) marks a cell whose
// resend plays a place sound: an empty hand or a non-block item
// places nothing (BlockItem.place is where the sound comes from).
if let Some(r) = self.remote.as_mut() {
r.use_target = Some(hit.pos);
r.placing = Some((target, ticks));
}
if reacts {
self.predict_use(&changes, seq);
@@ -4515,6 +4627,7 @@ impl Game<'_> {
}
KeyCode::KeyE => {
// The inventory opens without telling the server.
self.recipe_ui.reset_screen();
self.screen = if self.creative {
Screen::Creative
} else {
@@ -4781,6 +4894,7 @@ impl Game<'_> {
self.remote_close_local();
}
fn remote_close_local(&mut self) {
self.recipe_ui.reset_screen();
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
@@ -4816,16 +4930,20 @@ impl Game<'_> {
if self.open_menu_name(p.container) != "minecraft:merchant" {
return;
}
let cost = |g: &Self, c: &play::NetCost| horizon_client::merchant::ItemCost {
stack: ItemStack::new(&g.item_name(c.item), c.count.max(0) as u32),
let cost = |g: &Self, c: &play::NetCost| {
g.to_stack(&Some(c.clone().into()))
.map(|stack| horizon_client::merchant::ItemCost { stack })
};
let offers: Vec<_> = p
.offers
.iter()
.filter_map(|o| {
let mut offer = horizon_client::merchant::MerchantOffer {
base_cost_a: cost(self, &o.cost_a),
cost_b: o.cost_b.as_ref().map(|b| cost(self, b)),
base_cost_a: cost(self, &o.cost_a)?,
cost_b: match &o.cost_b {
Some(b) => Some(cost(self, b)?),
None => None,
},
result: self.to_stack(&Some(o.result.clone()))?,
uses: o.uses,
max_uses: o.max_uses,
@@ -4851,6 +4969,9 @@ impl Game<'_> {
}
/// ClientboundOpenScreenPacket for the menus Horizon draws.
fn remote_open_screen(&mut self, p: play::OpenScreen) {
// A new RecipeBookComponent owns fresh ghost slots and an overlay,
// even when the server replaces a menu without a close packet.
self.recipe_ui.reset_screen();
let menu = self
.remote
.as_ref()
Loaded 100 of 469 files, more files were not shown because too many files have changed in this diff. Show more