Merge origin/main into fix/witch-drinking

Keeps main's sitting/ridden fields on Seen beside this branch's effects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-26 19:19:22 +01:00
commit f8415efe29
420 files changed
+13936 -18050

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
+9
View File
@@ -1368,6 +1368,7 @@ dependencies = [
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1426,6 +1427,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", "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.
+3
View File
@@ -262,6 +262,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
Output::QuietCommands { lines, .. } => {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
+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());
@@ -1091,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.
@@ -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)]
+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),
+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)
);
}
}
+117 -3
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,9 +94,66 @@ 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),
}
/// `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
/// sound and `custom_name`.
@@ -304,6 +372,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 +412,7 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::Campfire(_) => "minecraft:campfire",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -362,6 +434,7 @@ impl BlockEntity {
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>] {
@@ -382,7 +455,9 @@ impl BlockEntity {
| 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>] {
@@ -403,7 +478,8 @@ impl BlockEntity {
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &mut [],
| Self::CommandBlock(_)
| Self::Campfire(_) => &mut [],
}
}
}
@@ -638,7 +714,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 +771,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
}
+51
View File
@@ -591,6 +591,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 {
+74 -1
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::{
@@ -1313,6 +1313,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();
+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);
+343 -93
View File
@@ -18,6 +18,8 @@ mod boats;
mod books;
mod bossbars;
mod breezes;
mod campfire;
mod campfire_douse;
mod cartography;
mod chat;
mod client_ambient;
@@ -33,6 +35,7 @@ mod copper_golems;
mod creaking;
mod creatures;
mod crossbow;
pub(crate) mod cushions;
mod debug_keys;
mod decorated_pot;
mod dedicated;
@@ -80,6 +83,7 @@ mod lan_mobs;
mod lan_mount_menu;
mod lan_particles;
mod lan_pvp;
mod lan_quiet;
mod lan_screen;
mod lan_stations;
mod lan_trading;
@@ -147,6 +151,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_mods;
mod server_packs;
mod server_travel;
mod shader_packs;
@@ -552,6 +557,11 @@ struct GameLevel<'w> {
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
registry: &'static horizon_world::registry::BlockRegistry,
rules: horizon_world::level::GameRules,
/// `Difficulty.getId()` (peaceful 0 .. hard 3).
difficulty: i32,
/// The feet of the players who keep fire spreading around them: the local
/// player and the LAN guests in this level, spectators aside.
fire_players: Vec<[f64; 3]>,
/// Block loot random sequences (`ServerLevel.getRandomSequence`).
rand_sequences: &'w mut horizon_client::random_sequences::RandomSequences,
pending: &'w mut spill::Pending,
@@ -561,6 +571,9 @@ struct GameLevel<'w> {
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
/// Players with each chest open (a trapped chest's signal).
lid_openers: &'w HashMap<(i32, i32, i32), i32>,
/// `Level.isClientSide`: a client's copy of the level (a guest of a LAN
/// world, a window on its server thread, a dedicated server's client).
client_side: bool,
}
struct PlayerCollision {
y: f64,
@@ -650,6 +663,9 @@ impl GameLevel<'_> {
}
}
impl LevelAccess for GameLevel<'_> {
fn is_client_side(&self) -> bool {
self.client_side
}
fn world_surface(&self, x: i32, z: i32) -> i32 {
self.world.world_surface(x, z, |s| self.facts.is_air(s))
}
@@ -715,6 +731,34 @@ impl LevelAccess for GameLevel<'_> {
fn game_rules(&self) -> horizon_world::level::GameRules {
self.rules
}
fn difficulty(&self) -> i32 {
self.difficulty
}
/// `ServerLevel.canSpreadFireAround`: a player (not a spectator) closer
/// than the `fire_spread_radius_around_player` rule to the block's corner
/// (`ChunkMap.anyPlayerCloseEnoughTo`); -1 spreads everywhere.
fn can_spread_fire_around(&self, pos: BlockPos) -> bool {
let radius = self.rules.fire_spread_radius_around_player;
radius == -1
|| self.fire_players.iter().any(|p| {
let d = [
p[0] - f64::from(pos.x),
p[1] - f64::from(pos.y),
p[2] - f64::from(pos.z),
];
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < f64::from(radius) * f64::from(radius)
})
}
fn increased_fire_burnout(&self, pos: BlockPos) -> bool {
self.world
.chunks
.get(&(pos.x >> 4, pos.z >> 4))
.is_some_and(|chunk| {
self.biomes
.get(chunk.biome(pos.x, pos.y, pos.z))
.increased_fire_burnout
})
}
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.world.block(pos.x, pos.y, pos.z)
}
@@ -853,7 +897,16 @@ impl LevelAccess for GameLevel<'_> {
fn emit(&mut self, event: LevelEvent) {
match event {
// Block.dropResources draws its loot and spots right away.
LevelEvent::DropResources { pos, state } => self.drop_resources_now(pos, state),
// `popResource` only drops server-side (`!level.isClientSide`):
// a client's moveBlocks (a piston's block event) skips it.
LevelEvent::DropResources { pos, state } if !self.client_side => {
self.drop_resources_now(pos, state)
}
LevelEvent::DropResources { .. } => {}
// `ClientLevel.playSeededSound` plays only when the excluded
// player is this client's own: the block's `playSound(null, ...)`
// (a piston's move, a note) reaches it from the server instead.
LevelEvent::Sound { .. } if self.client_side => {}
// Block.popResource of the block's own item (tall flowers, flower beds).
LevelEvent::BlockEvent {
pos,
@@ -1266,6 +1319,13 @@ struct Game<'a> {
/// Controllers (Horizon's own).
pad: pad::Pads,
frames: u64,
/// `item_level` for one frame, game time, day time and dimension.
item_level_cache: std::cell::RefCell<
Option<(
(u64, i64, i64, &'static str),
horizon_assets::item_model::LevelState,
)>,
>,
/// Frames drawn with the player in the world, for `--world-frames`.
world_frames: u64,
fps: f32,
@@ -1429,6 +1489,8 @@ struct Game<'a> {
map_frame_model: frames::FrameModel,
glow_map_frame_model: frames::FrameModel,
armor_stands: Vec<horizon_client::entities::ArmorStand>,
/// `Cushion` decoration entities (game/cushions.rs).
cushions: Vec<horizon_client::entities::Cushion>,
/// Entities of types Horizon does not simulate.
others: Vec<commands::OtherEntity>,
armor_stand_assets: armor_stands::ArmorStandAssets,
@@ -1444,6 +1506,8 @@ struct Game<'a> {
boat_models: boats::BoatModels,
/// EndCrystalModel's baked layer.
crystal_model: Option<horizon_client::mobs::Part>,
/// CushionModel's baked layer.
cushion_model: Option<horizon_client::mobs::Part>,
carts: Vec<minecarts::Minecart>,
/// Songs playing from jukeboxes (tracked sound handles).
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
@@ -1580,6 +1644,8 @@ struct Game<'a> {
/// position) for the guests.
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
/// Successful crits (attacking guest, hit entity), queued while the hub is taken.
lan_critical_hits: Vec<(Option<u32>, i32)>,
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
lan_global_events: Vec<(i32, [i32; 3], i32)>,
/// `ContainerOpenersCounter`: how many players (the host and the guests)
@@ -2452,6 +2518,19 @@ impl Game<'_> {
Dim::End => LevelDimension::End,
};
let rules = self.level_rules();
let fire_players: Vec<[f64; 3]> = self
.player
.iter()
.filter(|_| !spectator)
.map(|p| [p.position.x, p.position.y, p.position.z])
.chain(self.lan.iter().flat_map(|h| {
h.hub
.guests
.iter()
.filter(|g| h.hub.here(g) && !g.spectator())
.map(|g| g.position)
}))
.collect();
let blockers = Blockers {
mobs: &self.mobs,
armor_stands: &self.armor_stands,
@@ -2482,12 +2561,15 @@ impl Game<'_> {
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
registry: self.pack.assets.registry,
rules,
difficulty: i32::from(self.ui.difficulty),
fire_players,
rand_sequences: &mut self.rand_sequences,
pending: &mut self.pending,
spill: &mut self.spill,
tickets: self.tickets.levels(),
random_tick_sections: &mut self.random_tick_sections,
lid_openers: &self.lid_openers,
client_side: self.remote.is_some(),
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
@@ -2549,14 +2631,15 @@ impl Game<'_> {
(p.position.z.floor() as i32) >> 4,
));
}
let sources = (self.options.dedicated.is_some() || !players.is_empty()).then(|| {
horizon_client::ticket_levels::Sources {
players,
forced: self.streaming.forced.iter().copied().collect(),
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.simulation_distance.max(2),
}
});
let sources =
(!self.options.execution_role.is_client() || !players.is_empty()).then(|| {
horizon_client::ticket_levels::Sources {
players,
forced: self.streaming.forced.iter().copied().collect(),
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.simulation_distance.max(2),
}
});
self.tickets.update(sources);
}
/// `ServerLevel.startTickingChunk`: a loaded chunk's saved ticks are
@@ -2785,6 +2868,9 @@ impl Game<'_> {
pos,
data,
} => self.consume_teleport_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent { id: 2003, pos, .. } => {
self.eye_break_particles([pos.x, pos.y, pos.z]);
}
LevelEvent::LevelEvent {
id: 2013,
pos,
@@ -3333,9 +3419,17 @@ impl Game<'_> {
self.with_player_actor(|game| game.break_block_as_player(pos))
}
fn break_block_as_player(&mut self, pos: [i32; 3]) -> Result<()> {
if !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
return Ok(());
}
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
// ServerPlayerGameMode.destroyBlock.
let state = self.world.block(pos[0], pos[1], pos[2]);
let broken = state;
let facts = self.res.facts().get(state).clone();
let state = self.crack_mined_pot(pos, state);
// Block.playerWillDestroy: mining their gold angers all nearby piglins.
@@ -3367,6 +3461,9 @@ impl Game<'_> {
creative,
)
});
if removed {
self.mod_block_event(pos, broken, false);
}
if removed && !creative {
let held = self.held().cloned();
self.mine_block_with_held(&facts.name, facts.hardness);
@@ -3475,6 +3572,7 @@ impl Game<'_> {
fn interact_entity(&mut self) -> Result<bool> {
Ok(self.interact_frame()
|| self.interact_armor_stand()
|| self.interact_cushion()
|| self.interact_boat()
|| self.interact_cart()
|| self.interact_mob()?
@@ -3536,6 +3634,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_campfire_douse(hit.pos, held.as_ref())? {
self.swing_arm();
return Ok(true);
}
match self.use_respawn_anchor(hit.pos, held.as_ref())? {
respawn_anchor::AnchorUse::Charged => {
self.swing_arm();
@@ -3661,6 +3763,7 @@ impl Game<'_> {
|| self.place_painting(hit, &stack)
|| self.place_item_frame(hit, &stack)
|| self.place_armor_stand(hit, &stack)
|| self.place_cushion(hit, &stack)
|| self.place_end_crystal(hit, &stack))
{
return Ok(true);
@@ -3709,6 +3812,7 @@ impl Game<'_> {
return Ok(true);
};
let target = [pos.x, pos.y, pos.z];
self.mod_block_event(target, state, true);
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
let key = (target[0], target[1], target[2]);
self.world.block_entities.insert(key, entity);
@@ -3951,7 +4055,12 @@ impl Game<'_> {
return Ok(());
};
// GameType.isBlockPlacingRestricted: adventure and spectator.
if !self.may_build() {
if !self.may_build()
|| !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
self.mining = None;
return Ok(());
}
@@ -4225,8 +4334,9 @@ impl Game<'_> {
if running {
self.world.game_time += 1;
if self.ui.rules.bool("advance_weather") {
self.world.weather.tick(&mut self.world.random);
self.world.weather.tick_timers(&mut self.world.random);
}
self.world.weather.tick_levels();
self.tick_weather_effects();
}
if self.ticks.is_multiple_of(6000) {
@@ -4242,6 +4352,7 @@ impl Game<'_> {
self.update_totem_ready();
self.tick_command_extras();
self.tick_mods();
self.tick_server_mods();
let camera_eye = self.camera_eye_height();
let stick = self.pad_move();
if self.riding.is_some() && self.player.is_some() {
@@ -4544,6 +4655,7 @@ impl Game<'_> {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands());
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
@@ -4752,7 +4864,10 @@ impl Game<'_> {
if capture && !self.focused {
return;
}
if !capture {
if capture {
// The inventory's screens are behind us: no drag survives them.
self.reset_inventory_clicks();
} else {
self.stop_mining();
}
// The real pointer takes over from an agent's.
@@ -5278,7 +5393,8 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::Skull(_),
| BlockEntity::Skull(_)
| BlockEntity::Campfire(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
@@ -5548,7 +5664,8 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::Skull(_),
| BlockEntity::Skull(_)
| BlockEntity::Campfire(_),
) => {}
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
None => {
@@ -5591,7 +5708,18 @@ impl Game<'_> {
self.draw_inventory_effects(batch, (ox, oy), 176);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen; Horizon keeps them on the game, so they are
/// dropped when a screen closes. A press whose release never reaches the
/// screen (the pointer left the window, or the screen closed first) would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks = Default::default();
self.inv_drag = None;
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
self.reset_inventory_clicks();
self.close_station();
self.mount_closed();
self.recipe_ui.reset_screen();
@@ -5687,8 +5815,9 @@ impl Game<'_> {
if self.creative_screen.tab().search {
// CreativeModeInventoryScreen.slotClicked: moveCursorToEnd, then
// setHighlightPos(0), so typing replaces the whole query.
let search = &self.creative_screen.search;
self.text_selection.select(("creative.search", 0), search);
let search = &mut self.creative_screen.search;
search.move_cursor_to_end(false);
search.highlight = 0;
}
let items = self.creative_screen.items(&self.pack.names);
let first = self.creative_screen.first_row(&self.pack.names);
@@ -6330,6 +6459,13 @@ impl Game<'_> {
false,
);
if outside {
// AbstractRecipeBookScreen.mouseClicked: the book takes the click
// before the outside test, and its tabs sit left of the strip
// hasClickedOutside counts as the book.
if self.recipe_book_click(right) {
self.inv_clicks.skip_release();
return;
}
// slotId -999: an empty hand throws nothing; a carried stack
// starts a drag whose release drops it.
match input {
@@ -6895,6 +7031,9 @@ impl Game<'_> {
self.prepare_entity_culling(camera);
self.prepare_trims();
self.mark_entity(out.len(), "minecraft:entity_flame");
// One scan for both passes that draw them (block entities below and the
// overlay near the end of this function).
let cracks = self.cracks(camera);
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
self.yaw + 180.0
} else {
@@ -6991,6 +7130,9 @@ impl Game<'_> {
// `VaultRenderer`.
self.mark_entity(out.len(), "");
self.vault_geometry(camera, alpha, &mut out);
// `CampfireRenderer`.
self.mark_entity(out.len(), "");
self.campfire_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
// Mobs and their projectiles (a span each).
@@ -7120,7 +7262,7 @@ impl Game<'_> {
.render(&scene, visible, &mut out);
}
self.mark_stage(out.len(), "DESTROY");
for (pos, stage) in self.cracks(camera) {
for &(pos, stage) in &cracks {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.res
.block_entity_render()
@@ -7233,6 +7375,7 @@ impl Game<'_> {
self.frame_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:armor_stand");
self.armor_stand_geometry(camera, alpha, &mut out);
self.cushion_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:oak_boat");
self.boat_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:minecart");
@@ -7263,7 +7406,7 @@ impl Game<'_> {
);
// Breaking progress overlays (destroy_stage_0..9): ours and the other players'.
self.mark_stage(out.len(), "DESTROY");
for (pos, stage) in self.cracks(camera) {
for &(pos, stage) in &cracks {
let o = (glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- camera)
.as_vec3();
@@ -7311,6 +7454,20 @@ impl Game<'_> {
/// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the
/// furthest progress on each block).
fn cracks(&self, camera: glam::DVec3) -> Vec<([i32; 3], [f32; 2])> {
// `destroy_stage_0` .. `destroy_stage_9`: the sprite name by stage, so the
// lookup does not format a string per crack.
const DESTROY_STAGES: [&str; 10] = [
"block/destroy_stage_0",
"block/destroy_stage_1",
"block/destroy_stage_2",
"block/destroy_stage_3",
"block/destroy_stage_4",
"block/destroy_stage_5",
"block/destroy_stage_6",
"block/destroy_stage_7",
"block/destroy_stage_8",
"block/destroy_stage_9",
];
let mut stages: Vec<([i32; 3], u32)> = Vec::new();
let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) {
Some(c) => c.1 = c.1.max(stage),
@@ -7336,11 +7493,8 @@ impl Game<'_> {
d.length_squared() <= 1024.0
})
.filter_map(|(pos, stage)| {
let r = self
.gui
.atlas
.sprites
.get(&format!("block/destroy_stage_{stage}"))?;
let name = DESTROY_STAGES.get(stage as usize)?;
let r = self.gui.atlas.sprites.get(*name)?;
Some((pos, [r.u0, r.v0]))
})
.collect()
@@ -7518,6 +7672,7 @@ impl Game<'_> {
.map_or(0.0, |i| i.attack_damage);
if self.attack_frame()
|| self.attack_armor_stand()
|| self.attack_cushion()
|| self.attack_boat(hand_damage)
|| self.attack_cart(hand_damage)
|| self.attack_dragon()
@@ -7605,10 +7760,9 @@ impl Game<'_> {
self.stats
.custom("damage_dealt", (actual * 10.0).round() as i32);
if crit {
self.particles.tracking_emitter(
horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Crit,
),
self.critical_hit(
self.lan_acting,
self.mobs[index].id as i32,
feet,
width,
height,
@@ -7990,6 +8144,7 @@ impl Game<'_> {
/// (scaled coordinates, or the End portal's fixed destination).
pub(super) fn switch_level(&mut self, target: Dim, from: Vec3) -> Result<Vec3> {
// Save the departing dimension's changed chunks.
self.save.save_forced_chunks(&self.streaming.forced)?;
self.streaming.save(&mut self.world, true);
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
@@ -8263,6 +8418,7 @@ impl Game<'_> {
self.boats.clear();
self.carts.clear();
self.armor_stands.clear();
self.cushions.clear();
self.item_frames.clear();
self.paintings.clear();
self.others.clear();
@@ -8681,6 +8837,13 @@ impl Game<'_> {
{
return Ok(UseResult::Success { swing: true });
}
if allow_block_actions
&& let Some(hit) = self.pick()
&& let Some(placed) = self.use_on_campfire(hit.pos)?
{
// SUCCESS_SERVER swings; a full campfire's CONSUME does not.
return Ok(UseResult::Success { swing: placed });
}
// BucketItem has no useOn: a guest's UseItemOn leaves it to UseItem.
let bucket = horizon_client::items::bucket_contents(&stack.item);
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
@@ -9579,8 +9742,9 @@ impl Game<'_> {
rain.extend(snow_vertices);
rain
}
/// Player.startSleepInBed: night or thunder, no monsters within 8 blocks;
/// the bed becomes the respawn point either way.
/// ServerPlayer.startSleepInBed: in reach, not obstructed, night or thunder,
/// and (outside creative) no monsters within 8 blocks; the bed becomes the
/// respawn point either way.
fn try_sleep(&mut self, pos: [i32; 3]) {
let Some(p) = self.player.as_ref() else {
return;
@@ -9595,22 +9759,40 @@ impl Game<'_> {
.state(state)
.map(|s| s.properties.clone())
.unwrap_or_default();
let head = if props.get("part").map(String::as_str) == Some("foot") {
let d = facing_offset(props.get("facing").map_or("north", String::as_str));
[pos[0] + d[0], pos[1], pos[2] + d[2]]
// The head and the foot: the block faces from the foot to the head.
let d = facing_offset(props.get("facing").map_or("north", String::as_str));
let (head, foot) = if props.get("part").map(String::as_str) == Some("foot") {
([pos[0] + d[0], pos[1], pos[2] + d[2]], pos)
} else {
pos
(pos, [pos[0] - d[0], pos[1], pos[2] - d[2]])
};
let dist = ((p.position.x - f64::from(head[0]) - 0.5).powi(2)
+ (p.position.z - f64::from(head[2]) - 0.5).powi(2))
.sqrt();
if dist > 3.0 {
// ServerPlayer.bedInRange / isReachableBedBlock: within 3 blocks
// sideways and 2 vertically of the head or the foot.
let reachable = |bed: [i32; 3]| {
(p.position.x - f64::from(bed[0]) - 0.5).abs() <= 3.0
&& (p.position.y - f64::from(bed[1])).abs() <= 2.0
&& (p.position.z - f64::from(bed[2]) - 0.5).abs() <= 3.0
};
if !reachable(head) && !reachable(foot) {
self.message = Some((
"You may not rest now; the bed is too far away".into(),
Instant::now(),
));
return;
}
// ServerPlayer.bedBlocked: Player.freeAt (not suffocating) for the
// blocks above the head and the foot, as `in_wall` reads suffocating.
let blocked = [head, foot].iter().any(|bed| {
let f = self
.res
.facts()
.get(self.world.block(bed[0], bed[1] + 1, bed[2]));
f.full_collision && f.solid_render
});
if blocked {
self.message = Some(("This bed is obstructed".into(), Instant::now()));
return;
}
// In reach (bedInRange), day or night: setRespawnPosition(
// RespawnConfig(the head, the player's yaw), not forced). It replaces
// an anchor or a spawnpoint; respawning checks the bed is still there
@@ -9640,13 +9822,15 @@ impl Game<'_> {
));
return;
}
let monsters = self.mobs.iter().any(|m| {
m.hostile()
&& m.alive()
&& (m.position.x - p.position.x).abs() < 8.0
&& (m.position.y - p.position.y).abs() < 5.0
&& (m.position.z - p.position.z).abs() < 8.0
});
// Creative players skip the monster check (`!this.isCreative()`).
let monsters = !self.creative
&& self.mobs.iter().any(|m| {
m.hostile()
&& m.alive()
&& (m.position.x - p.position.x).abs() < 8.0
&& (m.position.y - p.position.y).abs() < 5.0
&& (m.position.z - p.position.z).abs() < 8.0
});
if monsters {
self.message = Some((
"You may not rest now; there are monsters nearby".into(),
@@ -9696,14 +9880,20 @@ impl Game<'_> {
}
}
// ServerLevel.tick: once enough of the players have slept 100 ticks,
// skip to morning, clear the weather and wake everyone.
// skip to morning if `advance_time` is on, wake everyone, and
// resetWeatherCycle if `advance_weather` is on and it is raining.
if self.enough_slept() {
let t = self.world.day_time;
self.world.day_time = t + 24000 - t.rem_euclid(24000);
self.world.weather.raining = false;
self.world.weather.thundering = false;
self.world.weather.rain_time = 0;
self.world.weather.thunder_time = 0;
if self.ui.rules.bool("advance_time") {
let t = self.world.day_time;
self.world.day_time = t + 24000 - t.rem_euclid(24000);
}
// Level.isRaining: the rain level is above 0.2.
if self.ui.rules.bool("advance_weather") && self.world.weather.levels(1.0).0 > 0.2 {
self.world.weather.raining = false;
self.world.weather.thundering = false;
self.world.weather.rain_time = 0;
self.world.weather.thunder_time = 0;
}
if self.sleeping.is_some() {
self.wake_up();
}
@@ -9731,8 +9921,9 @@ impl Game<'_> {
fn draw_creative(&mut self, batch: &mut HudBatch) {
let (ox, oy) = Self::creative_origin(batch);
let selected = self.creative_screen.selected;
let tabs = self.creative_screen.tabs.clone();
let tab = self.creative_screen.tab().clone();
// Only what is drawn of the tabs: their stacks stay where they are.
let tabs = self.creative_screen.tab_headers();
let tab = tabs[selected.min(tabs.len() - 1)].clone();
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
format!(
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
@@ -9798,20 +9989,16 @@ impl Game<'_> {
);
}
if tab.search {
// The search box: an unbordered EditBox of 80 at (82, 6), white.
let search = &self.creative_screen.search;
horizon_client::ui::append_field(
batch,
atlas,
search,
(ox + 82.0, oy + 6.0, 80.0),
(u32::MAX, true),
clipboard::CREATIVE_SEARCH_CHARS,
self.text_selection
.covers(clipboard::CREATIVE_SEARCH, search),
true,
self.creative_screen.search_focused.elapsed().as_millis(),
);
// CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6).
let search = &mut self.creative_screen.search;
search.x = ox + 82.0;
search.y = oy + 6.0;
search.width = 80.0;
search.bordered = false;
search.focused = true;
search.text_color = u32::MAX;
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
search.draw(batch, atlas, None);
}
let mut hovered: Option<ItemStack> = None;
if tab.inventory {
@@ -9919,8 +10106,7 @@ impl Game<'_> {
} else if self.carried.is_none()
&& let Some(item) = hovered
{
let lines = self.stack_tooltip(&item);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1);
}
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
}
@@ -9973,6 +10159,9 @@ impl Game<'_> {
}
return;
}
if !right && self.creative_search_click(ox, oy) {
return;
}
// Holding the scrollbar scrolls as the mouse moves (mouseDragged).
if !right
&& self
@@ -9985,6 +10174,24 @@ impl Game<'_> {
self.inventory_press(right);
}
}
/// A click on the search tab's box (`EditBox.onClick`).
fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool {
if !self.creative_screen.tab().search {
return false;
}
let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0);
let (mx, my) = self.cursor;
if mx < x || my < y || mx >= x + w || my >= y + h {
return false;
}
let shift = self.has_shift_down();
let search = &mut self.creative_screen.search;
search.x = x;
search.y = y;
search.width = w;
search.click(&self.gui.atlas, mx, shift, false);
true
}
/// Copy the live player and world state into the `Level` that gets saved.
pub(super) fn sync_level(&mut self) {
// Level.write saves the game rules (game_rules.dat) with the rest.
@@ -10104,6 +10311,7 @@ impl Game<'_> {
}
/// Save the level in place: its changed chunks, its entities and its raids.
fn save_level(&mut self, wait: bool) -> Result<()> {
self.save.save_forced_chunks(&self.streaming.forced)?;
self.streaming.save(&mut self.world, wait);
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
@@ -10303,8 +10511,6 @@ impl Game<'_> {
Vec::new()
};
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
// Map textures changed since the last drawn frame (game/maps.rs).
hud.patches.append(&mut self.map_patches);
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
let title = self.title.is_some()
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
@@ -10406,6 +10612,10 @@ impl Game<'_> {
} else {
self.shader_pack_inputs(camera, alpha as f32)
};
// Map textures and font glyphs changed while building this frame's
// geometry (game/maps.rs, world text) upload with this batch; draining
// map_patches any earlier leaves them a frame behind.
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let frame = Frame {
weather: &weather,
@@ -11492,16 +11702,17 @@ impl ApplicationHandler for Game<'_> {
}
self.extras_mouse(button, pressed);
self.mod_mouse_key(button, pressed);
match self.settings.keys.translate_mouse(button) {
Some(Err(b)) => self.play_mouse(b, pressed)?,
Some(Ok(k)) if pressed => {
self.keys.insert(k);
self.key_pressed(k)?;
for mapped in self.settings.keys.translate_mouse_all(button) {
match mapped {
Err(b) => self.play_mouse(b, pressed)?,
Ok(k) if pressed => {
self.keys.insert(k);
self.key_pressed(k)?;
}
Ok(k) => {
self.keys.remove(&k);
}
}
Some(Ok(k)) => {
self.keys.remove(&k);
}
None => {}
}
}
Screen::Inventory | Screen::Creative
@@ -11628,8 +11839,17 @@ impl ApplicationHandler for Game<'_> {
// The hovered slot took the key (`ignoreTextInput`).
} else if let Some(shortcut) = self.edit_shortcut(&event) {
self.creative_search_shortcut(shortcut);
} else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) {
self.creative_search_backspace(self.has_control_down());
} else if let PhysicalKey::Code(key) = event.physical_key {
// EditBox.keyPressed: arrows, Home, End, Delete and Backspace.
let before = self.creative_screen.search.value.clone();
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
if self.creative_screen.search.key(key, ctrl, shift) {
if self.creative_screen.search.value != before {
self.creative_screen.scroll = 0.0;
}
} else {
self.creative_search_edit(event.text.as_deref());
}
} else {
self.creative_search_edit(event.text.as_deref());
}
@@ -11650,6 +11870,9 @@ impl ApplicationHandler for Game<'_> {
// The anvil rename box takes every key but Escape (AnvilScreen.keyPressed).
if let Some(shortcut) = self.edit_shortcut(&event) {
self.station_shortcut(shortcut);
} else if let PhysicalKey::Code(key) = event.physical_key
&& self.station_key(key)
{
} else {
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
self.station_text(event.text.as_deref(), backspace);
@@ -11737,18 +11960,24 @@ impl ApplicationHandler for Game<'_> {
{
self.smooth_camera = !self.smooth_camera;
}
// Key binds: act as the default key of the bound mapping.
let key = match self.settings.keys.translate(key) {
Some(Ok(k)) => k,
Some(Err(button)) => {
if self.screen == Screen::Playing && !event.repeat {
self.play_mouse(button, event.state == ElementState::Pressed)?;
// Key binds: act as the default key of every mapping bound
// to it (KeyMapping.set drives them all).
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(
k,
event.state == ElementState::Pressed,
event.repeat,
)?;
}
Err(button) => {
if self.screen == Screen::Playing && !event.repeat {
self.play_mouse(button, event.state == ElementState::Pressed)?;
}
}
return Ok(());
}
None => return Ok(()),
};
self.mapped_key(key, event.state == ElementState::Pressed, event.repeat)?;
}
}
WindowEvent::RedrawRequested => {
self.observe_fullscreen();
@@ -12162,6 +12391,7 @@ impl<'a> Game<'a> {
last_input: now,
pad: Default::default(),
frames: 0,
item_level_cache: Default::default(),
world_frames: 0,
fps: 0.0,
fps_frames: 0,
@@ -12266,6 +12496,7 @@ impl<'a> Game<'a> {
map_frame_model: prepared.map_frame_model,
glow_map_frame_model: prepared.glow_map_frame_model,
armor_stands: Vec::new(),
cushions: Vec::new(),
others: Vec::new(),
armor_stand_assets: prepared.armor_stand_assets,
waypoint_styles: prepared.waypoint_styles,
@@ -12275,6 +12506,7 @@ impl<'a> Game<'a> {
boats: Vec::new(),
boat_models: prepared.boat_models,
crystal_model: prepared.crystal_model,
cushion_model: prepared.cushion_model,
carts: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
@@ -12361,6 +12593,7 @@ impl<'a> Game<'a> {
lan_events: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
lan_global_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
@@ -12412,6 +12645,7 @@ struct GameCpuResources {
boat_models: boats::BoatModels,
cart_models: minecarts::CartModels,
crystal_model: Option<horizon_client::mobs::Part>,
cushion_model: Option<horizon_client::mobs::Part>,
dragon_model: Option<horizon_client::mobs::Part>,
arrow_model: Option<horizon_client::mobs::Part>,
trident_model: Option<horizon_client::mobs::Part>,
@@ -12432,6 +12666,7 @@ impl GameCpuResources {
boat_models: boats::BoatModels::load(document.as_ref()),
cart_models: minecarts::CartModels::load(document.as_ref()),
crystal_model: model("minecraft:end_crystal#main"),
cushion_model: model("minecraft:cushion#main"),
dragon_model: model("minecraft:ender_dragon#main"),
arrow_model: model("minecraft:arrow#main"),
trident_model: model("minecraft:trident#main"),
@@ -12991,6 +13226,14 @@ fn build_renderer(
) -> Result<Renderer> {
let assets = &pack.render;
let atlas = &gui.atlas;
// The new renderer's atlas texture starts from `HudAtlas.pixels`, which
// holds the textures but never a glyph rasterized at run time. The font's
// cache still answers with the rects it drew into the previous renderer's
// texture, so forget them: the glyphs that are drawn again are placed into
// this texture (as `ui_theme` does when its atlas changes). Without this,
// Chinese and Japanese menu labels go blank when the loading screen hands
// over to the game.
atlas.font.reset_cache();
let mut renderer = pollster::block_on(Renderer::new(
window,
offscreen,
@@ -13037,6 +13280,12 @@ fn loading_renderer(
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
None => (EMPTY_HUD.as_slice(), 1, 1),
};
// A glyph rasterized into an earlier texture is not in these pixels; the
// new texture gets it again when the character is drawn (see
// `build_renderer`).
if let Some(atlas) = atlas {
atlas.font.reset_cache();
}
let mut renderer = pollster::block_on(Renderer::new(
window,
offscreen,
@@ -13773,7 +14022,7 @@ mod startup_cpu_tests {
r#"{
"minecraft:end_crystal#main": {}, "minecraft:ender_dragon#main": {},
"minecraft:arrow#main": {}, "minecraft:trident#main": {},
"minecraft:armor_stand#main": {}
"minecraft:armor_stand#main": {}, "minecraft:cushion#main": {}
}"#,
)
.unwrap();
@@ -13793,6 +14042,7 @@ mod startup_cpu_tests {
.unwrap();
std::fs::remove_dir_all(&root).unwrap();
assert!(prepared.crystal_model.is_some());
assert!(prepared.cushion_model.is_some());
assert!(prepared.dragon_model.is_some());
assert!(prepared.arrow_model.is_some());
assert!(prepared.trident_model.is_some());
+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(
@@ -56,6 +56,7 @@ 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::Campfire(_)) => self.tick_campfire(pos)?,
_ => {}
}
}
@@ -92,7 +93,10 @@ impl Game<'_> {
| "minecraft:vault"
| "minecraft:beehive"
| "minecraft:bee_nest"
| "minecraft:brewing_stand" => Ticking::Yes,
| "minecraft:brewing_stand"
// Lit, cookTick; unlit, cooldownTick.
| "minecraft:campfire"
| "minecraft:soul_campfire" => Ticking::Yes,
"minecraft:jukebox" => {
let record = registry
.state(state)
+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);
+8 -3
View File
@@ -52,11 +52,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]) {
+95 -37
View File
@@ -85,16 +85,16 @@ fn format_double(v: f64) -> String {
impl Game<'_> {
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) {
pub(super) fn run_command(&mut self, command: &str) -> bool {
if self.mod_command(command) {
return;
return true;
}
let d = vanilla::game_dispatcher();
let parse = d.parse(command, &self.ui.registries);
let result = match parse.executable() {
Err(e) => {
self.syntax_failure(&e, command);
return;
return false;
}
Ok((ctx, _)) => {
let ctx = ctx.clone();
@@ -103,6 +103,7 @@ impl Game<'_> {
self.run_chain(&root, &ctx)
}
};
let ok = result.is_ok();
if let Err(e) = result {
if e.input.is_some() {
self.syntax_failure(&e, command);
@@ -114,6 +115,20 @@ impl Game<'_> {
if self.player.is_some() {
self.check_inventory_trigger();
}
ok
}
/// `run_command` with its output suppressed
/// (`CommandSourceStack.withSuppressedOutput`): the player's own source,
/// rights and checks, no feedback and no failure in the chat.
pub(super) fn run_command_quietly(&mut self, command: &str) -> bool {
let quiet = execute::Source {
silent: true,
..self.player_source()
};
let outer = self.command_source.replace(quiet);
let ok = self.run_command(command);
self.command_source = outer;
ok
}
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
self.command_failure(e.message.clone());
@@ -1870,11 +1885,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);
@@ -1882,6 +1898,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")?;
@@ -2578,38 +2667,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 {
+34 -11
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])> {
@@ -374,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();
@@ -841,6 +842,7 @@ impl Game<'_> {
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) => {
@@ -1066,7 +1068,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,
@@ -1088,6 +1093,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]);
@@ -1121,7 +1129,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
@@ -1265,6 +1273,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,
@@ -1358,7 +1373,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 {
@@ -1379,7 +1394,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 {
@@ -1401,7 +1416,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.
@@ -1417,7 +1432,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
@@ -1513,15 +1528,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;
@@ -1540,7 +1559,10 @@ impl Game<'_> {
}
}
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);
@@ -3295,6 +3317,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.
+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;
+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 {
+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
+3 -1
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;
@@ -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).
+1 -1
View File
@@ -231,7 +231,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(
+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);
+115 -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;
@@ -71,6 +71,8 @@ pub(super) struct LanHost {
/// Guests' commands waiting for the hub to be back in `self.lan`, where
/// a command can reach every player (game/lan_actions.rs).
pub commands: Vec<super::lan_actions::GuestCommand>,
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
/// The entities the guests track, described again every tick
/// (`lan_entities`) over last tick's states.
states: lan::entity::States,
@@ -145,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)>,
) {
@@ -194,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 };
@@ -596,6 +625,7 @@ impl Game<'_> {
waypoint_dead: Default::default(),
border_sent: None,
commands: Vec::new(),
quiet: std::collections::VecDeque::new(),
states: Default::default(),
});
self.audio.capture = Some(Vec::new());
@@ -634,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(
@@ -820,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,
});
@@ -835,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;
@@ -912,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 = {
@@ -960,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()
@@ -1029,6 +1071,14 @@ impl Game<'_> {
}
host.states = states;
}
if !host.quiet.is_empty() {
self.lan = Some(host);
self.run_quiet_commands();
host = self
.lan
.take()
.expect("the hub is back after the quiet commands");
}
self.lan_flush_effects(&mut host);
if !paused {
self.tick_guests(&mut host);
@@ -1134,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));
}
_ => {}
}
}
}
@@ -1162,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
@@ -1236,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.
@@ -1403,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),
+105 -37
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.
@@ -94,11 +98,14 @@ pub(super) struct GuestPlay {
/// A guest's command waiting for the hub (`LanHost::commands`).
pub(super) struct GuestCommand {
conn: u32,
command: String,
permission: u8,
pub(super) conn: u32,
pub(super) command: String,
pub(super) permission: u8,
/// An integrated server's: no level teleports (yet).
integrated: bool,
pub(super) integrated: bool,
/// A mod's quiet command (game/lan_quiet.rs): its output suppressed, as
/// `CommandSourceStack.withSuppressedOutput` does.
pub(super) quiet: bool,
}
/// The game's own state while a guest's is swapped in (`enter_guest`), and
@@ -872,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)
@@ -886,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 {
@@ -1210,6 +1250,9 @@ impl Game<'_> {
self.guest_carried_back(host, gi);
}
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
S::CustomPayload(p) if p.channel == horizon_protocol::quiet::CHANNEL => {
self.guest_quiet_commands(host, conn, &p.data)
}
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
@@ -1308,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 => {
@@ -1533,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
@@ -1559,6 +1613,7 @@ impl Game<'_> {
command: command.trim_start_matches('/').to_owned(),
permission,
integrated,
quiet: false,
});
}
@@ -1573,41 +1628,54 @@ impl Game<'_> {
return;
}
let c = host.commands.remove(0);
let mut host = self.lan.take().expect("checked above");
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
self.lan = Some(host);
continue;
};
self.lan = Some(host);
self.chat_capture = Some(Vec::new());
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
self.run_command(&c.command);
self.ui.registries.permission = level;
let lines = self.chat_capture.take().unwrap_or_default();
let mut host = self.lan.take().expect("a command puts the hub back");
self.leave_guest(&mut host, swap);
host.hub.sync_all_effects();
if !c.integrated {
// A teleport into another level happens before its feedback is sent.
self.run_level_teleports(&mut host);
}
// What the command earned is announced before its feedback.
self.announce_guest_advancements(&mut host);
for line in lines {
host.hub.send_to(
c.conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
// The other operators hear of it; never the player whose command it was.
self.send_admin_feedback(&mut host, Some(c.conn));
self.lan = Some(host);
self.run_guest_command(c);
}
}
/// One guest's command as that guest, its feedback sent to them; false
/// when it failed (or the guest is gone).
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
let Some(mut host) = self.lan.take() else {
return false;
};
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
self.lan = Some(host);
return false;
};
self.lan = Some(host);
self.chat_capture = Some(Vec::new());
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
let ok = if c.quiet {
self.run_command_quietly(&c.command)
} else {
self.run_command(&c.command)
};
self.ui.registries.permission = level;
let lines = self.chat_capture.take().unwrap_or_default();
let mut host = self.lan.take().expect("a command puts the hub back");
self.leave_guest(&mut host, swap);
host.hub.sync_all_effects();
if !c.integrated {
// A teleport into another level happens before its feedback is sent.
self.run_level_teleports(&mut host);
}
// What the command earned is announced before its feedback.
self.announce_guest_advancements(&mut host);
for line in lines {
host.hub.send_to(
c.conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
// The other operators hear of it; never the player whose command it was.
self.send_admin_feedback(&mut host, Some(c.conn));
self.lan = Some(host);
ok
}
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
/// whose it was; none for the host's or the console's): every other
/// operator gets them, and the console logs them as the server's own
+5 -1
View File
@@ -28,7 +28,11 @@ impl Game<'_> {
/// SignableCommand.of: the command's message arguments, by name, with
/// their text, not past a redirect to the root (`execute ... run`).
fn signable_arguments(&mut self, permission: u8, command: &str) -> Vec<(String, String)> {
pub(super) fn signable_arguments(
&mut self,
permission: u8,
command: &str,
) -> Vec<(String, String)> {
let parse = self.parse_as(permission, command);
let nodes = &vanilla::game_dispatcher().nodes;
let root = parse.context.root;
@@ -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());
}
}
@@ -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",
+165
View File
@@ -0,0 +1,165 @@
//! Quiet commands (`horizon_protocol::quiet`): a Horizon client's mod sends a
//! batch of command lines in one payload, and the server runs each exactly as
//! that player typing it would (`guest_command`'s checks, the player's own
//! permission level, at the time it runs) with its output suppressed, as
//! vanilla's functions run (`CommandSourceStack.withSuppressedOutput`): no
//! feedback and no failure in anyone's chat. A batch waits in a queue and runs
//! [`PER_TICK`] lines a player a tick, so a mod can't flood a server; each
//! finished batch is one line in the server's log.
use super::{Game, lan::LanHost, lan_actions::GuestCommand};
use horizon_protocol::quiet;
use std::collections::{HashMap, VecDeque};
/// Lines a player may have waiting at most; a batch past it loses the rest.
pub(super) const PENDING: usize = 4096;
/// Lines run a player a tick at most.
pub(super) const PER_TICK: usize = 64;
/// A batch waiting to run.
pub(super) struct QuietBatch {
conn: u32,
mod_id: String,
lines: VecDeque<String>,
ran: usize,
failed: usize,
/// Lines past [`PENDING`], never run.
dropped: usize,
}
impl Game<'_> {
/// A `horizon:quiet_commands` payload from `conn`: queued (or logged as
/// refused when it breaks the payload's caps).
pub(super) fn guest_quiet_commands(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
let Some(name) = host.hub.guest(conn).map(|g| g.name.clone()) else {
return;
};
let batch = match quiet::decode(data) {
Ok(b) => b,
Err(e) => {
super::dedicated::log(
"WARN",
&format!("{name} sent quiet commands that were refused: {e}"),
);
return;
}
};
let waiting: usize = host
.quiet
.iter()
.filter(|b| b.conn == conn)
.map(|b| b.lines.len())
.sum();
let room = PENDING.saturating_sub(waiting);
let dropped = batch.lines.len().saturating_sub(room);
host.quiet.push_back(QuietBatch {
conn,
mod_id: batch.mod_id,
lines: batch.lines.into_iter().take(room).collect(),
ran: 0,
failed: 0,
dropped,
});
}
/// The waiting batches, [`PER_TICK`] lines a player this tick, in order.
pub(super) fn run_quiet_commands(&mut self) {
let mut left: HashMap<u32, usize> = HashMap::new();
let mut i = 0;
loop {
let Some(host) = self.lan.as_mut() else {
return;
};
let Some(batch) = host.quiet.get_mut(i) else {
return;
};
let conn = batch.conn;
let budget = left.entry(conn).or_insert(PER_TICK);
let gone = host.hub.guest(conn).is_none();
if gone || batch.lines.is_empty() {
let batch = host.quiet.remove(i).expect("got it above");
if !gone {
let name = host
.hub
.guest(conn)
.map_or(String::new(), |g| g.name.clone());
super::dedicated::log("INFO", &summary(&name, &batch));
}
continue;
}
if *budget == 0 {
i += 1;
continue;
}
*budget -= 1;
let line = batch.lines.pop_front().expect("not empty");
let ok = self.run_quiet_line(conn, &line);
if let Some(batch) = self.lan.as_mut().and_then(|h| h.quiet.get_mut(i)) {
batch.ran += 1;
batch.failed += usize::from(!ok);
}
}
}
/// One line as `guest_command` would run it for `conn`, quietly. False
/// when it is refused or fails. A command that carries a chat message
/// (`say`, `msg`...) is refused: it would need the player's signature.
fn run_quiet_line(&mut self, conn: u32, line: &str) -> bool {
let allowed = self.options.dedicated.is_some() || self.allow_commands();
let Some(host) = self.lan.as_ref() else {
return false;
};
let integrated = host.hub.config.integrated;
if !integrated && (!host.settings.guest_commands || !allowed) {
return false;
}
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
return false;
};
let command = line.trim_start_matches('/');
if !self.signable_arguments(permission, command).is_empty() {
return false;
}
self.run_guest_command(GuestCommand {
conn,
command: command.to_owned(),
permission,
integrated,
quiet: true,
})
}
}
/// The batch's line in the server's log.
fn summary(name: &str, b: &QuietBatch) -> String {
let mut line = format!(
"{name} ran {} quiet command(s) from mod {}: {} failed",
b.ran, b.mod_id, b.failed
);
if b.dropped > 0 {
line += &format!(", {} dropped (more than {PENDING} waiting)", b.dropped);
}
line
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_log_line_counts_the_batch() {
let b = QuietBatch {
conn: 1,
mod_id: "orbital".into(),
lines: VecDeque::new(),
ran: 12,
failed: 3,
dropped: 0,
};
assert_eq!(
summary("Alex", &b),
"Alex ran 12 quiet command(s) from mod orbital: 3 failed"
);
let b = QuietBatch { dropped: 5, ..b };
assert!(summary("Alex", &b).ends_with(", 5 dropped (more than 4096 waiting)"));
}
}
@@ -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];
+22 -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,
@@ -135,6 +137,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 +162,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 +189,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 +214,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);
@@ -287,8 +293,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.
@@ -390,7 +396,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 +405,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() {
+13 -17
View File
@@ -162,11 +162,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 +181,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 +212,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);
}
}
+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
}
+143 -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,11 +54,24 @@ 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>,
/// `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.
#[cfg(feature = "mods")]
commands: Vec<String>,
/// `command.run-quiet` batches for this window's own world, by mod id,
/// run on the next tick as `commands` are.
#[cfg(feature = "mods")]
quiet: Vec<(String, Vec<String>)>,
/// The server's command tree with the running mods' commands added.
#[cfg(feature = "mods")]
remote_tree: Option<Dispatcher>,
@@ -272,7 +287,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));
@@ -282,8 +297,18 @@ 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();
let dev = &self.mods.dev;
self.settings.mods.installed = self
.mods
@@ -369,8 +394,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,
@@ -414,7 +449,7 @@ impl Game<'_> {
mappings,
cps,
font_widths: self.mods.font_widths.clone(),
players: Vec::new(),
players,
view_hit: self.mod_view_hit(),
}
}
@@ -474,6 +509,51 @@ impl Game<'_> {
}
None
}
/// A mod's `command.run-quiet` lines (vanilla's commands only, as
/// `command.run`): to a server that runs them quietly as one payload
/// (`horizon_protocol::quiet`), to any other as chat commands, or for this
/// window's own world next tick.
fn mod_quiet_commands(&mut self, mod_id: String, lines: Vec<String>) {
let lines: Vec<String> = lines
.into_iter()
.filter(|l| vanilla_command(l.split(' ').next().unwrap_or_default()))
.collect();
match self.remote.as_ref().map(|r| r.quiet_commands) {
Some(true) => {
for data in horizon_protocol::quiet::encode(&mod_id, &lines) {
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_protocol::quiet::CHANNEL.into(),
data,
},
));
}
}
// The server checks the player's permissions; its feedback shows.
Some(false) => {
for line in lines {
self.send_chat(&format!("/{line}"));
}
}
None => self.mods.quiet.push((mod_id, lines)),
}
}
/// A quiet batch in this window's own world, as a server runs one
/// (game/lan_quiet.rs): each line as the player typing it, no chat, a
/// chat message refused; one line in the log.
fn run_quiet_batch(&mut self, mod_id: &str, lines: &[String]) {
let permission = self.ui.registries.permission;
let mut failed = 0;
for line in lines {
let ok = self.signable_arguments(permission, line).is_empty()
&& self.run_command_quietly(line);
failed += usize::from(!ok);
}
log::info!(
"[mod {mod_id}] ran {} quiet command(s): {failed} failed",
lines.len()
);
}
/// A game tick.
pub(super) fn tick_mods(&mut self) {
// Here, at the player's level: shown inside a console call (a control
@@ -481,6 +561,9 @@ impl Game<'_> {
for line in std::mem::take(&mut self.mods.commands) {
self.run_command(&line);
}
for (mod_id, lines) in std::mem::take(&mut self.mods.quiet) {
self.run_quiet_batch(&mod_id, &lines);
}
if !self.mods.mods.is_running() {
return;
}
@@ -490,23 +573,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;
}
@@ -537,8 +629,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 } => {
@@ -561,12 +658,25 @@ impl Game<'_> {
}
continue;
}
Output::QuietCommands { mod_id, lines } => {
self.mod_quiet_commands(mod_id, lines);
continue;
}
Output::Stopped { name, reason } => {
// Its screen goes with it: the entry's run is gone, so
// `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 { .. }) => {
self.route_server_output(host.as_deref_mut(), server);
continue;
}
Output::OpenScreen { owner } => {
self.open_mod_screen(owner, false);
@@ -582,20 +692,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.
@@ -604,6 +702,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() {
+2 -2
View File
@@ -314,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,
@@ -386,7 +386,7 @@ impl Game<'_> {
[white.u0, white.v0, white.u1, white.v1],
BACKGROUND,
true,
2,
text_layer,
);
}
let mut x = x0;
+23 -1
View File
@@ -3,7 +3,10 @@
//! window, waits until it stands in the world with the chunks around it,
//! sends a chat message and `/list`, collects the chat it is shown, leaves
//! and writes a JSON report (position, chunks, chat lines, whether the
//! server kept it connected throughout).
//! server kept it connected throughout). With `HORIZON_NET_QUIET` (lines
//! split by `;`) it then sends them as one batch of a mod's quiet commands
//! (`horizon_protocol::quiet`, `tools/quiet_commands_e2e.py`) and waits two
//! seconds for any chat they bring.
use super::Game;
use horizon_client::Result;
use serde_json::json;
@@ -23,6 +26,9 @@ impl Game<'_> {
let mut step_at = Instant::now();
let mut chat_at = 0;
let mut position = None;
let quiet: Vec<String> = std::env::var("HORIZON_NET_QUIET")
.map(|v| v.split(';').map(str::to_owned).collect())
.unwrap_or_default();
let mut chunks = 0;
let mut error = None;
while Instant::now() < deadline {
@@ -71,6 +77,22 @@ impl Game<'_> {
step_at = now;
chat_at = shown;
}
2 if answered && !quiet.is_empty() => {
for data in horizon_protocol::quiet::encode("net_harness", &quiet) {
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_protocol::quiet::CHANNEL.into(),
data,
},
));
}
step = 4;
step_at = now;
}
4 if now - step_at > Duration::from_secs(2) => {
step = 3;
break;
}
2 if answered => {
step = 3;
break;
@@ -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))
+73 -20
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);
@@ -484,6 +494,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 +531,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 +538,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).
+147 -25
View File
@@ -109,6 +109,8 @@ enum Received {
pub(super) struct Remote {
pub session: Session,
pub brand: String,
/// The server announced it runs mods' quiet commands.
pub quiet_commands: bool,
pub(super) builtin: Arc<Builtin>,
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
@@ -419,6 +421,7 @@ impl Game<'_> {
self.remote = Some(Remote {
session,
brand: "unknown".into(),
quiet_commands: false,
builtin: Arc::new(builtin),
lang,
registries: Arc::new(Dynamic::default()),
@@ -839,7 +842,15 @@ impl Game<'_> {
remote.brand = brand;
}
}
Event::Reconfigured(registries) => self.set_registries(registries),
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
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 } => {
@@ -1258,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);
@@ -1364,13 +1378,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]);
}
@@ -1380,6 +1398,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;
}
@@ -1426,6 +1460,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() {
@@ -1661,19 +1697,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
@@ -1958,6 +2005,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).
@@ -2039,6 +2087,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) => {
@@ -2048,9 +2098,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)
@@ -2064,6 +2119,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)
@@ -2936,8 +2996,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;
@@ -2950,12 +3014,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) {
@@ -3062,6 +3130,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);
@@ -3099,8 +3173,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();
@@ -3425,6 +3505,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;
}
@@ -3484,6 +3571,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);
@@ -3492,6 +3581,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;
@@ -3520,6 +3612,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)) {
@@ -3682,6 +3786,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;
};
@@ -4513,6 +4626,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 {
@@ -4779,6 +4893,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;
@@ -4814,16 +4929,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,
@@ -4849,6 +4968,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()
@@ -4,14 +4,14 @@
//! own simulation, which a client of a server doesn't run.
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, tame};
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
use horizon_protocol::entity_data::Value;
use horizon_protocol::registry::Builtin;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
/// `builtin` names the ids of built-in registries.
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
let species = m.species();
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
@@ -46,6 +46,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
_ => {}
}
match (species, *index, value) {
(Species::Guardian | Species::ElderGuardian, 16, Value::Bool(moving)) => {
m.extra.flag2 = *moving;
}
(Species::Guardian | Species::ElderGuardian, 17, Value::Int(target)) => {
// Guardian.onSyncedDataUpdated resets the visual charge clock.
m.extra.counter2 = 0;
m.extra.flag = *target != 0;
m.ai.target = (*target != 0).then_some(if *target == local_entity {
Who::Player
} else {
Who::Mob(*target as u32)
});
}
// Bat.DATA_ID_FLAGS: resting in bit 0.
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
(Species::Sheep, 18, Value::Byte(wool)) => {
m.extra.color = (*wool & 15) as u8;
@@ -114,7 +129,7 @@ impl Game<'_> {
return;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values, &r.builtin);
apply(m, values, &r.builtin, r.entity_id);
}
}
}
@@ -126,6 +141,10 @@ pub(super) fn client_defaults(m: &mut Mob) {
if matches!(m.species(), Species::Wolf | Species::Cat) {
m.extra.color = tame::RED;
}
if m.species() == Species::Bat {
// Bat's constructor sets resting only on the server; the data default is 0.
bat::set_resting(m, false);
}
}
#[cfg(test)]
@@ -147,7 +166,7 @@ mod tests {
}
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
super::apply(m, values, &builtin());
super::apply(m, values, &builtin(), 99);
}
fn mob(kind: &str) -> Option<Mob> {
@@ -158,6 +177,49 @@ mod tests {
Some(m)
}
#[test]
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
use horizon_client::mobs::guardian::{beam, client_tick};
for (kind, duration) in [("guardian", 80), ("elder_guardian", 60)] {
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert!(beam(&m, 0.0).is_none());
apply(
&mut m,
&[
(16, s::BOOLEAN, Value::Bool(true)),
(17, s::INT, Value::Int(99)),
],
);
assert!(m.extra.flag2);
assert_eq!(m.ai.target, Some(Who::Player));
assert_eq!(beam(&m, 0.0), Some((0.0, 0.0)));
for _ in 0..duration + 10 {
client_tick(&mut m);
}
assert_eq!(beam(&m, 0.0), Some((duration as f32, 1.0)));
// Another player or a mob retains its network entity id.
apply(&mut m, &[(17, s::INT, Value::Int(42))]);
assert_eq!(m.ai.target, Some(Who::Mob(42)));
assert_eq!(m.extra.counter2, 0);
client_tick(&mut m);
assert_eq!(m.extra.counter2, 1);
apply(
&mut m,
&[
(16, s::BOOLEAN, Value::Bool(false)),
(17, s::INT, Value::Int(0)),
],
);
client_tick(&mut m);
assert!(!m.extra.flag2);
assert_eq!(m.ai.target, None);
assert_eq!(m.extra.counter2, 0);
assert!(beam(&m, 0.0).is_none());
}
}
#[test]
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
let Some(mut m) = mob("minecraft:sheep") else {
@@ -174,6 +236,69 @@ mod tests {
assert!(!calf.is_baby());
}
#[test]
fn bat_flags_select_continuous_client_clocks_without_changing_motion() {
use horizon_client::mobs::{MobTypes, anim, keyframes::Animation};
use horizon_core::geometry::Vec3;
use serde_json::json;
use std::{collections::HashMap, fs, sync::Arc};
// Original minimal resources keep this regression runnable without vanilla data.
let root =
std::env::temp_dir().join(format!("horizon-bat-animation-{}", std::process::id()));
fs::create_dir(&root).unwrap();
fs::write(root.join("entity-types.json"), r#"{"minecraft:bat": {}}"#).unwrap();
fs::write(
root.join("entity-models.json"),
r#"{"minecraft:bat#main": {"children": {"right_wing": {}, "head": {}}}}"#,
)
.unwrap();
let mut types = MobTypes::load(&root).unwrap();
fs::remove_dir_all(root).unwrap();
let position = Vec3::new(1.0, 2.25, 3.0);
let velocity = Vec3::new(0.2, 0.3, 0.4);
let mut m = Mob::new(
1,
Arc::new(types.types.remove("minecraft:bat").unwrap()),
position,
0.0,
);
assert!(bat::resting(&m));
client_defaults(&mut m);
assert!(!bat::resting(&m));
m.velocity = velocity;
m.age = 1;
bat::setup_animation_states(&mut m);
m.age = 4;
apply(&mut m, &[(16, s::BYTE, Value::Byte(2))]);
bat::setup_animation_states(&mut m);
assert_eq!(m.anim.states[0].started, Some(1.0));
let curve = Animation::parse(&json!({"length": 1, "looping": true,
"bones": {"right_wing": [{"target": "rotation", "keyframes":
[[0,[0,0,0],[0,0,0],"linear"],[1,[0,1,0],[0,1,0],"linear"]]}]}}));
let animations = HashMap::from([("BatAnimation.BAT_FLYING".into(), curve)]);
let wing = |partial| {
let mut poses = anim::Poses::new(&m.kind.model);
anim::setup(&m, &anim::state(&m, partial), &mut poses, &animations);
poses.get("right_wing")[4]
};
assert!(wing(0.5) > wing(0.0));
m.age = 5;
apply(&mut m, &[(16, s::BYTE, Value::Byte(3))]);
bat::setup_animation_states(&mut m);
assert!(bat::resting(&m));
assert_eq!(m.anim.states[0].started, None);
assert_eq!(m.anim.states[1].started, Some(5.0));
m.age = 7;
apply(&mut m, &[(16, s::BYTE, Value::Byte(0))]);
bat::setup_animation_states(&mut m);
assert_eq!(m.anim.states[0].started, Some(7.0));
assert_eq!(m.anim.states[1].started, None);
assert_eq!((m.position, m.velocity), (position, velocity));
}
#[test]
fn a_wolf_takes_its_owner_flags_and_collar() {
let Some(mut m) = mob("minecraft:wolf") else {
@@ -3,7 +3,7 @@
//! `handleSetEquipment`): the server simulates them, the client places them
//! where it says and interpolates their moves like a mob's.
use super::Game;
use horizon_client::entities::{ArmorStand, ItemFrame, Painting};
use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting};
use horizon_core::geometry::Vec3;
use horizon_protocol::entity_data::Value;
use std::sync::Arc;
@@ -57,6 +57,16 @@ impl Game<'_> {
invisible: false,
check_interval: 0,
});
} else if kind == "minecraft:cushion" {
// BlockAttachedEntity.pos: `BlockPos.containing` of the spawn.
self.cushions.push(Cushion {
id,
position: at,
pos: block,
yaw: super::cushions::snap_yaw(yaw),
color: 0,
check_interval: 0,
});
} else if kind == "minecraft:lightning_bolt" {
self.spawn_remote_bolt(id, position);
} else if kind == "minecraft:painting" {
@@ -152,6 +162,11 @@ impl Game<'_> {
}
_ => {}
}
} else if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) {
// Cushion.DATA_COLOR (DyeColor, a byte-backed id).
if let (8, Value::Id(color)) = (index, value) {
c.color = (*color).clamp(0, 15) as u8;
}
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
// VehicleEntity's hurt, direction and damage; the paddles.
match (index, value) {
@@ -267,6 +282,12 @@ impl Game<'_> {
out.push(json!({"id": a.id, "kind": "minecraft:armor_stand", "position": at(a.position), "yaw": a.yaw,
"small": a.small, "arms": a.show_arms, "no_base_plate": a.no_base_plate, "equipment": items}));
}
for c in &self.cushions {
out.push(
json!({"id": c.id, "kind": "minecraft:cushion", "position": at(c.position),
"yaw": c.yaw, "color": c.color_name()}),
);
}
for f in &self.item_frames {
out.push(
json!({"id": f.id, "kind": f.kind(), "position": f.pos, "facing": f.facing,
@@ -286,6 +307,7 @@ impl Game<'_> {
self.carts.clear();
self.boats.clear();
self.armor_stands.clear();
self.cushions.clear();
self.item_frames.clear();
self.paintings.clear();
}
@@ -295,6 +317,7 @@ impl Game<'_> {
self.carts.retain(|c| c.id != id);
self.boats.retain(|b| b.id != id);
self.armor_stands.retain(|a| a.id != id);
self.cushions.retain(|c| c.id != id);
self.item_frames.retain(|f| f.id != id);
self.paintings.retain(|p| p.id != id);
self.bolts.retain(|b| b.id != id);
@@ -3,11 +3,26 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
/// An `AreaEffectCloud` a server tracks: what its client tick reads.
pub(super) struct RemoteCloud {
id: u32,
at: [f64; 3],
radius: f32,
waiting: bool,
particle: ParticleOptions,
}
/// `AreaEffectCloud.DEFAULT_PARTICLE`: a white entity effect.
fn default_cloud_particle() -> ParticleOptions {
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
}
/// The projectile a type id names, as Horizon models it.
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
Some(match kind {
@@ -37,6 +52,32 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
})
}
/// `AbstractArrow.tick` on a client: water drag before the move, air drag
/// after it, then gravity; a block it reaches holds it (`inGround`).
fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
p.previous = p.position;
p.age += 1;
if p.stuck {
return;
}
let water = w.in_water(p.position);
if water {
p.velocity = p.velocity.scale(0.6);
}
let to = p.position.add_vector(p.velocity);
if let Some((at, _, _)) = w.clip(p.position, to) {
p.position = at;
p.velocity = Vec3::ZERO;
p.stuck = true;
return;
}
p.position = to;
if !water {
p.velocity = p.velocity.scale(0.99);
}
p.velocity.y -= 0.05;
}
impl Game<'_> {
/// `handleAddEntity` for these types; false when `kind` isn't one.
pub(super) fn spawn_remote_projectile(
@@ -92,6 +133,16 @@ impl Game<'_> {
| "minecraft:experience_bottle" => {
self.potions.add_remote_thrown(id, kind, at, velocity);
}
"minecraft:dragon_fireball" => self
.dragon_fireballs
.push(super::dragon::DragonFireball::remote(id, at, velocity)),
"minecraft:area_effect_cloud" => self.potions.clouds.push(RemoteCloud {
id,
at: position,
radius: 3.0,
waiting: false,
particle: default_cloud_particle(),
}),
"minecraft:eye_of_ender" => {
self.end
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
@@ -125,6 +176,31 @@ impl Game<'_> {
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let id = id as u32;
self.remote_hook_data(id, values);
if let Some(at) = self
.potions
.clouds
.iter()
.find(|c| c.id == id)
.map(|c| c.at)
{
// AreaEffectCloud.DATA_RADIUS, DATA_WAITING and DATA_PARTICLE.
for (index, _, value) in values {
let particle = match (index, value) {
(10, Value::Particle(p)) => self.received_particle(p, at),
_ => None,
};
let Some(c) = self.potions.clouds.iter_mut().find(|c| c.id == id) else {
return;
};
match (index, value) {
(8, Value::Float(r)) => c.radius = *r,
(9, Value::Bool(w)) => c.waiting = *w,
(10, _) => c.particle = particle.unwrap_or_else(default_cloud_particle),
_ => {}
}
}
return;
}
if self.rockets.iter().any(|r| r.id == id) {
for (index, _, value) in values {
match (index, value) {
@@ -246,29 +322,34 @@ impl Game<'_> {
}
}
self.falling = falling;
for p in &mut self.creatures.projectiles {
p.previous = p.position;
p.age += 1;
if matches!(p.kind, ProjectileKind::LargeFireball { .. }) {
// AbstractHurtingProjectile.tick advances on the client between
// the tracker updates (ten ticks apart for a ghast's fireball).
let at = p.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;
p.apply_hurting_inertia(water);
p.position = p.position.add_vector(p.velocity);
} else {
p.position = step(p.id, p.position);
// Projectiles tick on the client too and fly on between the tracker's
// updates (10 to 20 ticks apart); a block stops them there, and only
// the server decides what they hit.
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
{
let w = self.mob_world_base(&[]);
let world = super::creatures::ProjWorld {
w: &w,
targets: Vec::new(),
};
for p in &mut projectiles {
match p.kind {
ProjectileKind::Arrow { .. } => remote_arrow_tick(p, &world),
// ShulkerBullet.tick: only the server steers it.
ProjectileKind::ShulkerBullet { .. } => {
p.previous = p.position;
p.age += 1;
p.position = p.position.add_vector(p.velocity);
}
_ => {
if !matches!(p.tick(&world).1, Hit::None) {
p.velocity = Vec3::ZERO;
}
}
}
}
}
self.creatures.projectiles = projectiles;
for t in &mut self.tridents {
let id = t.id;
t.remote_step(|at| step(id, at));
@@ -302,12 +383,78 @@ impl Game<'_> {
c.time += 1;
c.position = step(c.id, c.position);
}
self.potions.step_remote_thrown(&mut step);
self.step_remote_thrown();
self.step_remote_hooks(&mut step);
self.end.step_remote_eyes(|id, at| {
let at = step(id, Vec3::new(at[0], at[1], at[2]));
[at.x, at.y, at.z]
});
self.end.step_remote_eyes();
self.tick_remote_clouds();
// The dragon's fireballs fly on between the tracker's updates, with
// their dragon_breath trail (`AbstractHurtingProjectile.getTrailParticle`).
let trail = ParticleOptions::simple(ParticleType::DragonBreath);
for b in &mut self.dragon_fireballs {
let at = b.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;
b.remote_step(water);
let p = b.position;
self.particles.add(&trail, [p.x, p.y + 0.5, p.z], [0.0; 3]);
}
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
/// particles near the middle; then about one per square block of the
/// disc, drifting (an entity effect stays still).
fn tick_remote_clouds(&mut self) {
for c in &self.potions.clouds {
let r = self.particles.random();
if c.waiting && r.next_boolean() {
continue;
}
let (count, spread) = if c.waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
c.radius,
)
};
for _ in 0..count {
let r = self.particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
c.at[0] + f64::from(angle.cos() * distance),
c.at[1],
c.at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
let default = c.waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
c.particle.clone()
};
(options, [0.0; 3])
} else if c.waiting {
(c.particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(c.particle.clone(), v)
};
self.particles.add(&options, at, velocity);
}
}
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
@@ -318,6 +465,8 @@ impl Game<'_> {
self.rockets.retain(|r| r.id != id);
self.crystals.retain(|c| c.id != id);
self.potions.remove_remote_thrown(id);
self.potions.clouds.retain(|c| c.id != id);
self.dragon_fireballs.retain(|b| b.id != id);
self.end.remove_remote_eye(id);
self.remove_remote_hook(id);
}
@@ -330,7 +479,11 @@ impl Game<'_> {
self.tridents.clear();
self.rockets.clear();
self.crystals.clear();
// The dragon a server sent goes with its crystals.
self.dragon = None;
self.potions.clear_thrown();
self.potions.clouds.clear();
self.dragon_fireballs.clear();
self.end.clear_remote_eyes();
self.clear_remote_hooks();
}
@@ -56,6 +56,11 @@ impl Game<'_> {
let p = a.position;
hits.push((a.id as i32, d, [p.x, p.y, p.z]));
}
if let Some((i, d, _)) = self.cushion_hit() {
let c = &self.cushions[i];
let p = c.position;
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
}
if let Some((i, d)) = self.boat_hit() {
let p = self.boats[i].position;
hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z]));
@@ -79,6 +84,8 @@ impl Game<'_> {
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
}
}
// EnderDragonPart.isPickable: the server's dragon hurts by part.
hits.extend(self.remote_dragon_part_hit(eye, dir, reach));
}
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
let point = at(distance);
@@ -138,7 +145,10 @@ impl Game<'_> {
self.position_rider();
return true;
}
let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let seat = if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
// The seat does not move; the rider sits on the cushion.
Some(c.seat())
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
// AbstractBoat.tick on the controlling client: steer and move.
let mut b = self.boats[i].clone();
b.input = [keys.left, keys.right, keys.forward, keys.backward];
+1
View File
@@ -179,6 +179,7 @@ impl Game<'_> {
if keys.is_empty() {
self.prune_entity_memory();
self.save_raids();
self.save.save_forced_chunks(&self.streaming.forced)?;
self.save_game_metadata()?;
save.stage = Stage::Writes;
}
@@ -0,0 +1,356 @@
//! Server mods in the game (`docs/mods.md`, "Server mods"): the game that
//! simulates the world (in-frame single player, the server thread, a LAN
//! host, the dedicated server) runs them. At the end of each tick it tells
//! them who joined and left, then what players said, broke and placed since
//! the last one, then ticks them; a player's command reaches them at once.
//! Their lines go to the players they name. With no server mod on, each hook
//! here is one check.
use super::{Game, lan::LanHost};
#[cfg(not(feature = "mods"))]
use horizon_world::registry::BlockStateId;
/// Who did something a server mod hears about, resolved at the tick's end.
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) enum Who {
/// The window's own player, in-frame.
Local,
/// A connection's player (the server thread's owner is one).
Conn(u32),
Uuid(u128),
}
/// What players did during a tick, for the server mods at its end.
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) enum ModEvent {
Chat(Who, String),
Block {
who: Who,
pos: [i32; 3],
state: String,
placed: bool,
},
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn tick_server_mods(&mut self) {}
pub(super) fn mod_block_event(&mut self, _pos: [i32; 3], _state: BlockStateId, _placed: bool) {}
pub(super) fn mod_player_chat(&mut self, _who: Who, _text: &str) {}
pub(super) fn local_server_mod_command(&mut self, _line: &str) -> bool {
false
}
pub(super) fn guest_server_mod_command(
&mut self,
_host: &mut LanHost,
_conn: u32,
_line: &str,
) -> bool {
false
}
}
#[cfg(feature = "mods")]
mod run {
use super::{Game, LanHost, ModEvent, Who};
use horizon_client::{
command::args::GameMode as Mode,
lan::data::component_nbt,
text::{self, Text},
};
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::BlockStateId;
/// `UUID.toString()`.
fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
fn mode(id: i32) -> GameMode {
match id {
1 => GameMode::Creative,
2 => GameMode::Adventure,
3 => GameMode::Spectator,
_ => GameMode::Survival,
}
}
impl Game<'_> {
/// The players on this game's server: the window's own player
/// in-frame, then every connection's. `host` is the hub when it is
/// out of `self.lan`.
pub(in crate::game) fn server_players(&self, host: Option<&LanHost>) -> Vec<ServerPlayer> {
let mut out = Vec::new();
if self.options.execution_role.is_client()
&& self.remote.is_none()
&& let Some(p) = &self.player
{
out.push(ServerPlayer {
id: self.player_uuid_string(),
name: crate::game::chat::PLAYER_NAME.to_owned(),
x: p.position.x,
y: p.position.y,
z: p.position.z,
dimension: self.dim.id().to_owned(),
game_mode: match self.mode {
Mode::Survival => GameMode::Survival,
Mode::Creative => GameMode::Creative,
Mode::Adventure => GameMode::Adventure,
Mode::Spectator => GameMode::Spectator,
},
health: p.health.health(),
});
}
let guests = host
.or(self.lan.as_ref())
.into_iter()
.flat_map(|h| &h.hub.guests);
out.extend(guests.filter(|g| g.placed).map(|g| ServerPlayer {
id: uuid_string(g.uuid),
name: g.name.clone(),
x: g.position[0],
y: g.position[1],
z: g.position[2],
dimension: g.dimension.clone(),
game_mode: mode(g.game_mode),
health: g.health,
}));
out
}
/// Whether what players do now reaches server mods: one is running
/// and this game simulates the world.
fn hears_players(&self) -> bool {
self.mods.server && self.remote.is_none()
}
/// The tick's end: joins and leaves, the tick's events, the tick hook.
pub(in crate::game) fn tick_server_mods(&mut self) {
if !self.options.execution_role.is_client() {
// A server has no frames to check its dev mods in.
self.reload_dev_mods();
}
if !self.mods.server {
return;
}
let players = self.server_players(None);
let snapshot = self.mod_snapshot_with(players.clone());
self.mods.mods.set_snapshot(snapshot);
let before = std::mem::take(&mut self.mods.players);
for p in before
.iter()
.filter(|p| !players.iter().any(|q| q.id == p.id))
{
self.mods.mods.player_left(p);
}
for p in players
.iter()
.filter(|p| !before.iter().any(|q| q.id == p.id))
{
self.mods.mods.player_joined(p);
}
for event in std::mem::take(&mut self.mods.events) {
let who = |w: &Who| {
let id = match w {
Who::Local => self.player_uuid_string(),
Who::Uuid(u) => uuid_string(*u),
Who::Conn(c) => {
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
let g = guests.into_iter().find(|g| g.conn == *c);
g.map_or_else(String::new, |g| uuid_string(g.uuid))
}
};
players.iter().chain(&before).find(|p| p.id == id).cloned()
};
match event {
ModEvent::Chat(w, text) => {
if let Some(p) = who(&w) {
self.mods.mods.player_chat(&p, &text);
}
}
ModEvent::Block {
who: w,
pos,
state,
placed,
} => {
let Some(p) = who(&w) else { continue };
let pos = BlockPos {
x: pos[0],
y: pos[1],
z: pos[2],
};
if placed {
self.mods.mods.block_placed(&p, pos, &state);
} else {
self.mods.mods.block_broken(&p, pos, &state);
}
}
}
}
self.mods.mods.server_tick(self.ticks);
self.mods.players = players;
self.show_mod_output();
}
/// A player broke (or placed) `state` at `pos`: the player acting now.
pub(in crate::game) fn mod_block_event(
&mut self,
pos: [i32; 3],
state: BlockStateId,
placed: bool,
) {
if !self.hears_players() {
return;
}
let who = self.lan_acting.map_or(Who::Local, Who::Conn);
let state = self.state_string(state);
self.mods.events.push(ModEvent::Block {
who,
pos,
state,
placed,
});
}
/// A chat message a player sent.
pub(in crate::game) fn mod_player_chat(&mut self, who: Who, text: &str) {
if self.hears_players() {
self.mods.events.push(ModEvent::Chat(who, text.to_owned()));
}
}
/// `/line` typed by the window's own player in-frame: true when a
/// server mod owns it.
pub(in crate::game) fn local_server_mod_command(&mut self, line: &str) -> bool {
if !self.hears_players() {
return false;
}
let players = self.server_players(None);
let Some(me) = players.first().cloned() else {
return false;
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
let owned = self.mods.mods.server_command(&me, line);
self.show_mod_output();
owned
}
/// `/line` typed by a connection's player: true when a server mod
/// owns it, whatever the player's permission level.
pub(in crate::game) fn guest_server_mod_command(
&mut self,
host: &mut LanHost,
conn: u32,
line: &str,
) -> bool {
if !self.hears_players() {
return false;
}
let Some(g) = host.hub.guest(conn) else {
return false;
};
let id = uuid_string(g.uuid);
let players = self.server_players(Some(host));
let Some(me) = players.iter().find(|p| p.id == id).cloned() else {
return false;
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
if !self.mods.mods.server_command(&me, line) {
return false;
}
self.route_mod_output(Some(host));
true
}
/// Sends a server mod's line to the player with this id: the
/// window's own chat for the local player, a system message for a
/// connection's. `host` is the hub when it is out of `self.lan`.
pub(in crate::game) fn tell_server_player(
&mut self,
host: Option<&mut LanHost>,
id: &str,
line: Text,
) {
let local = self.options.execution_role.is_client()
&& self.remote.is_none()
&& self.player.is_some();
if local && self.player_uuid_string() == id {
self.add_mod_chat_line(line);
return;
}
let Some(host) = host.or(self.lan.as_mut()) else {
return;
};
let to = host
.hub
.guests
.iter()
.find(|g| g.placed && uuid_string(g.uuid) == id);
if let Some(conn) = to.map(|g| g.conn) {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: component_nbt(&line.component()),
overlay: false,
}),
);
}
}
/// Routes a server mod's output: `Tell` and `Reply` to their player,
/// `Broadcast` to everyone and the log.
pub(in crate::game) fn route_server_output(
&mut self,
mut host: Option<&mut LanHost>,
output: Output,
) {
let named = |name: &str, text: String| {
let mut line = Text::literal(format!("[{name}] ")).with_color(text::GRAY);
line.push(Text::literal(text));
line
};
match output {
Output::Tell { name, player, text } => {
self.tell_server_player(host, &player, named(&name, text));
}
Output::Reply {
player,
text,
error,
} => {
let line = Text::literal(text);
let line = if error {
line.with_color(text::RED)
} else {
line
};
self.tell_server_player(host, &player, line);
}
Output::Broadcast { name, text } => {
let plain = format!("[{name}] {text}");
if self.options.dedicated.is_some() {
super::super::dedicated::log("INFO", &plain);
} else {
log::info!("{plain}");
}
for p in self.server_players(host.as_deref()) {
let line = named(&name, text.clone());
self.tell_server_player(host.as_deref_mut(), &p.id, line);
}
}
_ => {}
}
}
}
}
@@ -13,6 +13,7 @@ use super::respawn_anchor::RespawnConfig;
use super::{Dim, Game, lan::LanHost};
use horizon_client::{lan::hub::TickCtx, text::Text};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// A trip under way: the player is in the destination's level, whose
/// chunks are loading around `target`.
@@ -103,8 +104,35 @@ impl Game<'_> {
if let Some(pos) = self.nether_portal_inside(at) {
self.portals.player.set_inside(pos, PLAYER_DELAY);
}
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
// poem rolls, then PERFORM_RESPAWN takes it to its respawn point.
let play = host.play.entry(conn).or_default();
if play.won_game {
if std::mem::take(&mut play.respawn_requested) {
play.won_game = false;
self.start_server_trip(host, conn, Dim::Overworld, None);
self.portals.player.cooldown = 10;
}
return;
}
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
if end_portal {
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
g.seen_credits = true;
}
host.play.entry(conn).or_default().won_game = true;
// ClientboundGameEventPacket.WIN_GAME.
host.hub.send_to(
conn,
&C::GameEvent(play::GameEvent {
event: 4,
param: 0.0,
}),
);
return;
}
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
Dim::Overworld
+225 -89
View File
@@ -6,9 +6,9 @@ use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
signs::wood_type,
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -18,7 +18,16 @@ pub(super) struct SignEditor {
pub pos: (i32, i32, i32),
pub front: bool,
pub line: usize,
pub opened: Instant,
/// The line being edited (`AbstractSignEditScreen`'s text field).
edit: EditBox,
}
fn sign_box() -> EditBox {
let mut edit = EditBox::new(0.0, 0.0, 90.0, 9.0);
edit.bordered = false;
edit.max_length = 384;
edit.focused = true;
edit
}
impl Game<'_> {
@@ -28,8 +37,9 @@ impl Game<'_> {
pos,
front,
line: 0,
opened: Instant::now(),
edit: sign_box(),
});
self.sign_load();
self.keys.clear();
self.right = false;
self.capture(false);
@@ -140,97 +150,154 @@ impl Game<'_> {
/// A key and the text it typed on the sign being edited (the keyboard,
/// or a controller's on-screen keyboard).
pub(super) fn sign_input(&mut self, key: Option<KeyCode>, typed: Option<&str>) {
let Some(edit) = self.sign_edit.as_mut() else {
if self.sign_edit.is_none() {
return;
};
let front = edit.front;
let id = sign_line_id(front, edit.line);
}
match key {
Some(KeyCode::Escape) => self.close_sign_editor(),
Some(KeyCode::ArrowUp) => edit.line = (edit.line + 3) & 3,
Some(KeyCode::ArrowUp) => self.sign_move_line(3),
Some(KeyCode::ArrowDown | KeyCode::Enter | KeyCode::NumpadEnter) => {
edit.line = (edit.line + 1) & 3
self.sign_move_line(1)
}
Some(KeyCode::Backspace) => {
let (line, key) = (edit.line, edit.pos);
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
let text = if front {
&mut sign.front
} else {
&mut sign.back
};
let value = &mut text.lines[line];
if !self.text_selection.take(id, value) {
value.pop();
}
}
Some(
key @ (KeyCode::ArrowLeft
| KeyCode::ArrowRight
| KeyCode::Home
| KeyCode::End
| KeyCode::Backspace
| KeyCode::Delete),
) => {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
self.sign_change(|edit| {
edit.key(key, ctrl, shift);
});
}
_ => {
let Some(typed) = typed else {
return;
};
let line = edit.line;
let atlas = &self.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s);
let Some(key) = self.sign_edit.as_ref().map(|e| e.pos) else {
return;
};
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
let max = sign.max_line_width();
let text = if front {
&mut sign.front
} else {
&mut sign.back
};
self.text_selection.take(id, &mut text.lines[line]);
for ch in typed.chars().filter(|c| !c.is_control()) {
let mut next = text.lines[line].clone();
next.push(ch);
// TextFieldHelper filter: font.width(s) <= maxTextLineWidth.
if width(&next) <= max {
text.lines[line] = next;
}
}
}
self.sign_change(|edit| edit.insert_text(typed));
}
}
}
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line: a paste
/// (formatting and carriage returns removed) is taken whole or not at
/// all, as the line must stay within the sign's width.
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
/// Up or down a line: the box writes the line it leaves and loads the next.
fn sign_move_line(&mut self, by: usize) {
self.sign_write();
if let Some(edit) = self.sign_edit.as_mut() {
edit.line = (edit.line + by) & 3;
}
self.sign_load();
}
/// The edited line's text.
fn sign_current(&self) -> Option<String> {
let edit = self.sign_edit.as_ref()?;
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
return None;
};
let side = if edit.front { &sign.front } else { &sign.back };
Some(side.lines[edit.line].clone())
}
/// `EditBox.setValue` from the line now being edited.
fn sign_load(&mut self) {
let Some(text) = self.sign_current() else {
return;
};
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.set_value(&text);
}
}
/// The box's text onto the sign, when it still fits the line.
fn sign_write(&mut self) {
let Some(edit) = self.sign_edit.as_ref() else {
return;
};
let (key, front, line) = (edit.pos, edit.front, edit.line);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
let (pos, front, line, value) = (edit.pos, edit.front, edit.line, edit.edit.value.clone());
if !self.sign_fits(&value) {
return;
}
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else {
return;
};
let max = sign.max_line_width();
let text = if front {
let side = if front {
&mut sign.front
} else {
&mut sign.back
};
let atlas = &self.gui.atlas;
self.text_selection.apply(
sign_line_id(front, line),
&mut text.lines[line],
shortcut,
&mut self.clipboard,
|value, pasted| {
let next = format!(
"{value}{}",
horizon_client::clipboard::helper_clipboard_contents(pasted)
);
HudBatch::text_width(atlas, &next) <= max && {
*value = next;
true
}
},
);
side.lines[line] = value;
}
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20).
/// TextFieldHelper: `font.width(s) <= maxTextLineWidth`.
fn sign_fits(&self, value: &str) -> bool {
let Some(edit) = self.sign_edit.as_ref() else {
return false;
};
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) else {
return false;
};
HudBatch::text_width(&self.gui.atlas, value) <= sign.max_line_width()
}
/// Edit the line, then keep the result only when it still fits.
fn sign_change(&mut self, f: impl FnOnce(&mut EditBox)) {
let before = self
.sign_edit
.as_ref()
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
if let Some(edit) = self.sign_edit.as_mut() {
f(&mut edit.edit);
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
if clean != edit.edit.value {
let n = horizon_client::ui::char_len(&clean);
edit.edit.value = clean;
edit.edit.cursor = edit.edit.cursor.min(n);
edit.edit.highlight = edit.edit.highlight.min(n);
}
}
self.sign_keep(before);
}
/// Write the box when the line still fits; otherwise put the old text back.
fn sign_keep(&mut self, before: Option<(String, usize, usize)>) {
let value = self
.sign_edit
.as_ref()
.map(|e| e.edit.value.clone())
.unwrap_or_default();
if self.sign_fits(&value) {
self.sign_write();
} else if let (Some(edit), Some((value, cursor, highlight))) =
(self.sign_edit.as_mut(), before)
{
edit.edit.value = value;
edit.edit.cursor = cursor;
edit.edit.highlight = highlight;
}
}
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line. Paste
/// reads `getClipboardContents` before `insertText`. A paste that would
/// pass the sign's width is refused whole.
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
if shortcut == horizon_client::clipboard::EditShortcut::Paste {
let raw = horizon_client::clipboard::Clipboard::get(&mut self.clipboard);
let text = horizon_client::clipboard::helper_clipboard_contents(&raw);
self.sign_change(|edit| edit.insert_text(&text));
return;
}
let before = self
.sign_edit
.as_ref()
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.shortcut(shortcut, &mut self.clipboard);
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
if clean != edit.edit.value {
let n = horizon_client::ui::char_len(&clean);
edit.edit.value = clean;
edit.edit.cursor = edit.edit.cursor.min(n);
edit.edit.highlight = edit.edit.highlight.min(n);
}
}
self.sign_keep(before);
}
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20),
/// or a click on a line (`EditBox.onClick`).
pub(super) fn sign_edit_click(&mut self, size: [u32; 2]) {
let b = HudBatch::new(size);
let (x, y) = (b.width / 2.0 - 100.0, b.height / 4.0 + 144.0);
@@ -238,7 +305,56 @@ impl Game<'_> {
if mx >= x && mx < x + 200.0 && my >= y && my < y + 20.0 {
self.audio.play_button_click();
self.close_sign_editor();
return;
}
let Some((cx, top, line_height, max_width, lines)) = self.sign_lines(b.width) else {
return;
};
let shift = self.has_shift_down();
for i in 0..lines.len() {
let ly = top + i as f32 * line_height;
let left = cx - max_width / 2.0;
if mx < left || mx >= left + max_width || my < ly || my >= ly + line_height {
continue;
}
let current = self.sign_edit.as_ref().map(|e| e.line);
if current != Some(i) {
self.sign_move_line((i + 4 - current.unwrap_or(0)) & 3);
}
let value = self
.sign_edit
.as_ref()
.map(|e| e.edit.value.clone())
.unwrap_or_default();
let lw = HudBatch::text_width(&self.gui.atlas, &value);
let text_x = cx - (lw as i32 / 2) as f32;
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.x = text_x;
edit.edit.y = ly;
edit.edit.width = max_width;
edit.edit.click(&self.gui.atlas, mx, shift, false);
}
self.sign_write();
return;
}
}
/// Centre x, the first line's y, the line height, the width limit and the four lines.
fn sign_lines(&self, width: f32) -> Option<(f32, f32, f32, f32, Vec<String>)> {
let edit = self.sign_edit.as_ref()?;
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
return None;
};
let text = if edit.front { &sign.front } else { &sign.back };
let line_height = sign.line_height();
let y = if sign.hanging { 125.0 } else { 90.0 };
let top = y - (4.0 * line_height / 2.0).floor();
Some((
width / 2.0,
top,
line_height,
sign.max_line_width(),
text.lines.to_vec(),
))
}
pub(super) fn draw_sign_editor(&self, batch: &mut HudBatch) {
let Some(edit) = &self.sign_edit else { return };
@@ -304,23 +420,48 @@ impl Game<'_> {
let color = rgba((color >> 16) as u8, (color >> 8) as u8, color as u8, 255);
let line_height = sign.line_height();
let midpoint = (4.0 * line_height / 2.0).floor();
let blink = (edit.opened.elapsed().as_millis() / 300) % 2 == 0;
for (i, line) in text.lines.iter().enumerate() {
let lw = HudBatch::text_width(atlas, line);
let shown = if i == edit.line {
edit.edit.value.as_str()
} else {
line.as_str()
};
let lw = HudBatch::text_width(atlas, shown);
let x = cx - (lw as i32 / 2) as f32;
let ly = y + i as f32 * line_height - midpoint;
batch.text(atlas, line, x, ly, color, false);
if i == edit.line
&& self
.text_selection
.covers(sign_line_id(edit.front, i), line)
{
// AbstractSignEditScreen: the selection from its start to its end.
batch.text_highlight(atlas, (x, ly, lw, line_height), true);
batch.text(atlas, shown, x, ly, color, false);
if i != edit.line {
continue;
}
if i == edit.line && blink {
// TextCursorUtils.extractAppendCursor: an underscore after the text.
batch.text(atlas, "_", x + lw, ly, color, false);
let value = &edit.edit.value;
if edit.edit.cursor != edit.edit.highlight {
let (a, b) = (
edit.edit.cursor.min(edit.edit.highlight),
edit.edit.cursor.max(edit.edit.highlight),
);
let pre: String = value.chars().take(a).collect();
let sel: String = value.chars().skip(a).take(b - a).collect();
batch.text_highlight(
atlas,
(
x + HudBatch::text_width(atlas, &pre),
ly,
HudBatch::text_width(atlas, &sel),
line_height,
),
true,
);
}
if edit.edit.cursor_visible() {
let pre: String = value.chars().take(edit.edit.cursor).collect();
batch.fill(
atlas,
x + HudBatch::text_width(atlas, &pre),
ly,
1.0,
line_height,
color,
);
}
}
let (bx, by) = (w / 2.0 - 100.0, h / 4.0 + 144.0);
@@ -344,8 +485,3 @@ impl Game<'_> {
);
}
}
/// The sign line being edited, for Select All.
fn sign_line_id(front: bool, line: usize) -> horizon_client::clipboard::FieldId {
(if front { "sign.front" } else { "sign.back" }, line)
}
+6 -2
View File
@@ -398,8 +398,12 @@ impl GameLevel<'_> {
}
}
PreRemove::Campfire => {
for _ in 0..4 {
self.drop_item_stack(pos, None);
let items = match &entity {
Some(BlockEntity::Campfire(c)) => c.items.clone(),
_ => Default::default(),
};
for stack in items {
self.drop_item_stack(pos, stack);
}
}
PreRemove::Jukebox => {
+263 -47
View File
@@ -135,8 +135,10 @@ pub(super) struct Station {
pub(super) offers: EnchantOffers,
bookcases: i32,
pub(super) anvil: AnvilResult,
/// Anvil rename box text.
/// Anvil rename box text (`AnvilMenu.itemName`).
pub(super) name: String,
/// The box itself (`AnvilScreen`'s `EditBox` of 50, unbordered).
pub(super) rename: horizon_client::ui::EditBox,
/// Stonecutter: the menu's recipe list (horizon_client::menu), its
/// `selectedRecipeIndex` and the screen's first shown index.
pub(super) stone: horizon_client::menu::Stonecutter,
@@ -146,8 +148,6 @@ pub(super) struct Station {
anvil_last: Option<ItemStack>,
/// Anvil on a server: the name last sent (the client menu's `itemName`).
pub(super) anvil_sent: Option<String>,
/// When the screen opened: the rename box has the focus from then.
opened: std::time::Instant,
pub(super) last_sound: u64,
/// Villager trading state.
pub(super) merchant: Option<Box<super::trading::MerchantState>>,
@@ -169,12 +169,12 @@ impl Station {
bookcases: 0,
anvil: AnvilResult::default(),
name: String::new(),
rename: anvil_box(),
stone: Default::default(),
selected: None,
start: 0,
anvil_last: None,
anvil_sent: None,
opened: std::time::Instant::now(),
last_sound: 0,
merchant: None,
beacon: [None; 2],
@@ -868,6 +868,9 @@ impl Game<'_> {
if kind == Kind::Stonecutter && self.stonecutter_click(local) {
return true;
}
if kind == Kind::Anvil && !right && self.anvil_name_click(ox, oy) {
return true;
}
}
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
return true;
@@ -894,6 +897,8 @@ impl Game<'_> {
.as_ref()
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
.unwrap_or_default();
let name = s.name.clone();
s.rename.set_value(&name);
}
self.station_changed();
}
@@ -960,21 +965,29 @@ impl Game<'_> {
let Some(s) = self.station.as_mut() else {
return;
};
let cleared = self.text_selection.take(ANVIL_NAME, &mut s.name);
if backspace {
if !cleared {
s.name.pop();
}
s.rename.delete_chars(-1);
} else if let Some(t) = text {
for c in t.chars().filter(|c| !c.is_control()) {
if s.name.chars().count() < ANVIL_NAME_CHARS {
s.name.push(c);
}
}
s.rename.insert_text(t);
}
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
}
/// `AnvilScreen.keyPressed`: the rename box handles the key.
pub(super) fn station_key(&mut self, key: winit::keyboard::KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let Some(s) = self.station.as_mut() else {
return false;
};
if !s.rename.key(key, ctrl, shift) {
return false;
}
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
true
}
/// Ctrl+A/C/V/X in the anvil's rename box (an `EditBox` of 50) or the
/// recipe book's search box.
pub(super) fn station_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
@@ -985,18 +998,52 @@ impl Game<'_> {
let Some(s) = self.station.as_mut() else {
return;
};
if Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
ANVIL_NAME,
&mut s.name,
shortcut,
ANVIL_NAME_CHARS,
) {
if s.rename.shortcut(shortcut, &mut self.clipboard) {
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
}
}
/// A click in the anvil's name box (`EditBox.onClick`).
fn anvil_name_click(&mut self, ox: f32, oy: f32) -> bool {
let open = self
.station
.as_ref()
.is_some_and(|s| s.kind == Kind::Anvil && s.inputs[0].is_some());
if !open {
return false;
}
let (x, y, w, h) = (ox + 62.0, oy + 24.0, 103.0, 12.0);
let (mx, my) = self.cursor;
if mx < x || my < y || mx >= x + w || my >= y + h {
return false;
}
let shift = self.has_shift_down();
let Some(s) = self.station.as_mut() else {
return false;
};
s.rename.x = x;
s.rename.y = y;
s.rename.width = w;
s.rename.click(&self.gui.atlas, mx, shift, false);
true
}
/// AnvilScreen's name box, drawn after the station picture.
fn draw_anvil_name(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
let Some(s) = self.station.as_mut() else {
return;
};
let edit = &mut s.rename;
edit.x = ox + 62.0;
edit.y = oy + 24.0;
edit.width = 103.0;
edit.height = 12.0;
edit.bordered = false;
edit.focused = true;
edit.text_color = u32::MAX;
edit.max_length = ANVIL_NAME_CHARS;
edit.draw(batch, &self.gui.atlas, None);
}
// ---------------------------------------------------------------- drawing
@@ -1094,6 +1141,10 @@ impl Game<'_> {
let (ox, oy) = self.inventory_origin(batch);
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
let mut anvil_name = false;
// Keep item tooltips as stacks until drawing: text alone loses the
// bundle contents component (including in the player's inventory).
let mut tooltip_stack: Option<ItemStack> = None;
{
let atlas = &self.gui.atlas;
batch.fill(
@@ -1428,18 +1479,8 @@ impl Game<'_> {
);
}
if s.inputs[0].is_some() {
// AnvilScreen's name box: white, 103 wide, no inverted highlight.
horizon_client::ui::append_field(
batch,
atlas,
&s.name,
(ox + 62.0, oy + 24.0, 103.0),
(u32::MAX, true),
ANVIL_NAME_CHARS,
self.text_selection.covers(ANVIL_NAME, &s.name),
false,
s.opened.elapsed().as_millis(),
);
// Drawn once this borrow of the station ends.
anvil_name = true;
}
let cost = s.anvil.cost;
if cost > 0 {
@@ -1579,13 +1620,16 @@ impl Game<'_> {
);
self.draw_item(batch, &result, ox + x, oy + y);
if hover {
tooltip = Some(self.stack_tooltip(&result));
tooltip_stack = Some(result);
}
}
}
}
}
}
if anvil_name {
self.draw_anvil_name(batch, ox, oy);
}
let drag = self.drag_view();
for i in self.station_slots().unwrap_or_default() {
let Some((sx, sy)) = self.slot_position_any(i) else {
@@ -1606,13 +1650,16 @@ impl Game<'_> {
);
if self.carried.is_none()
&& tooltip.is_none()
&& tooltip_stack.is_none()
&& let Some(stack) = self.slot_mut(slot).clone()
{
tooltip = Some(self.stack_tooltip(&stack));
tooltip_stack = Some(stack);
}
}
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = tooltip_stack {
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(lines) = tooltip {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
@@ -1656,6 +1703,8 @@ impl Game<'_> {
};
let name = if let Some(n) = stack.custom_name() {
n.to_owned()
} else if let Some(n) = stack.item_name() {
n
} else if stack.data().is_some_and(|d| d.ominous_banner) {
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
@@ -1682,7 +1731,9 @@ impl Game<'_> {
// ItemContainerContents.addToTooltip.
let hover_name = |s: &ItemStack| {
s.custom_name()
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
.map(str::to_owned)
.or_else(|| s.item_name())
.unwrap_or_else(|| self.pack.names.display_item(&s.item))
};
if display.shows("minecraft:container") {
for text in
@@ -1907,32 +1958,65 @@ impl Game<'_> {
if lines.is_empty() {
return;
}
let lines: Vec<_> = lines
let lines = Self::resolved(lines);
let atlas = &self.gui.atlas;
let (x, y, w, h) =
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), 0.0);
Self::tooltip_box(batch, atlas, x, y, w, h);
Self::tooltip_text(batch, atlas, &lines, x, y);
}
/// A tooltip's lines with their legacy codes resolved and their colours.
fn resolved<T: Clone + Into<horizon_client::text::Text>>(
lines: &[(T, u32)],
) -> Vec<(horizon_client::text::Text, u32)> {
lines
.iter()
.map(|(text, color)| {
let text: horizon_client::text::Text = text.clone().into();
(text.resolve_legacy(), *color)
})
.collect();
let atlas = &self.gui.atlas;
.collect()
}
/// The tooltip's text's top-left corner and the box's size: beside the
/// cursor, kept on screen, `extra` pixels taller than the text.
fn tooltip_geometry(
atlas: &horizon_client::hud::HudAtlas,
lines: &[(horizon_client::text::Text, u32)],
(x, y): (f32, f32),
(width, height): (f32, f32),
extra: f32,
) -> (f32, f32, f32, f32) {
let w = lines
.iter()
.map(|(t, _)| atlas.font.width_text(t))
.fold(0.0, f32::max);
let h = if lines.len() == 1 {
.fold(0.0, f32::max)
.max(if extra > 0.0 { 96.0 } else { 0.0 });
let text_h = if lines.len() == 1 {
8.0
} else {
8.0 + 2.0 + (lines.len() - 1) as f32 * 10.0
};
let h = text_h + extra;
let mut x = x + 12.0;
let mut y = y - 12.0;
if x + w > batch.width - 4.0 {
if x + w > width - 4.0 {
x = (x - 28.0 - w).max(4.0);
}
if y + h + 3.0 > batch.height {
y = batch.height - h - 3.0;
if y + h + 3.0 > height {
y = height - h - 3.0;
}
y = y.max(4.0);
(x, y, w, h)
}
/// The box and its frame sprites.
fn tooltip_box(
batch: &mut HudBatch,
atlas: &horizon_client::hud::HudAtlas,
x: f32,
y: f32,
w: f32,
h: f32,
) {
batch.fill(atlas, x - 3.0, y - 4.0, w + 6.0, 1.0, rgba(16, 0, 16, 240));
batch.fill(
atlas,
@@ -1968,12 +2052,137 @@ impl Game<'_> {
h + 4.0,
rgba(60, 0, 190, 80),
);
}
fn tooltip_text(
batch: &mut HudBatch,
atlas: &horizon_client::hud::HudAtlas,
lines: &[(horizon_client::text::Text, u32)],
x: f32,
y: f32,
) {
let mut ty = y;
for (i, (text, color)) in lines.iter().enumerate() {
batch.text_styled(atlas, text, x, ty, *color, true);
ty += if i == 0 { 12.0 } else { 10.0 };
}
}
/// A stack's tooltip: its lines, and under them a bundle's contents
/// (`ClientBundleTooltip`: the 4 wide grid, the `+N` of what it leaves
/// out, and the weight bar).
pub(super) fn draw_stack_tooltip(
&mut self,
batch: &mut HudBatch,
stack: &ItemStack,
x: f32,
y: f32,
) {
let lines = Self::resolved(&self.stack_tooltip(stack));
if lines.is_empty() {
return;
}
// The component's height does not depend on where it is drawn, so a
// probe at the origin gives the box its extra height.
let probe = horizon_client::bundle::tooltip_layout(self.res.items(), stack, 0.0, 0.0, 96.0);
let extra = probe.as_ref().map_or(0.0, |l| l.height + 2.0);
let atlas = &self.gui.atlas;
let (x, y, w, h) =
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), extra);
Self::tooltip_box(batch, atlas, x, y, w, h);
let text_h = h - extra;
Self::tooltip_text(batch, atlas, &lines, x, y);
let Some(layout) =
horizon_client::bundle::tooltip_layout(self.res.items(), stack, x, y + text_h + 2.0, w)
else {
return;
};
self.draw_bundle_tooltip(batch, &layout);
}
/// `ClientBundleTooltip.extractImage`: the slots, the count past them and
/// the progress bar.
fn draw_bundle_tooltip(
&mut self,
batch: &mut HudBatch,
layout: &horizon_client::bundle::TooltipLayout,
) {
for (at, stack) in &layout.slots {
// Each draw takes the atlas on its own: the item's needs `&mut self`.
horizon_client::ui::nine_slice(
batch,
&self.gui.atlas,
"gui/sprites/container/bundle/slot_background",
at[0],
at[1],
24.0,
24.0,
u32::MAX,
);
let stack = stack.clone();
let [x, y] = *at;
self.draw_item(batch, &stack, x + 4.0, y + 4.0);
}
let atlas = &self.gui.atlas;
if let Some((at, hidden)) = layout.overflow {
let label = horizon_client::text::Text::literal(format!("+{hidden}"));
let w = atlas.font.width_text(&label);
batch.text_styled(
atlas,
&label,
at[0] + 12.0 - (w / 2.0).floor(),
at[1] + 10.0,
u32::MAX,
true,
);
}
if let Some((at, description)) = layout
.empty_description
.zip(Some("item.minecraft.bundle.empty.description"))
{
let text = horizon_client::text::Text::translate(description, &[]);
let mut ty = at[1];
for line in atlas.font.split_lines(&text, layout.width) {
batch.text_styled(atlas, &line.0, at[0], ty, 0xFFAA_AAAA, false);
ty += 9.0;
}
}
let [bx, by] = layout.bar;
let fill_sprite = if layout.full {
"gui/sprites/container/bundle/bundle_progressbar_full"
} else {
"gui/sprites/container/bundle/bundle_progressbar_fill"
};
horizon_client::ui::nine_slice(
batch,
atlas,
fill_sprite,
bx + 1.0,
by,
layout.fill,
13.0,
u32::MAX,
);
horizon_client::ui::nine_slice(
batch,
atlas,
"gui/sprites/container/bundle/bundle_progressbar_border",
bx,
by,
96.0,
13.0,
u32::MAX,
);
if let Some(key) = layout.label {
let label = horizon_client::text::Text::translate(key, &[]);
let w = atlas.font.width_text(&label);
batch.text_styled(
atlas,
&label,
bx + 48.0 - (w / 2.0).floor(),
by + 3.0,
u32::MAX,
true,
);
}
}
/// Hovered-slot tooltip on the plain inventory, crafting and container screens.
pub(super) fn draw_slot_tooltip(&mut self, batch: &mut HudBatch) {
if self.carried.is_some() {
@@ -1984,8 +2193,7 @@ impl Game<'_> {
};
let stack = self.slot_view(slot);
if let Some(stack) = stack {
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(text) = self.crafter_tooltip(slot) {
self.draw_tooltip_lines(
batch,
@@ -2041,5 +2249,13 @@ impl Game<'_> {
}
/// The anvil's rename box (`AnvilScreen`: `setMaxLength(50)`).
const ANVIL_NAME: horizon_client::clipboard::FieldId = ("anvil.name", 0);
const ANVIL_NAME_CHARS: usize = 50;
fn anvil_box() -> horizon_client::ui::EditBox {
let mut edit = horizon_client::ui::EditBox::new(0.0, 0.0, 103.0, 12.0);
edit.bordered = false;
edit.max_length = ANVIL_NAME_CHARS;
edit.text_color = u32::MAX;
edit.focused = true;
edit
}
@@ -210,6 +210,7 @@ impl Game<'_> {
if bg != 0 {
batch.fill(
atlas,
// submitTextBackground(-1, -1, width, height): corners, not a size.
-1.0,
-1.0,
width + 1.0,
+1 -2
View File
@@ -803,8 +803,7 @@ impl Game<'_> {
text.split('\n').map(|l| (l.to_owned(), u32::MAX)).collect();
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
} else if let Some(stack) = tooltip {
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);
}
true
}
@@ -261,6 +261,7 @@ pub(super) fn make_streaming(
res.threads(),
)
};
streaming.forced = save.load_forced_chunks();
streaming.radius = settings.render_distance;
streaming.biome_blend = settings.biome_blend;
streaming.smooth_lighting = settings.smooth_lighting;
Loaded 100 of 420 files, more files were not shown because too many files have changed in this diff. Show more