Merge main into this branch (resolves the conflict with main)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-27 03:58:55 +01:00
commit 0ab24d0805
267 files changed
+16023 -1574

No files matched your search

+1
View File
@@ -144,6 +144,7 @@ jobs:
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
+2 -2
View File
@@ -25,8 +25,8 @@ jobs:
parity-main:
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
# behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs
# make a core-crate run several times longer, and a failure here bisects and comments on merges.
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
+43 -62
View File
@@ -1,6 +1,6 @@
name: parity
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's
# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the
# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
on:
workflow_call:
@@ -13,6 +13,13 @@ on:
description: The commit to check out and test
type: string
required: true
full:
description: >-
The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it
runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is
busy the job waits for it.
type: boolean
default: false
permissions:
contents: read
env:
@@ -21,40 +28,53 @@ env:
jobs:
parity:
runs-on: [self-hosted, horizon-game]
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }}
# A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4
# harnesses at a time) up to a few hours, and any pull request's job may land there.
timeout-minutes: ${{ inputs.full && 360 || 180 }}
steps:
- uses: actions/checkout@v4
with:
# The commit the event names: a fork's later pushes need another run-parity label.
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
# main into it.
ref: ${{ inputs.head }}
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- name: The head merged into main (what merging it would land)
id: merge
env:
BASE_SHA: ${{ inputs.base }}
HEAD_SHA: ${{ inputs.head }}
run: |
# Harness fixes and game fixes that reached main since the branch was made then apply here too: a head
# tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before
# #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull
# request's own changes. A head that conflicts with main is tested alone.
set -euo pipefail
git merge --abort 2>/dev/null || true
git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main
main=$(git rev-parse origin/main)
if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then
echo "base=$main" >> "$GITHUB_OUTPUT"
echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY"
else
git merge --abort
echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT"
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
fi
- uses: dtolnay/rust-toolchain@stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
env:
BASE_SHA: ${{ inputs.base }}
run: |
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
set -euo pipefail
if git rev-parse --verify -q origin/main >/dev/null \
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
bash "$RUNNER_TEMP/vanilla-data.sh"
else
bash tools/ci/vanilla-data.sh
fi
run: bash tools/ci/vanilla-data.sh
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
env:
BASE_SHA: ${{ inputs.base }}
BASE_SHA: ${{ steps.merge.outputs.base }}
run: |
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
# the tool nor the number, so there is nothing to check.
# A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number.
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
exit 0
@@ -65,47 +85,8 @@ jobs:
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ inputs.base }}
run: |
# main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
# branch made before a better selection landed gets it too; the head's own runner then runs those
# harnesses. A branch that changes the selection itself selects with its own code.
set -euo pipefail
own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
fork_point=$(git merge-base "$BASE_SHA" HEAD)
if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
own
exit
fi
had_reach=$(git ls-files tools/parity_reach.py)
git show origin/main:tools/parity.py > tools/parity.py
git show origin/main:tools/parity_reach.py > tools/parity_reach.py
status=0
python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
git checkout -- tools/parity.py
if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
cat .local/parity-selection.txt
if [ "$status" != 0 ]; then
echo "main's selection failed: the head's own selection runs"
own
exit
fi
names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
# The summary line says how many would run: a count that doesn't match the names read (a format change)
# must not let the job pass untested.
expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
count=$(printf '%s' "$names" | grep -c . || true)
if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
own
exit
fi
if [ "$count" = 0 ]; then
echo "Nothing to run."
exit 0
fi
# shellcheck disable=SC2086
python3 tools/parity.py $names --timeout 2400
BASE_SHA: ${{ steps.merge.outputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
+1
View File
@@ -22,3 +22,4 @@ jobs:
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
+6 -4
View File
@@ -34,7 +34,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -118,16 +120,16 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
Generated
+33
View File
@@ -216,6 +216,13 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "beacons"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -1365,6 +1372,8 @@ dependencies = [
"glam",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
@@ -1396,6 +1405,23 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-client-core"
version = "0.1.0"
dependencies = [
"horizon-world",
"serde_json",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-protocol",
"horizon-world",
]
[[package]]
name = "horizon-core"
version = "0.1.0"
@@ -2971,6 +2997,13 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "recipes"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
+40 -1
View File
@@ -1,6 +1,45 @@
[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-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"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-threads",
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-command",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
"crates/horizon-protocol",
"crates/horizon-theme",
"crates/horizon-prepare",
"crates/horizon-telemetry",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mod-sdk",
"crates/horizon-mods",
"crates/horizon-mods/tests/guest",
"crates/horizon-mods/tests/ui_guest",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
]
resolver = "2"
[workspace.package]
+10 -1
View File
@@ -200,7 +200,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
return Err(format!("it didn't start: {reason}"));
}
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
@@ -266,8 +266,17 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::SetBlocks {
dimension, blocks, ..
} => {
format!("set {} block(s) in {dimension}", blocks.len())
}
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::PlaceRecipe { book_id, all } => {
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
}
Output::Give { item, count, .. } => format!("gave {count} {item}"),
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-client-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
[dependencies]
horizon-world = { path = "../horizon-world" }
serde_json = { version = "1.0.151", features = ["raw_value"] }
[lints]
workspace = true
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The loaded `SignText` of one side, or the empty text.
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
pub fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
@@ -252,7 +252,7 @@ pub fn to_text_with(
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
resolve(sel)
} else if let Some(key) = o.get("keybind").and_then(Value::as_str) {
crate::keybinds::display_name(key)
crate::key_display::display_name(key)
} else {
Text::empty()
};
@@ -292,27 +292,4 @@ mod tests {
assert_eq!(GameRules::load(&dir), rules);
let _ = fs::remove_dir_all(dir);
}
/// The world's save (Level.write) writes the rules as they are now, and a
/// world opened again reads them back (#74).
#[test]
fn level_save_keeps_changed_rules() {
let dir =
std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut level = crate::save::Level::default();
level.write(&dir).unwrap();
// Rules changed while playing, then saved and loaded again.
let mut rules = GameRules::load(&dir);
assert_eq!(rules, GameRules::default());
assert!(rules.set("advance_time", Value::Bool(false)));
assert!(rules.set("advance_weather", Value::Bool(false)));
assert!(rules.set("random_tick_speed", Value::Int(0)));
level.game_rules = rules.by_name();
level.write(&dir).unwrap();
assert_eq!(GameRules::load(&dir), rules);
let read = crate::save::Level::read(&dir).unwrap();
assert_eq!(read.game_rules, rules.by_name());
assert_eq!(read.game_rule("advance_time"), 0);
let _ = fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,18 @@
//! Resource identifiers (`Identifier`).
/// Identifier.parse: `ns:path` or `path`, validated.
pub fn parse_identifier(raw: &str) -> Option<String> {
let (ns, path) = match raw.split_once(':') {
Some(("", path)) => ("minecraft", path),
Some((ns, path)) => (ns, path),
None => ("minecraft", raw),
};
let ns_ok = ns != ".."
&& ns
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
@@ -0,0 +1,38 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
&& !crate::lang::has(key)
{
return Text::translate("key.mouse", &[Text::literal(n)]);
}
Text::translate(key, &[])
}
@@ -42,8 +42,12 @@ struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
+23
View File
@@ -0,0 +1,23 @@
//! The client's lowest layer, below commands and gameplay: chat text and
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
//! bars, game rules, block entity NBT tables, and the registry, particle and
//! sound source tables commands name. horizon-client re-exports each module
//! under its old path (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
pub mod component;
pub mod fastutil_order;
pub mod gamerules;
pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod particle_type;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -0,0 +1,209 @@
//! Particle types (`ParticleTypes`): the vanilla particle types with a client
//! implementation, by registry name, and their per-type flags.
macro_rules! particle_types {
($($variant:ident = $name:literal),* $(,)?) => {
/// Vanilla particle types with a client implementation here.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ParticleType { $($variant),* }
impl ParticleType {
pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*];
/// Registry path (`minecraft:<name>`), also the sprite-set file name.
pub fn name(self) -> &'static str {
match self { $(ParticleType::$variant => $name),* }
}
pub fn from_name(name: &str) -> Option<Self> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
match name { $($name => Some(ParticleType::$variant),)* _ => None }
}
}
};
}
particle_types! {
Block = "block",
BlockCrumble = "block_crumble",
Trail = "trail",
DustPillar = "dust_pillar",
FallingDust = "falling_dust",
Smoke = "smoke",
LargeSmoke = "large_smoke",
WhiteSmoke = "white_smoke",
Flame = "flame",
SoulFireFlame = "soul_fire_flame",
CopperFireFlame = "copper_fire_flame",
SmallFlame = "small_flame",
Lava = "lava",
DrippingWater = "dripping_water",
FallingWater = "falling_water",
DrippingLava = "dripping_lava",
FallingLava = "falling_lava",
LandingLava = "landing_lava",
DrippingHoney = "dripping_honey",
FallingHoney = "falling_honey",
LandingHoney = "landing_honey",
FallingNectar = "falling_nectar",
FallingSporeBlossom = "falling_spore_blossom",
SporeBlossomAir = "spore_blossom_air",
DrippingObsidianTear = "dripping_obsidian_tear",
FallingObsidianTear = "falling_obsidian_tear",
LandingObsidianTear = "landing_obsidian_tear",
DrippingDripstoneWater = "dripping_dripstone_water",
FallingDripstoneWater = "falling_dripstone_water",
DrippingDripstoneLava = "dripping_dripstone_lava",
FallingDripstoneLava = "falling_dripstone_lava",
Splash = "splash",
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
Underwater = "underwater",
Portal = "portal",
ReversePortal = "reverse_portal",
Enchant = "enchant",
Nautilus = "nautilus",
VaultConnection = "vault_connection",
Crit = "crit",
EnchantedHit = "enchanted_hit",
DamageIndicator = "damage_indicator",
Heart = "heart",
AngryVillager = "angry_villager",
HappyVillager = "happy_villager",
Composter = "composter",
Mycelium = "mycelium",
Dolphin = "dolphin",
EggCrack = "egg_crack",
Poof = "poof",
Explosion = "explosion",
ExplosionEmitter = "explosion_emitter",
CampfireCosySmoke = "campfire_cosy_smoke",
CampfireSignalSmoke = "campfire_signal_smoke",
Note = "note",
Dust = "dust",
TotemOfUndying = "totem_of_undying",
Firework = "firework",
EndRod = "end_rod",
Ash = "ash",
WhiteAsh = "white_ash",
CrimsonSpore = "crimson_spore",
WarpedSpore = "warped_spore",
CherryLeaves = "cherry_leaves",
PaleOakLeaves = "pale_oak_leaves",
TintedLeaves = "tinted_leaves",
Effect = "effect",
InstantEffect = "instant_effect",
EntityEffect = "entity_effect",
Witch = "witch",
Infested = "infested",
Cloud = "cloud",
Sneeze = "sneeze",
Snowflake = "snowflake",
Soul = "soul",
SculkSoul = "sculk_soul",
DragonBreath = "dragon_breath",
Gust = "gust",
SmallGust = "small_gust",
GustEmitterLarge = "gust_emitter_large",
GustEmitterSmall = "gust_emitter_small",
DustColorTransition = "dust_color_transition",
SculkCharge = "sculk_charge",
SculkChargePop = "sculk_charge_pop",
Shriek = "shriek",
SonicBoom = "sonic_boom",
Vibration = "vibration",
SweepAttack = "sweep_attack",
Glow = "glow",
WaxOn = "wax_on",
WaxOff = "wax_off",
ElectricSpark = "electric_spark",
Scrape = "scrape",
SquidInk = "squid_ink",
GlowSquidInk = "glow_squid_ink",
Item = "item",
ItemSlime = "item_slime",
ItemSnowball = "item_snowball",
ItemCobweb = "item_cobweb",
SulfurCubeGoo = "sulfur_cube_goo",
Flash = "flash",
Firefly = "firefly",
TrialSpawnerDetection = "trial_spawner_detection",
TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous",
TrialOmen = "trial_omen",
RaidOmen = "raid_omen",
OminousSpawning = "ominous_spawning",
Spit = "spit",
Fishing = "fishing",
DustPlume = "dust_plume",
RedPoplarLeaves = "red_poplar_leaves",
OrangePoplarLeaves = "orange_poplar_leaves",
YellowPoplarLeaves = "yellow_poplar_leaves",
PauseMobGrowth = "pause_mob_growth",
ResetMobGrowth = "reset_mob_growth",
BlockMarker = "block_marker",
}
impl ParticleType {
/// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`):
/// spawned regardless of distance.
pub fn override_limiter(self) -> bool {
matches!(
self,
Self::BlockMarker
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
| Self::Gust
| Self::GustEmitterLarge
| Self::GustEmitterSmall
| Self::SonicBoom
| Self::SculkCharge
| Self::SculkChargePop
| Self::Vibration
| Self::Poof
| Self::Spit
| Self::SquidInk
| Self::SweepAttack
| Self::CampfireCosySmoke
| Self::CampfireSignalSmoke
| Self::GlowSquidInk
| Self::Glow
| Self::WaxOn
| Self::WaxOff
| Self::ElectricSpark
| Self::Scrape
| Self::TrialSpawnerDetection
| Self::TrialSpawnerDetectionOminous
| Self::VaultConnection
| Self::OminousSpawning
)
}
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
/// hardcodes the translucent layer, drawn after translucent terrain.
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
/// sprite set on the engine.
pub fn translucent_layer(self) -> bool {
match self {
// CampfireSmokeParticle.
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
// PlayerCloudParticle.
| Self::Cloud | Self::Sneeze
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
| Self::Vibration | Self::Firefly | Self::Flash
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
// SparkParticle (firework), SquidInkParticle, TotemParticle.
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
| Self::TotemOfUndying
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
| Self::VaultConnection => true,
_ => false,
}
}
}
@@ -0,0 +1,188 @@
//! Registry contents and source state that commands validate and complete
//! arguments against (what the server's registry access holds).
use std::collections::BTreeMap;
/// Registry contents and source state used to validate and complete arguments.
#[derive(Clone, Debug, Default)]
pub struct Registries {
/// Permission level of the command source.
pub permission: u8,
pub items: Vec<String>,
pub item_tags: Vec<String>,
/// Block id -> (property, allowed values).
pub blocks: BTreeMap<String, Vec<(String, Vec<String>)>>,
pub block_tags: Vec<String>,
/// Entity types that `summon` can create.
pub summonable: Vec<String>,
/// Every entity type (selector `type=`).
pub entity_types: Vec<String>,
pub effects: Vec<String>,
pub enchantments: Vec<String>,
pub structures: Vec<String>,
pub biomes: Vec<String>,
pub dimensions: Vec<String>,
pub clocks: Vec<String>,
pub timelines: Vec<String>,
pub time_markers: Vec<String>,
pub advancements: Vec<String>,
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
pub tracked: Vec<String>,
/// Loaded datapack functions (`ns:path`) and function tags (`ns:path`).
pub functions: Vec<String>,
/// `/data ... storage` ids.
pub storages: Vec<String>,
/// Boss bar ids and datapack predicate ids.
pub bossbars: Vec<String>,
pub predicates: Vec<String>,
pub item_modifiers: Vec<String>,
pub schedules: Vec<String>,
pub stopwatches: Vec<String>,
pub recipes: Vec<String>,
pub sounds: Vec<String>,
pub damage_types: Vec<String>,
pub attributes: Vec<String>,
pub loot_tables: Vec<String>,
pub templates: Vec<String>,
pub random_sequences: Vec<String>,
pub datapacks_enabled: Vec<String>,
pub datapacks_disabled: Vec<String>,
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
/// Blocks whose default state has a block entity.
pub block_entities: std::collections::BTreeSet<String>,
/// Block tag -> member blocks.
pub block_tag_members: BTreeMap<String, Vec<String>>,
/// Data component types with a codec (not transient).
pub persistent_components: Vec<String>,
/// Recipes `/recipe` can give (not special).
pub learnable_recipes: Vec<String>,
/// Advancement id -> criterion names.
pub advancement_criteria: BTreeMap<String, Vec<String>>,
/// World clock -> time markers shown in commands.
pub clock_time_markers: BTreeMap<String, Vec<String>>,
/// Trigger objectives the source may use (`/trigger` suggestions).
pub trigger_objectives: Vec<String>,
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
}
/// The ids and tag ids of one registry.
#[derive(Clone, Debug, Default)]
pub struct RegistryIds {
pub ids: Vec<String>,
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
impl Registries {
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
/// mob effects, data component types and dialogs, block entities and tag
/// members. Registries that data packs reload stay with the game.
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
fn strings(v: &serde_json::Value) -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
let fill = |field: &mut Vec<String>, key: &str| {
if field.is_empty() {
*field = strings(&r[key]);
}
};
fill(&mut self.effects, "effects");
fill(&mut self.enchantments, "enchantments");
fill(&mut self.attributes, "attributes");
fill(&mut self.damage_types, "damage_types");
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
"minecraft:loot_table",
"minecraft:predicate",
"minecraft:item_modifier",
];
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
continue;
}
self.by_registry.insert(
name.clone(),
RegistryIds {
ids: strings(&entry["ids"]),
tags: strings(&entry["tags"]),
},
);
}
if self.block_entities.is_empty() {
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
}
if self.block_tag_members.is_empty() {
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
self.block_tag_members.insert(tag.clone(), strings(members));
}
}
if self.clock_time_markers.is_empty() {
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
self.clock_time_markers
.insert(clock.clone(), strings(markers));
}
}
if self.advancement_criteria.is_empty() {
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
self.advancement_criteria
.insert(id.clone(), strings(criteria));
}
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.ids;
}
match registry {
"minecraft:item" => &self.items,
"minecraft:entity_type" => &self.entity_types,
"minecraft:mob_effect" => &self.effects,
"minecraft:enchantment" => &self.enchantments,
"minecraft:worldgen/structure" => &self.structures,
"minecraft:worldgen/biome" => &self.biomes,
"minecraft:world_clock" => &self.clocks,
"minecraft:timeline" => &self.timelines,
"minecraft:attribute" => &self.attributes,
"minecraft:damage_type" => &self.damage_types,
"minecraft:sound_event" => &self.sounds,
"minecraft:predicate" => &self.predicates,
"minecraft:item_modifier" => &self.item_modifiers,
"minecraft:loot_table" => &self.loot_tables,
"minecraft:recipe" => &self.recipes,
"minecraft:advancement" => &self.advancements,
_ => EMPTY,
}
}
/// The tag ids of a registry (sorted).
pub fn tags(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.tags;
}
match registry {
"minecraft:item" => &self.item_tags,
"minecraft:block" => &self.block_tags,
_ => EMPTY,
}
}
}
@@ -2,9 +2,9 @@
//! `ObjectiveCriteria`, `NumberFormat`) and its saved data
//! (`data/minecraft/scoreboard.dat`, `ScoreboardSaveData.Packed`).
use crate::{
command::Registries,
component,
fastutil_order::{HashSetOrder, OpenOrder},
registries::Registries,
text::{self, Style, Text},
};
use horizon_world::nbt::Tag;
@@ -1266,7 +1266,7 @@ fn entity_uuids(tag: &mut Tag) {
})
.collect::<Option<Vec<i32>>>(),
Tag::String(s) => {
crate::save::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
crate::uuid::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
}
_ => None,
};
File renamed without changes.
@@ -0,0 +1,7 @@
//! Sound sources (`SoundSource`), by name.
/// SoundSource names in vanilla order.
pub const SOUND_SOURCES: [&str; 11] = [
"master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient",
"voice", "ui",
];
@@ -67,7 +67,7 @@ pub struct Style {
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
crate::command::args::parse_identifier(v.as_str()?)
crate::identifier::parse_identifier(v.as_str()?)
}
impl Style {
pub fn color(c: u32) -> Self {
+24
View File
@@ -0,0 +1,24 @@
//! UUIDs as vanilla's four-int form, to and from their text form.
pub fn uuid_text(u: [i32; 4]) -> String {
let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
return None;
}
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
}
Some(out)
}
+2
View File
@@ -9,6 +9,8 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> {
pose,
num("maxCrossbowChageDuration"),
num("ticksUsingItem"),
false,
);
}
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
@@ -1057,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
effects: Vec::new(),
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
@@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
.iter()
.filter_map(|e| match e {
MotionEvent::Fall(e) => Some(e.trace()),
// The oracle's player wears no armor; the game applies this one.
MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None,
MotionEvent::Health(e) => Some(e.trace()),
MotionEvent::Snowflake { position, velocity } => Some(format!(
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
+24 -1
View File
@@ -1,7 +1,7 @@
//! Biome colours from the pinned biome definitions and colormaps
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
use crate::Result;
use horizon_core::{noise::SimplexNoise, random::LegacyRandom};
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
@@ -33,6 +33,8 @@ pub struct BiomeInfo {
pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
/// `temperature_modifier: frozen` (the frozen oceans).
pub frozen: bool,
pub downfall: f32,
pub precipitation: bool,
pub grass: u32,
@@ -45,6 +47,26 @@ pub struct BiomeInfo {
pub fog: Option<u32>,
pub water_fog: u32,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
/// with height above `sea_level + 17`.
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
use horizon_world::worldgen::biome_temperature::{
BiomeTemperatureSettings, TemperatureModifier,
};
BiomeTemperatureSettings {
base_temperature: self.temperature,
modifier: if self.frozen {
TemperatureModifier::Frozen
} else {
TemperatureModifier::None
},
has_precipitation: self.precipitation,
downfall: self.downfall,
}
.height_adjusted(pos, sea_level)
}
}
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
@@ -135,6 +157,7 @@ impl Biomes {
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
@@ -648,6 +648,61 @@ impl Conduit {
}
}
/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ConduitCheck {
/// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`.
pub toggled: Option<bool>,
/// The frame blocks counted, while the conduit is active: `applyEffects`
/// and `updateAndAttackTarget` follow (the attack needs 42 or more).
pub frames: Option<usize>,
}
/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks.
#[derive(Clone, Debug, Default)]
pub struct ConduitServer {
shape: Conduit,
/// `destroyTarget`: the mob id of the enemy being struck.
pub target: Option<u32>,
next_ambient_sound: i64,
}
impl ConduitServer {
/// The `gameTime % 40` part of `serverTick`: the shape is checked again.
pub fn check(
&mut self,
pos: [i32; 3],
game_time: i64,
is_water: impl Fn([i32; 3]) -> bool,
is_frame: impl Fn([i32; 3]) -> bool,
) -> Option<ConduitCheck> {
if game_time % 40 != 0 {
return None;
}
let active = self.shape.update_shape(pos, &is_water, &is_frame);
let toggled = (active != self.shape.is_active).then_some(active);
self.shape.is_active = active;
Some(ConduitCheck {
toggled,
frames: active.then_some(self.shape.effect_blocks.len()),
})
}
/// The sounds of an active conduit at the end of `serverTick`: whether
/// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every
/// 60 to 99 ticks) play.
pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) {
if !self.shape.is_active {
return (false, false);
}
let short = game_time > self.next_ambient_sound;
if short {
self.next_ambient_sound =
game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0));
}
(game_time % 80 == 0, short)
}
}
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Skull {
@@ -832,6 +887,8 @@ pub struct BlockEntityAnims {
pub gateways: FxHashMap<[i32; 3], EndGateway>,
/// The integrated server's copies of the gateways (`portalTick`).
pub gateway_servers: FxHashMap<[i32; 3], EndGateway>,
/// The integrated server's copies of the conduits (`serverTick`).
pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>,
/// The client level's random (conduit particles).
pub random: LegacyRandom,
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
@@ -854,6 +911,7 @@ impl Default for BlockEntityAnims {
pots: FxHashMap::default(),
gateways: FxHashMap::default(),
gateway_servers: FxHashMap::default(),
conduit_servers: FxHashMap::default(),
random: LegacyRandom::new(0x6c6576656c),
kinds: Vec::new(),
present: Vec::new(),
@@ -909,6 +967,7 @@ impl BlockEntityAnims {
self.pots.retain(|p, _| keep(*p));
self.gateways.retain(|p, _| keep(*p));
self.gateway_servers.retain(|p, _| keep(*p));
self.conduit_servers.retain(|p, _| keep(*p));
}
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
+66 -9
View File
@@ -95,6 +95,15 @@ impl ItemEntity {
self.health -= fire_damage as i32;
}
}
// CactusBlock.entityInside: `entity.hurt(cactus, 1.0)`. An item has no
// hurt cooldown, so it loses 1 health every tick it touches a cactus
// (5 health: gone in 5 ticks). Fire resistance does not help;
// `ItemStack.canBeHurtBy` only skips it for a damage_resistant stack.
if crate::motion::environment::cactus_contact(world, registry, self.bounds())
&& !items.resists_cactus(&self.stack)
{
self.health -= 1;
}
ItemEnvironmentTick {
alive: self.health > 0,
lava_hurt,
@@ -385,8 +394,29 @@ pub struct ExperienceOrb {
pub value: u32,
pub age: u32,
pub on_ground: bool,
/// `ExperienceOrb.count`: orbs merged into this one; each pickup gives
/// `value` once and takes one off.
pub count: u32,
/// `Entity.tickCount`, which paces `ExperienceOrb.scanForMerges`.
pub ticks: u32,
}
impl ExperienceOrb {
pub fn bounds(&self) -> Aabb {
let h = 0.25;
Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 0.5,
self.position.z + h,
),
)
}
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
/// the same value and ids a multiple of 40 apart.
pub fn can_merge(&self, id: u32, value: u32) -> bool {
self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0
}
/// ExperienceOrb.getExperienceValue: the largest orb size not above `value`.
pub fn split(value: u32) -> u32 {
[2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1]
@@ -416,7 +446,9 @@ impl ExperienceOrb {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
next_float: &mut dyn FnMut() -> f32,
) -> bool {
self.ticks += 1;
self.previous = self.position;
let block = world.block(
self.position.x.floor() as i32,
@@ -432,6 +464,14 @@ impl ExperienceOrb {
} else {
self.velocity.y -= 0.03;
}
if facts.get(block).fluid == Fluid::Lava {
// ExperienceOrb.tick: lava throws the orb back out.
self.velocity = Vec3::new(
f64::from((next_float() - next_float()) * 0.2),
f64::from(0.2f32),
f64::from((next_float() - next_float()) * 0.2),
);
}
if let Some(p) = player {
let d = [
(p[0] - self.position.x) / 8.0,
@@ -449,15 +489,7 @@ impl ExperienceOrb {
);
}
}
let h = 0.25;
let bounds = Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 0.5,
self.position.z + h,
),
);
let bounds = self.bounds();
let mut scene = SceneCollisions {
world,
binding: collision,
@@ -494,6 +526,31 @@ impl ExperienceOrb {
self.age < 6000
}
}
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
/// adding their counts and keeping the smaller age.
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
let mut removed = vec![false; orbs.len()];
for i in 0..orbs.len() {
if removed[i] || orbs[i].ticks % 20 != 1 {
continue;
}
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
for j in 0..orbs.len() {
if j != i
&& !removed[j]
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
&& orbs[j].bounds().intersects(area)
{
orbs[i].count += orbs[j].count;
orbs[i].age = orbs[i].age.min(orbs[j].age);
removed[j] = true;
}
}
}
let mut removed = removed.into_iter();
orbs.retain(|_| !removed.next().unwrap_or(false));
}
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
#[derive(Clone, Copy, Debug, Default)]
pub struct Experience {
+300 -20
View File
@@ -180,7 +180,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("Silent", m.silent),
("NoGravity", m.no_gravity),
("Glowing", m.glowing),
("Invulnerable", m.invulnerable_flag),
] {
if on {
map.insert(key.into(), Tag::Byte(1));
@@ -188,6 +187,26 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
);
map.insert(
"LeftHanded".into(),
Tag::Byte(i8::from(m.extra.left_handed)),
);
// Keys of older formats that structure templates still carry.
for key in [
"HandItems",
"ArmorItems",
"HandDropChances",
"ArmorDropChances",
"Dimension",
"HurtByTimestamp",
"Leashed",
] {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
if m.ticks_frozen > 0 {
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
} else {
@@ -197,7 +216,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("LeftHanded", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
(
"Brain",
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
@@ -209,7 +228,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
// Only breedable animals have an age; a loaded creature that was saved without
// one (a frog) stays without.
let aged = raw.is_none_or(|r| r.get("Age").is_some());
if m.kind.category == "creature" && aged {
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
@@ -260,11 +279,28 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
if id == "minecraft:piglin_brute" {
map.remove(&"CannotHunt".into());
}
map.insert(
"IsImmuneToZombification".into(),
Tag::Byte(i8::from(m.extra.flag2)),
);
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
if id == "minecraft:piglin" {
// InventoryCarrier.writeInventoryToTag.
map.insert(
"Inventory".into(),
Tag::List(
m.extra
.inventory
.iter()
.map(crate::save::item_to_nbt)
.collect(),
),
);
}
}
"minecraft:horse"
| "minecraft:donkey"
@@ -357,6 +393,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"minecraft:bat" => {
map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag)));
}
"minecraft:turtle" => {
map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag)));
if let Some(home) = m.extra.home {
map.insert("home_pos".into(), Tag::IntArray(home.to_vec()));
}
}
"minecraft:creaking" => {
// home_pos: the heart it is bound to.
if let Some(home) = m.extra.home {
@@ -479,10 +521,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
)])
});
}
"minecraft:zombie" => {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
for (key, value) in [
("CanBreakDoors", Tag::Byte(0)),
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
] {
@@ -507,6 +547,28 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
_ => {}
}
// Zombie.addAdditionalSaveData: CanBreakDoors.
if crate::mobs::doors::is_zombie(m.species()) {
map.insert(
"CanBreakDoors".into(),
Tag::Byte(i8::from(m.extra.can_break_doors)),
);
}
if matches!(
id,
"minecraft:zombie"
| "minecraft:husk"
| "minecraft:drowned"
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:piglin"
| "minecraft:zoglin"
) {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
}
if let Some(a) = m.extra.attributes.as_deref() {
put_attributes(map, m, a);
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -589,6 +651,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
tag
}
/// `AttributeMap.pack` for the attributes with a base or permanent modifier
/// finalizeSpawn set, over the saved list.
fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) {
use crate::mobs::finalize::{self, Attr, Op};
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
for attr in Attr::ALL {
let modifiers: Vec<Tag> = a
.modifiers
.iter()
.filter(|x| x.attr == attr)
.map(|x| {
let op = match x.op {
Op::AddValue => "add_value",
Op::AddMultipliedBase => "add_multiplied_base",
Op::AddMultipliedTotal => "add_multiplied_total",
};
Tag::compound([
("id", string(x.id)),
("amount", Tag::Double(x.amount.to_bits())),
("operation", string(op)),
])
})
.collect();
if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) {
continue;
}
let id = format!("minecraft:{}", attr.name());
let mut entry = vec![
("id", string(&id)),
("base", Tag::Double(finalize::base_value(m, attr).to_bits())),
];
if !modifiers.is_empty() {
entry.push(("modifiers", Tag::List(modifiers)));
}
let entry = Tag::compound(entry);
match list
.iter_mut()
.find(|t| text(t.get("id")).as_deref() == Some(id.as_str()))
{
Some(t) => *t = entry,
None => list.push(entry),
}
}
map.insert("attributes".into(), Tag::List(list));
}
/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones.
pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
if m.extra.memories.is_empty() {
return;
}
let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for &(name, value, ttl) in &m.extra.memories {
let value = match name {
"hunted_recently" => Tag::Byte(value as i8),
"home" => match m.extra.home {
Some(p) => Tag::compound([
("dimension", string(dimension)),
("pos", Tag::IntArray(p.to_vec())),
]),
None => continue,
},
_ => Tag::Int(value as i32),
};
let mut memory = vec![("value", value)];
if ttl != i64::MAX {
memory.push(("ttl", Tag::Long(ttl)));
}
memories.insert(
format!("minecraft:{name}").as_str().into(),
Tag::compound(memory),
);
}
map.insert(
"Brain".into(),
Tag::compound([("memories", Tag::Compound(memories))]),
);
}
/// `CustomName`: a text component saved as a string or a `{text: ..}` compound.
fn custom_name(tag: &Tag) -> Option<String> {
match tag.get("CustomName")? {
@@ -608,6 +753,22 @@ pub fn name_text(tag: &Tag) -> Text {
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
let Some(Tag::List(attrs)) = tag.get("attributes") else {
return None;
};
let range = attrs
.iter()
.find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?;
let Some(Tag::List(modifiers)) = range.get("modifiers") else {
return None;
};
let bonus = modifiers
.iter()
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
float(bonus.get("amount"))
}
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
@@ -633,6 +794,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
}
}
if let Some(bonus) = spawn_bonus(tag) {
use crate::mobs::finalize::{Attr, Op, add_modifier};
let id = "minecraft:random_spawn_bonus";
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
@@ -674,7 +840,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
if m.kind.category == "creature" {
m.extra.left_handed = flag(tag, "LeftHanded");
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
@@ -706,6 +873,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
) {
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
if m.species() == crate::mobs::Species::Piglin
&& let Some(Tag::List(items)) = tag.get("Inventory")
{
// InventoryCarrier.readInventoryFromTag (eight slots).
m.extra.inventory = items
.iter()
.filter_map(crate::save::item_from_nbt)
.take(8)
.collect();
}
}
if matches!(
m.species(),
@@ -847,6 +1024,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
{
crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]);
}
if m.species() == crate::mobs::Species::Turtle {
// Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`.
m.extra.flag = flag(tag, "has_egg");
m.extra.home = Some(match tag.get("home_pos") {
Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]],
_ => [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
});
}
if m.species() == crate::mobs::Species::Wither {
m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32;
}
@@ -925,6 +1114,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
crate::mobs::finalize::set_baby(&mut m, true);
}
// Zombie.readAdditionalSaveData: setCanBreakDoors.
if crate::mobs::doors::is_zombie(m.species()) {
crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors"));
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mob saved mid-death finishes dying; one saved dead is dropped.
@@ -1043,18 +1236,18 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
("Health", Tag::Short(5)),
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)),
("Count", Tag::Int(1)),
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
] {
put(&mut tag, key, value);
}
tag
}
/// One Horizon orb per counted orb (vanilla merges orbs of equal value).
/// One orb with its merged `Count`.
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
let Some(position) = position(tag) else {
return Vec::new();
};
let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize;
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
id: 0,
position,
@@ -1063,8 +1256,10 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
value: number(tag.get("Value")).unwrap_or(1).max(1) as u32,
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
on_ground: flag(tag, "OnGround"),
count,
ticks: 0,
};
vec![orb; count]
vec![orb]
}
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
@@ -1913,7 +2108,7 @@ mod tests {
assert_eq!(number(tag.get("Health")), Some(5));
}
#[test]
fn orbs_expand_their_count() {
fn orbs_keep_their_count() {
let o = ExperienceOrb {
id: 0,
position: Vec3::new(1.0, 2.0, 3.0),
@@ -1922,12 +2117,14 @@ mod tests {
value: 7,
age: 3,
on_ground: false,
count: 3,
ticks: 0,
};
let mut tag = orb_to_nbt(&o, [0; 4]);
put(&mut tag, "Count", Tag::Int(3));
let tag = orb_to_nbt(&o, [0; 4]);
let back = orbs_from_nbt(&tag);
assert_eq!(back.len(), 3);
assert_eq!(back.len(), 1);
assert_eq!(back[0].value, 7);
assert_eq!(back[0].count, 3);
}
}
@@ -1992,12 +2189,39 @@ fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
}
}
const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"];
/// `Salmon.Variant` names, by id.
const SALMON_SIZES: [&str; 3] = ["small", "medium", "large"];
/// `TropicalFish.Pattern` names in enum order: the six small patterns, then
/// the six large ones (the order of `extra.variant`).
const TROPICAL_PATTERNS: [&str; 12] = [
"kob",
"sunstreak",
"snooper",
"dasher",
"brinely",
"spotty",
"flopper",
"stripey",
"glitter",
"blockfish",
"betty",
"clayfish",
];
/// `Bucketable.saveDefaultDataToBucketTag`, with the age an axolotl and a
/// tadpole add (`saveToBucketTag`).
pub fn bucket_data(m: &Mob) -> Tag {
let mut data = Tag::compound([
("Age", Tag::Int(m.extra.age)),
("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))),
("Health", Tag::Float(m.health.to_bits())),
]);
let mut data = Tag::compound([("Health", Tag::Float(m.health.to_bits()))]);
if matches!(
m.species(),
crate::mobs::Species::Axolotl | crate::mobs::Species::Tadpole
) {
put(&mut data, "Age", Tag::Int(m.extra.age));
put(
&mut data,
"AgeLocked",
Tag::Byte(i8::from(m.extra.age_locked)),
);
}
for (key, on) in [
("NoAI", m.no_ai),
("Silent", m.silent),
@@ -2027,6 +2251,29 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
.unwrap_or(&"lucy");
data.set_other("minecraft:axolotl/variant", Some(string(variant)));
}
// Salmon.saveToBucketTag: the size.
if m.species() == crate::mobs::Species::Salmon {
let size = SALMON_SIZES
.get(usize::from(m.extra.variant))
.unwrap_or(&"medium");
data.set_other("minecraft:salmon/size", Some(string(size)));
}
// TropicalFish.saveToBucketTag: the pattern and both colours.
if m.species() == crate::mobs::Species::TropicalFish {
let dye = |i: u8| crate::mobs::render::DYE_NAMES[usize::from(i) & 15];
let pattern = TROPICAL_PATTERNS
.get(usize::from(m.extra.variant))
.unwrap_or(&"kob");
data.set_other("minecraft:tropical_fish/pattern", Some(string(pattern)));
data.set_other(
"minecraft:tropical_fish/base_color",
Some(string(dye(m.extra.variant2))),
);
data.set_other(
"minecraft:tropical_fish/pattern_color",
Some(string(dye(m.extra.variant3))),
);
}
data
}
pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
@@ -2077,6 +2324,39 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
{
m.extra.variant = id as u8;
}
// Salmon.applyImplicitComponents: the size.
if m.species() == crate::mobs::Species::Salmon
&& let Some(name) = text(components.other("minecraft:salmon/size"))
&& let Some(id) = SALMON_SIZES
.iter()
.position(|&v| v == name.trim_start_matches("minecraft:"))
{
m.extra.variant = id as u8;
}
// TropicalFish.applyImplicitComponent: the pattern and both colours.
if m.species() == crate::mobs::Species::TropicalFish {
let name = |key| text(components.other(key));
let dye = |key| {
name(key).and_then(|n| {
crate::mobs::render::DYE_NAMES
.iter()
.position(|&v| v == n.trim_start_matches("minecraft:"))
})
};
if let Some(id) = name("minecraft:tropical_fish/pattern").and_then(|n| {
TROPICAL_PATTERNS
.iter()
.position(|&v| v == n.trim_start_matches("minecraft:"))
}) {
m.extra.variant = id as u8;
}
if let Some(id) = dye("minecraft:tropical_fish/base_color") {
m.extra.variant2 = id as u8;
}
if let Some(id) = dye("minecraft:tropical_fish/pattern_color") {
m.extra.variant3 = id as u8;
}
}
}
#[cfg(test)]
@@ -35,6 +35,18 @@ impl Damage {
..Self::default()
}
}
/// A block that starts to fall: anvils hurt, and a brushable block
/// breaks where it lands (BrushableBlock.tick: disableDrop).
pub fn falling(block: &str) -> Self {
if block.ends_with("anvil") {
Self::anvil()
} else {
Self {
cancel_drop: block.starts_with("minecraft:suspicious_"),
..Self::default()
}
}
}
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
Self {
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
@@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_brushable_blocks_break_where_they_land() {
assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop);
assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop);
assert!(!Damage::falling("minecraft:gravel").cancel_drop);
assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities);
}
#[test]
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
let damage = Damage::anvil();
+272 -72
View File
@@ -102,7 +102,12 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mod_browser;
#[cfg(feature = "mods")]
mod mod_browser_recipes;
mod mod_input;
#[cfg(feature = "mods")]
mod mod_recipes;
mod mod_render;
mod mods;
mod mount_menu;
@@ -186,6 +191,7 @@ mod win;
mod wind;
mod wither;
mod world_loading;
mod zombie_reinforcements;
mod zombie_villagers;
use horizon_client::{
Result,
@@ -369,7 +375,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -424,6 +430,16 @@ impl Options {
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
"--world" => o.world = Some(value),
// Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a
// world that exists opens; else the title screen. --world also creates one.
"--quickPlaySingleplayer" => {
let folder = std::env::current_dir()?.join("saves").join(&value);
if folder.join("level.dat").is_file() {
o.world = Some(value);
} else {
eprintln!("Quick play: there is no world {value} in saves/");
}
}
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
if horizon_client::benchmark::scenes(&value).is_none() {
@@ -434,7 +450,7 @@ impl Options {
}
o.benchmark = Some(value);
}
"--server" => o.server = Some(value),
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
"--name" => o.player_name = Some(value),
"--session-file" => session_file = Some(value),
"--control-socket" => o.control = Some(value.into()),
@@ -2237,8 +2253,9 @@ impl Game<'_> {
ticks: 0,
});
}
/// `Block.getDrops`: the block's loot table, drawing from its server random
/// sequence (`LootContext.Builder.create`), else the level random.
/// `Block.getDrops` for a block a player breaks: the block's loot table
/// with the player as `this`, drawing from its server random sequence
/// (`LootContext.Builder.create`), else the level random.
fn block_loot_at(
&mut self,
pos: [i32; 3],
@@ -2252,9 +2269,9 @@ impl Game<'_> {
let random = self.rand_sequences.get(&sequence, self.level.seed);
self.res
.items()
.block_drops_with(registry, state, held, &dynamic, random)
.block_drops_by_player(registry, state, held, &dynamic, random)
}
None => self.res.items().block_drops_with(
None => self.res.items().block_drops_by_player(
registry,
state,
held,
@@ -2298,11 +2315,13 @@ impl Game<'_> {
// Player.drop: the drop statistics.
self.stats.add("dropped", &thrown.item, thrown.count as i32);
self.stats.custom("drop", 1);
self.throw_from_eyes(thrown, eye);
self.throw_from_eyes(thrown, eye, true);
}
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
/// along the view direction.
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) {
/// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the
/// drop key) names the player as the thrower; `drop(stack, false)`, for
/// what no longer fits in the inventory, does not.
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3], thrown_from_hand: bool) {
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
f64::from(self.pitch).to_radians(),
@@ -2316,7 +2335,7 @@ impl Game<'_> {
yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength,
];
self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40);
if self.lan_acting.is_none() {
if thrown_from_hand && self.lan_acting.is_none() {
let uuid = self.level.ensure_player_uuid();
self.dropped.last_mut().unwrap().thrower = Some(uuid);
}
@@ -2925,6 +2944,17 @@ impl Game<'_> {
pos,
data,
} => self.smash_dust(pos, data),
// LevelEventHandler 2002 and 2007: a potion's splash.
LevelEvent::LevelEvent {
id: 2002,
pos,
data,
} => self.potion_splash_particles(pos, data, false),
LevelEvent::LevelEvent {
id: 2007,
pos,
data,
} => self.potion_splash_particles(pos, data, true),
LevelEvent::LevelEvent {
id: id @ 3002..=3005,
pos,
@@ -2950,6 +2980,9 @@ impl Game<'_> {
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
1030 => Some(("minecraft:block.anvil.use", 1.0)),
1029 => Some(("minecraft:block.anvil.destroy", 1.0)),
1031 => Some(("minecraft:block.anvil.land", 0.3)),
@@ -2957,6 +2990,8 @@ impl Game<'_> {
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
1043 => Some(("minecraft:item.book.page_turn", 1.0)),
// LevelEvent.SOUND_SPLASH_POTION and its instant twin.
1053 | 1054 => Some(("minecraft:entity.splash_potion.break", 1.0)),
_ => None,
};
if let Some((name, volume)) = sound {
@@ -2966,12 +3001,17 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
1029 | 1030 | 1042 | 1043 | 1044 => {
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
1019 | 1021 => {
(self.world.random.next_float() - self.world.random.next_float())
* 0.2
+ 1.0
}
_ => 1.0,
};
if matches!(id, 1029 | 1030) {
if matches!(id, 1019 | 1021 | 1029 | 1030 | 1053 | 1054) {
// Clients play the anvil sound from its level event;
// capturing it also sends a duplicate sound packet.
self.audio.play_unshared(name, c, volume, pitch);
@@ -3020,11 +3060,9 @@ impl Game<'_> {
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
damage: if self.res.facts().get(state).name.ends_with("anvil") {
horizon_client::falling_block::Damage::anvil()
} else {
Default::default()
},
damage: horizon_client::falling_block::Damage::falling(
&self.res.facts().get(state).name,
),
saved: None,
});
}
@@ -3101,6 +3139,11 @@ impl Game<'_> {
kind: "hatch_sniffer",
..
} => self.hatch_sniffer([pos.x, pos.y, pos.z]),
LevelEvent::BlockEvent {
pos,
state,
kind: "hatch_turtles",
} => self.hatch_turtles([pos.x, pos.y, pos.z], state),
LevelEvent::BlockEvent {
pos,
kind: "portal_spawn",
@@ -3138,6 +3181,10 @@ impl Game<'_> {
.name
.clone();
let key = (pos[0], pos[1], pos[2]);
if self.chest_menu_blocked(key, &name) {
// The use still succeeds, but no menu, stat or piglin anger.
return;
}
if &*name == "minecraft:lectern" {
self.open_lectern(pos);
return;
@@ -4484,12 +4531,6 @@ impl Game<'_> {
let events = std::mem::take(&mut player.events);
let spin_hit = player.spin_hit.take();
let glider_wear = std::mem::take(&mut player.glider_wear);
if glider_wear > 0
&& !self.creative
&& let Some(elytra) = self.armor[1].as_mut()
{
elytra.damage += glider_wear;
}
let feet = [player.position.x, player.position.y, player.position.z];
let player_velocity = [player.velocity.x, player.velocity.y, player.velocity.z];
self.previous_eye = self.eye;
@@ -4500,9 +4541,36 @@ impl Game<'_> {
};
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
// `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the
// glide's wear): both draw from the level random.
if let Some((at, velocity)) = self.tick_soul_speed() {
self.particles.send_particles(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Soul,
),
at,
velocity,
1.0,
0,
);
if let Some(host) = self.lan.as_ref() {
lan_particles::send_soul_particle(host, at, velocity);
}
}
// `ItemStack.hurtAndBreak` for the glide: Unbreaking can save the wear.
if glider_wear > 0
&& !self.creative
&& let Some(stack) = self.armor[1].clone()
{
let loss = self.durability_loss(&stack, glider_wear);
if let Some(elytra) = self.armor[1].as_mut() {
elytra.damage += loss;
}
}
self.tick_avatar(before, riding);
self.end_spin_attack(spin_hit);
self.record_combat(&events);
self.wear_armor(&events);
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
@@ -4675,7 +4743,7 @@ impl Game<'_> {
&& let Some(stack) = self.carried.take()
&& let Some(eye) = self.eye_position()
{
self.throw_from_eyes(stack, eye);
self.throw_from_eyes(stack, eye, false);
}
}
self.tick_world(running)
@@ -4683,6 +4751,7 @@ impl Game<'_> {
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
self.share_spawn_conditions();
self.update_chunk_tickets();
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
// Player.tick's ItemCooldowns.tick for the window's player (a
@@ -4995,7 +5064,7 @@ impl Game<'_> {
self.capture(true);
}
}
fn draw_item(&mut self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
// special renderers included), then the decorations.
self.draw_item_model(batch, stack, x, y);
@@ -5085,7 +5154,7 @@ impl Game<'_> {
/// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`)
/// and damage bar.
fn draw_item_decorations(
&mut self,
&self,
batch: &mut HudBatch,
stack: &ItemStack,
x: f32,
@@ -5212,12 +5281,12 @@ impl Game<'_> {
for d in &draws {
if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d {
let stack = if slot == 40 {
self.offhand.clone()
self.offhand.as_ref()
} else {
self.inventory[slot as usize].clone()
self.inventory[slot as usize].as_ref()
};
if let Some(stack) = stack {
self.draw_item(&mut batch, &stack, x as f32, y as f32);
self.draw_item(&mut batch, stack, x as f32, y as f32);
}
}
}
@@ -5378,6 +5447,7 @@ impl Game<'_> {
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::Inventory => {
self.draw_inventory(&mut batch);
self.draw_mod_browser(&mut batch);
}
Screen::Creative => {
self.draw_creative(&mut batch);
@@ -5770,6 +5840,7 @@ impl Game<'_> {
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
self.mod_browser_closed();
self.reset_inventory_clicks();
self.close_station();
self.mount_closed();
@@ -5783,7 +5854,8 @@ impl Game<'_> {
if let Some(rest) = self.give(stack)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40);
// AbstractContainerMenu.dropOrPlaceInInventory: `drop(stack, false)`.
self.throw_from_eyes(rest, eye, false);
}
}
self.craft_size = 2;
@@ -6496,6 +6568,9 @@ impl Game<'_> {
fn inventory_press(&mut self, right: bool) {
use horizon_client::container_screen::Target;
use horizon_client::menu::Input;
if self.mod_browser_press(right) {
return;
}
if !right && self.sort_press() {
return;
}
@@ -6679,24 +6754,8 @@ impl Game<'_> {
}
/// `hasClickedOutside` of the open screen at the cursor.
fn clicked_outside(&self, batch: &HudBatch) -> bool {
use horizon_client::container_screen::{Book, Image, clicked_outside};
let (ox, oy) = self.inventory_origin(batch);
let (shift, width, height) = if self.screen == Screen::Creative {
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
} else {
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
Some(stations::Kind::Merchant) => (-50.0, 276),
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
_ => (0.0, 176),
};
(shift, width, self.screen_height() as i32)
};
let image = Image {
left: (ox + shift) as i32,
top: oy as i32,
width,
height,
};
use horizon_client::container_screen::{Book, clicked_outside};
let image = self.container_image(batch);
let book = self
.recipe_book_area(batch)
.map(|(visible, tab_hovered)| Book {
@@ -6710,6 +6769,26 @@ impl Game<'_> {
book,
)
}
/// The open container screen's window: its place and size.
fn container_image(&self, batch: &HudBatch) -> horizon_client::container_screen::Image {
let (ox, oy) = self.inventory_origin(batch);
let (shift, width, height) = if self.screen == Screen::Creative {
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
} else {
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
Some(stations::Kind::Merchant) => (-50.0, 276),
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
_ => (0.0, 176),
};
(shift, width, self.screen_height() as i32)
};
horizon_client::container_screen::Image {
left: (ox + shift) as i32,
top: oy as i32,
width,
height,
}
}
/// A click outside the window (slot id -999): `THROW` does nothing; a
/// `PICKUP` drops the carried stack (left) or one of it (right), thrown
/// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`).
@@ -6870,7 +6949,7 @@ impl Game<'_> {
}
}
fn click_inventory(&mut self, right: bool) {
if self.mount_click(right) {
if self.mod_browser_press(right) || self.mount_click(right) {
return;
}
if self.recipe_book_click(right) {
@@ -7917,6 +7996,9 @@ impl Game<'_> {
if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event {
self.audio
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
} else {
// Left for the end of the tick.
self.mobs[index].outbox.push(event);
}
}
self.weapon_hit(index);
@@ -8039,6 +8121,7 @@ impl Game<'_> {
creative: bool,
create: Option<&horizon_client::title::WorldOptions>,
) -> Result<()> {
world_loading::world_data_used();
if self.defer_world_entry(PendingWorldEntry::Local {
folder: folder.to_owned(),
name: name.map(str::to_owned),
@@ -8104,6 +8187,7 @@ impl Game<'_> {
);
self.session_lock = Some(session_lock);
self.end.set_locator(locator);
self.share_spawn_conditions();
self.score = Default::default();
self.fns = Default::default();
self.bosses = Default::default();
@@ -8242,6 +8326,7 @@ impl Game<'_> {
seed,
)?;
self.end.set_locator(locator);
self.share_spawn_conditions();
Ok((streaming, save))
}
/// Change dimension through a portal (Entity.changeDimension with
@@ -8591,15 +8676,54 @@ impl Game<'_> {
}
self.apply_enchantment_attributes();
}
/// Inventory.hurtArmor: each piece loses max(1, damage / 4) durability.
fn hurt_armor(&mut self, damage: f32) {
/// The armor hits among a tick's motion events (`Player.hurtArmor` from
/// `getDamageAfterArmorAbsorb`), whatever the damage: a mob, an explosion,
/// a cactus, lava, a campfire.
fn wear_armor(&mut self, events: &[MotionEvent]) {
for event in events {
if let MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt {
source,
amount,
}) = event
{
self.hurt_armor(*source, *amount);
}
}
}
/// `Player.hurtArmor` -> `LivingEntity.doHurtEquipment`: each worn piece that
/// wears when its wearer is hurt loses max(1, damage / 4) durability, except
/// to a fire source when the piece resists fire (`ItemStack.canBeHurtBy`).
fn hurt_armor(&mut self, source: horizon_world::damage_source::DamageTypeId, damage: f32) {
if damage <= 0.0 {
return;
}
let amount = ((damage / 4.0) as u32).max(1);
let name = self
.motion
.health
.damage
.definition(source)
.map(|d| d.name.clone())
.unwrap_or_default();
let fire = self
.motion
.health
.damage
.in_tag(source, "minecraft:is_fire");
let mut broke = false;
let losses: Vec<u32> = (0..4)
.map(|i| {
self.armor[i]
.clone()
.map_or(amount, |s| self.durability_loss(&s, amount))
.map(|i| match self.armor[i].clone() {
Some(s)
if self
.res
.items()
.get(&s.item)
.is_some_and(|info| info.equip_damage_on_hurt)
&& !(fire && self.res.items().resists_fire(&s, &name)) =>
{
self.durability_loss(&s, amount)
}
_ => 0,
})
.collect();
for (slot, amount) in self.armor.iter_mut().zip(losses) {
@@ -8696,7 +8820,8 @@ impl Game<'_> {
}
fn drop_stack(&mut self, stack: ItemStack) {
if let Some(eye) = self.0.eye_position() {
self.0.spawn_item(stack, eye, [0.0, 0.1, 0.0], 40);
// ItemUtils.createFilledResult: `player.drop(stack, false)`.
self.0.throw_from_eyes(stack, eye, false);
}
}
}
@@ -9039,7 +9164,8 @@ impl Game<'_> {
} else if let Some(rest) = self.give(filled)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
// ItemUtils.createFilledResult: `player.drop(stack, false)`.
self.throw_from_eyes(rest, eye, false);
}
}
return Ok(UseResult::Success { swing: true });
@@ -9189,8 +9315,29 @@ impl Game<'_> {
while value > 0 {
let v = ExperienceOrb::split(value);
value -= v;
let _merge_id = self.world.random.next_int_bound(40);
self.add_orb(at, v);
let merge_id = self.world.random.next_int_bound(40).unwrap_or(0);
self.award_orb(at, v, merge_id);
}
}
/// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a
/// 1 block box of `at`, whose id is a multiple of 40 from `merge_id`, takes
/// the new one as one more in its count (and restarts its age); otherwise
/// a fresh orb spawns.
fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) {
let centre = Vec3::new(at[0], at[1], at[2]);
let half = Vec3::new(0.5, 0.5, 0.5);
let area = Aabb::new(centre.subtract(half), centre.add_vector(half));
// A server's orbs are its own; a LAN or server guest only shows them.
let target = self.remote.is_none().then(|| {
self.orbs
.iter_mut()
.find(|o| o.can_merge(merge_id as u32, value) && o.bounds().intersects(area))
});
if let Some(Some(orb)) = target {
orb.count += 1;
orb.age = 0;
} else {
self.add_orb(at, value);
}
}
/// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from
@@ -9212,6 +9359,8 @@ impl Game<'_> {
value,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
});
}
fn can_place_used_fire(&mut self, target: [i32; 3]) -> bool {
@@ -9288,18 +9437,31 @@ impl Game<'_> {
}),
);
}
// Player.aiStep: the pickup area is the box inflated by (1, 0.5, 1).
let bounds = player.map(|p| {
let w = f64::from(p.dimensions.width) / 2.0;
(
[p.position.x - w, p.position.y, p.position.z - w],
[
p.position.x + w,
p.position.y + f64::from(p.dimensions.height),
p.position.z + w,
p.position.x - w - 1.0,
p.position.y - 0.5,
p.position.z - w - 1.0,
],
[
p.position.x + w + 1.0,
p.position.y + f64::from(p.dimensions.height) + 0.5,
p.position.z + w + 1.0,
],
)
});
let mut orbs = std::mem::take(&mut self.orbs);
// An orb has no random of its own here: lava draws from one made on
// first use, apart from the level stream.
let mut lava_random = None;
let mut next_float = || {
lava_random
.get_or_insert_with(dispenser::entity_random)
.next_float()
};
orbs.retain_mut(|o| {
let distance = |feet: &[f64; 3]| -> f64 {
(feet[0] - o.position.x).powi(2)
@@ -9311,12 +9473,22 @@ impl Game<'_> {
.filter(|(feet, _)| distance(feet) < 64.0)
.min_by(|a, b| distance(&a.0).total_cmp(&distance(&b.0)))
.map(|(_, target)| *target);
o.tick(&self.world, self.res.facts(), self.collision, target)
o.tick(
&self.world,
self.res.facts(),
self.collision,
target,
&mut next_float,
)
});
// ExperienceOrb.scanForMerges runs on the server only.
if self.remote.is_none() {
horizon_client::entities::merge_orbs(&mut orbs);
}
let mut gained = 0;
let mut taken = Vec::new();
if let Some((min, max)) = bounds {
orbs.retain(|o| {
orbs.retain_mut(|o| {
let touching = o.position.x + 0.25 > min[0]
&& o.position.x - 0.25 < max[0]
&& o.position.y + 0.5 > min[1]
@@ -9324,11 +9496,13 @@ impl Game<'_> {
&& o.position.z + 0.25 > min[2]
&& o.position.z - 0.25 < max[2];
if touching && self.xp_delay == 0 {
// ExperienceOrb.playerTouch: one orb every 2 ticks.
// ExperienceOrb.playerTouch: one orb every 2 ticks; a
// merged orb gives its value once per count.
self.xp_delay = 2;
gained += o.value;
taken.push(o.clone());
return false;
o.count -= 1;
return o.count > 0;
}
true
});
@@ -9404,7 +9578,10 @@ impl Game<'_> {
};
// BowItem.shootProjectile: shootFromRotation(power * 3, inaccuracy 1).
let p = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let (power_bonus, infinity) = self.bow_enchantments();
let ammo = arrow_slot
.and_then(|i| self.stack_in(i).cloned())
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
let (power_bonus, infinity) = self.bow_enchantments(&ammo.item);
let kind = horizon_client::projectiles::ProjectileKind::Arrow {
base_damage: 2.0 + power_bonus,
critical: power >= 1.0,
@@ -9418,9 +9595,6 @@ impl Game<'_> {
// ProjectileWeaponItem.useAmmo: creative and Infinity draw without
// using the ammo, so the arrow is CREATIVE_ONLY; else setOwner(player)
// lets it be picked up.
let ammo = arrow_slot
.and_then(|i| self.stack_in(i).cloned())
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(&horizon_client::arrows::drawn_ammo(
&ammo,
@@ -11998,6 +12172,14 @@ impl ApplicationHandler for Game<'_> {
// The recipe book's search box takes every key but Escape.
self.recipe_key(&event);
}
WindowEvent::KeyboardInput { event, .. }
if self.mod_browser_typing()
&& event.state == ElementState::Pressed
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
{
// A mod's item panel's search box, as the recipe book's.
self.mod_browser_key(&event);
}
WindowEvent::KeyboardInput { event, .. }
if self.station_typing()
&& event.state == ElementState::Pressed
@@ -12539,7 +12721,14 @@ impl<'a> Game<'a> {
level,
session_lock: None,
audio,
music: Default::default(),
music: {
// The manager starts with the saved option (MusicManager's constructor).
let mut music = horizon_client::music::MusicManager::default();
music.frequency = horizon_client::music::MusicFrequency::parse(
&settings.file.name("musicFrequency"),
);
music
},
ambience: Default::default(),
sound_paused: false,
cart_sounds: HashSet::new(),
@@ -12906,6 +13095,17 @@ impl Game<'_> {
if self.rest_loader.is_some() {
return Ok(());
}
let on_world_screens = self.title.as_ref().is_some_and(|title| {
!matches!(
title.mode,
horizon_client::title::Mode::Main | horizon_client::title::Mode::Multiplayer(_)
)
});
world_loading::preload_world_data(
on_world_screens,
&self.options.resources,
self.pack.assets.registry,
);
if std::mem::take(&mut self.pending_pack_reload) {
self.reload_packs();
}
@@ -453,6 +453,11 @@ impl Game<'_> {
}
// ArmorStand.swapItem.
match held {
// A player with infinite materials puts a copy of one on an empty
// slot and keeps the stack.
Some(h) if self.creative && current.is_none() => {
self.armor_stands[i].equipment[slot] = Some(ItemStack { count: 1, ..h });
}
Some(h) if h.count > 1 => {
if current.is_some() {
return true;
@@ -5,10 +5,14 @@
//! [`Game::block_anim`] and [`Game::chest_lid`].
use super::Game;
use horizon_client::{
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
block_entity_anim::{
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
containers::BlockEntity,
effects::EffectInstance,
mobs::{Damage, DamageKind},
particles::{ParticleOptions, ParticleType},
save::number,
skins,
@@ -16,6 +20,7 @@ use horizon_client::{
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_world::nbt::Tag;
@@ -218,9 +223,12 @@ impl Game<'_> {
/// The client tickers of the loaded animated block entities, once per
/// client tick (`ClientLevel.tickBlockEntities`).
pub(super) fn tick_block_entity_anims(&mut self) {
// A server has no window, but its gateways and conduits still run
// their server tickers; a client of one only animates what it is sent.
if self.remote.is_none() {
self.tick_conduit_servers();
}
let Some(r) = &self.renderer else {
// A server has no window, but its gateways still run their
// server ticker.
if self.remote.is_none() {
self.tick_gateway_servers();
}
@@ -337,6 +345,7 @@ impl Game<'_> {
}
}
Ticker::Conduit => {
let target = self.conduit_target(pos);
let world = &self.world;
let facts = self.res.facts();
let registry = self.pack.assets.registry;
@@ -356,7 +365,7 @@ impl Game<'_> {
time,
is_water,
is_frame,
None,
target,
self.particles.random(),
);
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
@@ -383,6 +392,248 @@ impl Game<'_> {
}
self.be_anims.present = positions;
}
/// `ConduitBlockEntity.serverTick` of every conduit in the chunks that
/// tick blocks, with or without a renderer. The conduits are looked for
/// when the shape is checked, every 40 ticks (they have no block entity
/// of their own here).
fn tick_conduit_servers(&mut self) {
if self.world.game_time % 40 == 0 {
self.find_conduits();
}
let mut positions: Vec<[i32; 3]> = self.be_anims.conduit_servers.keys().copied().collect();
positions.sort_unstable();
for pos in positions {
self.tick_conduit_server(pos);
}
}
/// Sets `be_anims.conduit_servers` to the conduits in the
/// chunks that tick blocks, keeping the state of those already known.
fn find_conduits(&mut self) {
let levels = self.tickets.levels();
let kinds = &self.be_anims.kinds;
let is_conduit = |id: u32| matches!(kinds.get(id as usize), Some(Some(Ticker::Conduit)));
let mut found = Vec::new();
for (&(cx, cz), chunk) in &self.world.chunks {
if !levels.block_ticking_at(cx * 16, cz * 16) {
continue;
}
for (si, section) in chunk.sections.iter().enumerate() {
if !section.blocks.maybe_has(is_conduit) {
continue;
}
for i in 0..4096 {
if is_conduit(section.get(i)) {
found.push([
cx * 16 + (i & 15) as i32,
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
cz * 16 + ((i >> 4) & 15) as i32,
]);
}
}
}
}
let mut old = std::mem::take(&mut self.be_anims.conduit_servers);
for pos in found {
let known = old.remove(&pos).unwrap_or_default();
self.be_anims.conduit_servers.insert(pos, known);
}
}
/// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk
/// that ticks blocks: the shape check every 40 ticks with its sounds, the
/// power for players in range, the hurt for an enemy, then the ambient
/// sounds.
fn tick_conduit_server(&mut self, pos: [i32; 3]) {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
if self.be_anims.ticker(state) != Some(Ticker::Conduit) {
// Broken since the chunks were searched.
self.be_anims.conduit_servers.remove(&pos);
return;
}
if !self.tickets.levels().block_ticking_at(pos[0], pos[2])
|| !self.border.contains(f64::from(pos[0]), f64::from(pos[2]))
{
return;
}
let time = self.world.game_time;
let mut conduit = self
.be_anims
.conduit_servers
.remove(&pos)
.unwrap_or_default();
let check = {
let world = &self.world;
let facts = self.res.facts();
let registry = self.pack.assets.registry;
let frames = self
.res
.items()
.block_tags
.get("minecraft:conduit_effect_block");
// Level.isWaterAt: the fluid at the position is water.
let is_water = |p: [i32; 3]| {
facts.get(world.block(p[0], p[1], p[2])).fluid
== horizon_client::blocks::Fluid::Water
};
let is_frame = |p: [i32; 3]| {
registry
.state(world.block(p[0], p[1], p[2]))
.is_some_and(|s| frames.is_some_and(|f| f.contains(&s.name)))
};
conduit.check(pos, time, is_water, is_frame)
};
let centre = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
if let Some(check) = check {
if let Some(active) = check.toggled {
let sound = if active {
"minecraft:block.conduit.activate"
} else {
"minecraft:block.conduit.deactivate"
};
self.audio.play(sound, Some(centre), 1.0, 1.0);
}
if let Some(frames) = check.frames {
self.conduit_apply_effects(pos, frames);
self.conduit_attack(&mut conduit, pos, frames >= 42);
}
}
let (ambient, short) = conduit.ambient(time, &mut self.world.random);
if ambient {
self.audio
.play("minecraft:block.conduit.ambient", Some(centre), 1.0, 1.0);
}
if short {
self.audio.play(
"minecraft:block.conduit.ambient_short",
Some(centre),
1.0,
1.0,
);
}
self.be_anims.conduit_servers.insert(pos, conduit);
}
/// `ConduitBlockEntity.applyEffects`: conduit power for every player
/// in water or rain within `frames / 7 * 16` blocks.
fn conduit_apply_effects(&mut self, pos: [i32; 3], frames: usize) {
let range = (frames / 7 * 16) as i64;
self.for_each_player(|g| {
let Some(p) = g.player.as_ref().filter(|p| !p.health.dead_or_dying()) else {
return;
};
if g.spectator() {
return;
}
let at = p.position;
let in_water = p.fluids.was_touching_water;
let top = at.y + f64::from(p.dimensions.height);
// BlockPos.closerThan: the squared distance under range squared.
let d = [at.x, at.y, at.z]
.map(|v| v.floor() as i64)
.iter()
.zip(pos)
.map(|(a, b)| (a - i64::from(b)).pow(2))
.sum::<i64>();
if d >= range * range || !(in_water || g.in_rain(at, top)) {
return;
}
let power = EffectInstance {
ambient: true,
..EffectInstance::new("conduit_power", 260, 0)
};
g.potions.effects.add(power);
});
}
/// `Entity.isInRain`: rain at the block position or at the top of the box.
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
let p = BlockPos::new(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
);
let up = BlockPos::new(p.x, top.floor() as i32, p.z);
self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up))
}
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
/// (42 frame blocks) its target stays until it dies or leaves the 8
/// blocks, else a random enemy in water or rain is chosen; the target
/// takes 4 magic damage.
fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) {
conduit.target = match conduit.target {
_ if !hunting => None,
None => self.conduit_new_target(pos),
Some(id) => self
.mobs
.iter()
.find(|m| m.id == id && !m.removed)
.filter(|m| {
let at = [m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i64);
m.alive()
&& at
.iter()
.zip(pos)
.map(|(a, b)| (a - i64::from(b)).pow(2))
.sum::<i64>()
< 64
})
.map(|m| m.id),
};
let Some(m) = conduit
.target
.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
m.damage(4.0, Damage::of(DamageKind::Magic));
self.audio
.play("minecraft:block.conduit.attack.target", Some(at), 1.0, 1.0);
}
/// `ConduitBlockEntity.selectNewTarget`: one of the enemies in water or
/// rain whose box meets the conduit's block inflated by 8.
fn conduit_new_target(&mut self, pos: [i32; 3]) -> Option<u32> {
let [x, y, z] = pos.map(f64::from);
let area = Aabb::new(
Vec3::new(x - 8.0, y - 8.0, z - 8.0),
Vec3::new(x + 9.0, y + 9.0, z + 9.0),
);
let near: Vec<(u32, bool, Vec3, f64)> = self
.mobs
.iter()
.filter(|m| m.hostile() && !m.removed && m.bounds().intersects(area))
.map(|m| {
let top = m.position.y + f64::from(m.height());
(m.id, m.fluids.in_water, m.position, top)
})
.collect();
let candidates: Vec<u32> = near
.into_iter()
.filter(|&(_, in_water, at, top)| in_water || self.in_rain(at, top))
.map(|(id, ..)| id)
.collect();
if candidates.is_empty() {
return None;
}
let pick = self
.world
.random
.next_int_bound(candidates.len() as i32)
.unwrap_or(0);
Some(candidates[pick as usize])
}
/// The conduit's `destroyTarget` as the client draws it.
fn conduit_target(&self, pos: [i32; 3]) -> Option<ConduitTarget> {
let id = self.be_anims.conduit_servers.get(&pos)?.target?;
let m = self.mobs.iter().find(|m| m.id == id && !m.removed)?;
Some(ConduitTarget {
position: [m.position.x, m.position.y, m.position.z],
eye_y: m.eye_y(),
width: m.width(),
height: m.height(),
})
}
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
/// gateways a server keeps a copy of, in block-ticking chunks
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
@@ -4,8 +4,11 @@
//! on the game that has the window, in-frame or following a server, never on
//! a server: nothing of them goes over the network.
use super::Game;
use horizon_client::blocks::RenderShape;
use horizon_client::mobs::{MobEvent, ambient};
use horizon_client::motion::{LAVA, WATER};
use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType};
use horizon_client::pose::Pose;
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
@@ -34,9 +37,96 @@ impl Game<'_> {
}
}
self.eye_particles();
self.sprint_dust();
self.effect_swirls();
}
/// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the
/// block under a sprinting player's feet streams back from them every
/// tick, for the window's own player and for the other players it sees.
fn sprint_dust(&mut self) {
// Each runner's feet, movement and supporting block.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
// Entity.canSpawnSprintParticle.
if let Some(p) = self.player.as_ref()
&& p.sprinting
&& !p.spectator
&& p.pose != Pose::Crouching
&& !p.health.dead_or_dying()
&& !p.fluids.was_touching_water
&& !p.is_in_lava()
&& p.fluid_height(WATER) <= 0.0
&& p.fluid_height(LAVA) <= 0.0
{
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
runners.push((p.position, p.velocity, support));
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
// Another player's fluids aren't known here: none where they stand.
let feet = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
if m.extra.sprinting
&& !m.extra.crouching
&& m.alive()
&& &*m.kind.id == "minecraft:player"
&& self
.res
.facts()
.get(self.world.block(feet[0], feet[1], feet[2]))
.fluid_level
== 0
{
runners.push((m.position, m.position.subtract(m.previous), None));
}
}
for (position, movement, support) in runners {
// Entity.getOnPosLegacy: getOnPos(0.2).
let below = (position.y - f64::from(0.2f32)).floor() as i32;
let tall = |game: &Self, s: [i32; 3]| {
let name = game.block_name(s);
[
"minecraft:fences",
"minecraft:walls",
"minecraft:fence_gates",
]
.iter()
.any(|tag| game.block_in_tag(tag, &name))
};
let at = match support {
Some(s) if tall(self, s) => s,
Some(s) => [s[0], below, s[2]],
None => [position.x.floor() as i32, below, position.z.floor() as i32],
};
let state = self.world.block(at[0], at[1], at[2]);
if self.res.facts().get(state).render_shape == RenderShape::Invisible {
continue;
}
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let width = f64::from(0.6f32);
let r = self.particles.random();
let mut x = position.x + (r.next_double() - 0.5) * width;
let mut z = position.z + (r.next_double() - 0.5) * width;
// Clamped into the block when the feet are in another column.
if position.x.floor() as i32 != at[0] {
x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0);
}
if position.z.floor() as i32 != at[2] {
z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0);
}
self.particles.add(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
[x, position.y + 0.1, z],
[movement.x * -4.0, 1.5, movement.z * -4.0],
);
}
}
/// `LivingEntity.tickEffects` on a client: the swirls around an entity
/// with visible effects, from the particles its synched data lists. The
/// window's player has its own effects, a server's mobs and players the
+4
View File
@@ -31,6 +31,10 @@ pub(super) struct CombatTracker {
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// Charged creepers whose blast just went off and that have not dropped
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
/// but the mobs they killed still ask for their loot.
pub(super) charged_exploders: Vec<u32>,
/// The player's fall distance before this tick's movement (as of the
/// end of the last one).
fall_before: f64,
@@ -1697,6 +1697,8 @@ impl Game<'_> {
value: 0,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
});
name = Text::translate("entity.minecraft.experience_orb", &[]);
}
@@ -145,9 +145,42 @@ impl Game<'_> {
.iter()
.find(|m| m.id == id)
.map_or(0.0, |m| m.eye_y() - m.position.y),
_ => 0.0,
// ArmorStand.getDefaultDimensions: a marker's are empty, a small
// stand's are BABY_DIMENSIONS, whose eye height is 0.9875F.
Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) {
Some(a) if a.marker => 0.0,
Some(a) if a.small => f64::from(0.9875f32),
_ => self.type_eye_height(&self.target_type(t)),
},
// Entity.getEyeHeight: the type's dimensions.
_ => self.type_eye_height(&self.target_type(t)),
}
}
/// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`,
/// read once: a float, widened as `Entity.getEyeY` does.
fn type_eye_height(&self, ty: &str) -> f64 {
static EYE_HEIGHTS: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
std::sync::OnceLock::new();
EYE_HEIGHTS
.get_or_init(|| {
std::fs::read(self.options.resources.join("entity-types.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
v.as_object().map(|o| {
o.iter()
.filter_map(|(k, t)| {
Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32)))
})
.collect()
})
})
.unwrap_or_default()
})
.get(ty)
.copied()
.unwrap_or(0.0)
}
/// EntityAnchorArgument.Anchor.apply.
pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] {
match s.entity {
@@ -467,9 +500,10 @@ impl Game<'_> {
"positioned" if c.has("as") => targets(self)?
.into_iter()
.filter_map(|t| {
// withPosition keeps the anchor (only `positioned <pos>`
// resets it to the feet).
Some(Source {
pos: self.target_position(t)?,
eyes: false,
..s
})
})
@@ -244,7 +244,11 @@ impl Game<'_> {
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
while self.pregen_runs_here() {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
while self.pregen_runs_here()
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
{
let now = Instant::now();
if now >= until {
break;
@@ -120,6 +120,9 @@ impl Game<'_> {
tracker.track(&holder(chunk_text(x, z)), i32::from(changed));
}
}
if add {
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
}
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
let verb = if add { "added" } else { "removed" };
let (message, total) = tracker.message(
@@ -115,6 +115,7 @@ impl Game<'_> {
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
"dry_out" => DamageKind::DryOut,
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
@@ -628,6 +628,9 @@ impl Game<'_> {
// The port, or the game rules' search and number boxes.
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
@@ -1080,6 +1083,9 @@ impl Game<'_> {
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
let mut fangs_vertices = Vec::new();
self.fangs_geometry(glam::DVec3::ZERO, 0.5, &mut fangs_vertices);
status["fangs_vertices"] = json!(fangs_vertices.len());
status["maps"] = json!(self.maps_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
+225 -34
View File
@@ -193,6 +193,7 @@ impl Game<'_> {
velocity: p.velocity,
yaw: self.yaw,
hero: self.potions.effects.get("hero_of_the_village").is_some(),
effects: self.potions.effects.list.iter().map(|e| e.id).collect(),
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
@@ -429,6 +430,12 @@ impl Game<'_> {
),
}
}
/// What the generator finalizes this level's structure mobs with.
pub(super) fn share_spawn_conditions(&self) {
if let Some(terrain) = self.end.locator() {
terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time);
}
}
/// `ServerLevel.getCurrentDifficultyAt`.
pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty {
use horizon_world::food::Difficulty;
@@ -497,6 +504,8 @@ impl Game<'_> {
self.died.insert(m.id);
self.mob_died(&m);
}
// Their victims have been looted: no charged creeper is waiting any more.
self.combat.charged_exploders.clear();
let uuid = self.level.player_uuid;
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
let mut events = Vec::new();
@@ -779,7 +788,17 @@ impl Game<'_> {
.mobs
.iter()
.find(|o| o.id == id)
.map(|o| drops::loot_entity(o, None)),
.map(|o| drops::loot_entity(o, None))
// A charged creeper's blast kills after the creeper left the list.
.or_else(|| {
self.combat
.charged_exploders
.contains(&id)
.then(|| LootEntity {
powered: true,
..LootEntity::new("minecraft:creeper")
})
}),
None => None,
};
let direct = match d.kind {
@@ -815,12 +834,12 @@ impl Game<'_> {
world_seed: self.level.seed,
};
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
if skulls
&& let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker)
&& let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id)
{
if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) {
// Creeper.droppedSkulls: one head per charged explosion.
creeper.extra.charged = false;
self.combat.charged_exploders.retain(|c| *c != id);
if let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) {
creeper.extra.charged = false;
}
}
let raider = horizon_client::mobs::raider::is_raider(m.species());
for d in items {
@@ -838,6 +857,47 @@ impl Game<'_> {
}
}
}
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
/// sequence with the mob as `this` (a chicken's egg depends on its
/// variant); the loot data of the game's resources decides, and without it
/// the items' table engine (no entity) rolls it.
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
use horizon_client::mobs::drops;
use horizon_world::loot::EntityLootParams;
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
let this = self
.mobs
.iter()
.find(|m| m.id == mob)
.map(|m| drops::loot_entity(m, None));
let stacks = match (loot, this) {
(Some(loot), Some(this)) if loot.has_table(table) => {
let params = EntityLootParams {
this,
..EntityLootParams::default()
};
let seed = self.level.seed;
match loot.random_sequence(table) {
Some(id) => {
let random = self.rand_sequences.get(id, seed);
loot.roll_entity(table, random, &params)
.iter()
.filter(|s| !s.is_empty())
.map(from_loot)
.collect()
}
None => Vec::new(),
}
}
_ => self
.res
.items()
.loot_table(table, false, None, &mut self.world.random),
};
for d in stacks {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
}
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
@@ -903,16 +963,24 @@ impl Game<'_> {
owner,
} => self.add_fangs(at, yaw, delay, owner),
MobEvent::Shoot(..) => {}
MobEvent::ExplosionSource { mob, charged } => {
self.combat.explosion_source = Some(mob);
if charged {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let source_mob = self
.mobs
.iter()
.filter(|m| {
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) < 1.0
})
.map(|m| m.id)
.next();
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
.iter()
.filter(|m| {
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2)
< 1.0
})
.map(|m| m.id)
.next()
});
if let Some(id) = source_mob {
self.note_attacker("minecraft:explosion", id);
}
@@ -943,6 +1011,9 @@ impl Game<'_> {
volume,
pitch,
);
} else {
// Left for the end of the tick.
t.outbox.push(event);
}
}
}
@@ -1045,6 +1116,40 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
self.process_level_events()?;
}
MobEvent::BreakDoor { pos } => {
// BreakDoorGoal.tick: removeBlock, then level events 1021
// and 2001 with the state that is there now.
let air = self.world.air;
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3));
self.process_level_events()?;
self.level_event_at(1021, pos, 0);
let left = self.world.block(pos[0], pos[1], pos[2]);
self.level_event_at(2001, pos, left.0 as i32);
}
MobEvent::DestroyProgress { mob, pos, progress } => {
self.destroy_block_progress(mob as i32, pos, progress);
// ServerLevel.destroyBlockProgress: every player nearby sees it.
if let Some(host) = self.lan.as_ref() {
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
horizon_protocol::packets::play::BlockDestruction {
id: mob as i32,
pos,
progress: if (0..10).contains(&progress) {
progress as u8
} else {
255
},
},
),
None,
);
}
}
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
MobEvent::SetBlockName { pos, name, effect } => {
if effect {
let old = self.world.block(pos[0], pos[1], pos[2]);
@@ -1190,6 +1295,10 @@ impl Game<'_> {
self.mob_vibration_particle(from, to, ticks)
}
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
MobEvent::BlockBreakEffect { pos, block } => {
let state = self.world.block(block[0], block[1], block[2]);
self.destroy_particles(pos, state);
}
MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius),
MobEvent::CreakingHurt {
home,
@@ -1200,6 +1309,7 @@ impl Game<'_> {
self.creaking_tear_down(center, spread)
}
MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob),
MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target),
MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other),
MobEvent::VillagerBirth {
parent,
@@ -1227,19 +1337,51 @@ impl Game<'_> {
m.ai.aggressive |= aggressive;
}
}
MobEvent::GiftLoot { table, at } => {
let drops =
self.res
.items()
.loot_table(table, false, None, &mut self.world.random);
for d in drops {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
MobEvent::GiftLoot { mob, table, at } => {
// Turtle.ageBoundaryReached: only with mob loot on.
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
{
continue;
}
self.drop_gift_loot(mob, table, at);
}
}
}
Ok(())
}
/// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle
/// per egg, a day from grown, with the egg as its home (the sound, the
/// crumbs and the block's removal came from the level).
pub(super) fn hatch_turtles(
&mut self,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
) {
let eggs = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("eggs"))
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(1);
for i in 0..eggs {
let at = [
f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2,
f64::from(pos[1]),
f64::from(pos[2]) + 0.3,
];
if let Some(id) = self.spawn_mob("minecraft:turtle", at, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.extra.age = -24000;
m.extra.home = Some(pos);
m.yaw = 0.0;
m.body_yaw = 0.0;
m.head_yaw = 0.0;
}
}
}
fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
@@ -1302,6 +1444,8 @@ impl Game<'_> {
.unwrap_or_else(|| parent.clone());
let mut child = parent.clone();
child.baby = true;
// TurtleBreedGoal.breed triggers it without a child.
let turtle = p.species() == Species::Turtle;
if matches!(
(
p.species(),
@@ -1314,7 +1458,7 @@ impl Game<'_> {
) {
child.kind = "minecraft:mule".into();
}
self.trigger_bred(&parent, &partner_view, Some(&child));
self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
}
if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) {
q.extra.age = 6000;
@@ -1326,6 +1470,19 @@ impl Game<'_> {
self.lay_sniffer_egg(&p);
return;
}
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
// experience is left, and only with mob loot on.
if p.species() == Species::Turtle {
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
// split up as `ExperienceOrb.award` does).
if self.ui.rules.bool("mob_drops")
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
{
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
self.add_orb([p.position.x, p.position.y, p.position.z], xp);
}
return;
}
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// A horse and a donkey have a mule.
@@ -1558,7 +1715,6 @@ impl Game<'_> {
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
}
}
self.hurt_armor(amount);
// The thorns hurt the entity that caused the hit, if there is one.
if let Some(a) = attacker.or(owner) {
self.thorns(a);
@@ -2408,12 +2564,14 @@ impl Game<'_> {
// ShulkerBullet.onHitEntity: 4 damage, then levitation for 10 s.
(ProjectileKind::ShulkerBullet { .. }, Hit::Entity { who, .. }) => match who {
Who::Player => {
if !self.creative {
self.mob_hits_player(
// Only a hit that hurt (`wasHurt`) brings the levitation.
if !self.creative
&& self.mob_hits_player(
4.0,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:mob_projectile",
);
)
{
self.give_player_effect("minecraft:levitation", 200, 0);
}
}
@@ -2422,6 +2580,8 @@ impl Game<'_> {
&& m.damage(4.0, source)
{
m.extra.levitation = 200;
// Shulker.hurtServer: a shulker hit by a bullet.
horizon_client::mobs::shulker::hit_by_bullet(m);
}
}
},
@@ -2503,21 +2663,22 @@ impl Game<'_> {
.as_ref()
.map(|h| h.item.to_string())
.unwrap_or_default();
// Bucketable.bucketMobPickup: a water bucket scoops up a fish, a
// tadpole or an axolotl, with the data its bucket carries.
if item == "minecraft:water_bucket"
&& self.mobs[index].species() == Species::Axolotl
&& self.mobs[index].alive()
&& let Some((bucket, sound)) = bucketable(self.mobs[index].species())
{
let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1);
let mut filled = ItemStack::new(bucket, 1);
filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components(
&self.mobs[index],
)));
self.audio.play(
"minecraft:item.bucket.fill_axolotl",
Some(self.mobs[index].center()),
1.0,
1.0,
);
self.audio
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
self.trigger_item("minecraft:filled_bucket", &filled);
// Leashable.dropLeash, then discard.
let id = self.mobs[index].id;
self.break_leash(id, true, false);
self.mobs[index].removed = true;
if self.creative {
if let Some(rest) = self.give(filled) {
@@ -2599,6 +2760,7 @@ impl Game<'_> {
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
let is_food = m.kind.food.contains(&item);
let baby_turtle = species == Species::Turtle && m.is_baby();
match species {
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
@@ -2888,6 +3050,14 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
if baby_turtle && !self.mobs[index].is_baby() {
self.handle_mob_events(vec![MobEvent::GiftLoot {
mob: self.mobs[index].id,
table: "minecraft:gameplay/turtle_grow",
at: feet,
}])?;
}
self.swing_arm();
if let Some(s) = sound {
let pitch =
@@ -3321,6 +3491,26 @@ impl Game<'_> {
}
}
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
/// bucket picks up.
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
let fish = "minecraft:item.bucket.fill_fish";
Some(match species {
Species::Cod => ("minecraft:cod_bucket", fish),
Species::Salmon => ("minecraft:salmon_bucket", fish),
Species::Pufferfish => ("minecraft:pufferfish_bucket", fish),
Species::TropicalFish => ("minecraft:tropical_fish_bucket", fish),
Species::Tadpole => (
"minecraft:tadpole_bucket",
"minecraft:item.bucket.fill_tadpole",
),
Species::Axolotl => (
"minecraft:axolotl_bucket",
"minecraft:item.bucket.fill_axolotl",
),
_ => return None,
})
}
/// Mob.getBaseExperienceReward
/// The damage type id of a Horizon damage kind.
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
@@ -3340,6 +3530,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::InFire => "minecraft:in_fire",
K::Lava => "minecraft:lava",
K::Drown => "minecraft:drown",
K::DryOut => "minecraft:dry_out",
K::Magic => "minecraft:magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
@@ -1131,14 +1131,18 @@ impl Game<'_> {
let (p, v) = (b.position, b.velocity);
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
// A merged orb prints once per count, as the oracle does: whether orbs
// merge depends on entity ids, which differ from vanilla's.
for o in &self.orbs {
let (p, v) = (o.position, o.velocity);
out.push(line(
"minecraft:experience_orb",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
));
for _ in 0..o.count.max(1) {
out.push(line(
"minecraft:experience_orb",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
));
}
}
for c in &self.carts {
let (p, v) = (c.position, c.velocity);
+3 -1
View File
@@ -2598,7 +2598,9 @@ impl Game<'_> {
}
pub(super) fn fight_ready(&mut self) -> bool {
self.load_fight();
self.fight.loaded
// spawnExitPortal runs from scanState and the dragon's death, both
// only with players in the fight.
self.fight.loaded && self.fight.players
}
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
self.fight.exit_portal
@@ -3,10 +3,14 @@
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
//! the armor attribute effects (respiration, depth strider, ...).
//! the armor attribute effects (respiration, depth strider, ...) and Soul
//! Speed's `location_changed` and `tick` effects.
use super::Game;
use horizon_client::{enchant::EffectContext, items::ItemStack};
use horizon_core::random::RandomSource;
use horizon_client::{
enchant::{EffectContext, Enchantments, level_value},
items::ItemStack,
};
use horizon_core::{position::BlockPos, random::RandomSource};
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
@@ -384,7 +388,9 @@ impl Game<'_> {
}
}
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
/// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the
/// `ammo_use` conditions as the tool, so Infinity only frees plain arrows.
pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) {
let Some(bow) = self.inventory[self.selected].clone() else {
return (0.0, false);
};
@@ -403,7 +409,7 @@ impl Game<'_> {
0.0,
);
let ammo_ctx = EffectContext {
tool: Some("minecraft:arrow"),
tool: Some(ammo),
items: Some(self.res.items()),
..Default::default()
};
@@ -456,6 +462,134 @@ impl Game<'_> {
}
ignite
}
/// Soul Speed (`data/minecraft/enchantment/soul_speed.json`), the parts of
/// `Enchantment.runLocationChangedEffects` and `tick` that file uses. The
/// `location_changed` effects run when the player enters a new block, lands,
/// or its boots change:
/// - the speed and movement efficiency modifiers turn on over a soul speed
/// block (not flying, not riding) and stay on while airborne, then off
/// (`enchantment_active_check`);
/// - the boots lose 1 durability with a 4% chance when on such a block.
///
/// Every fifth tick the player walking on one also gets a soul particle
/// (returned as its position and velocity, for the caller to show or send
/// to the players around) and sometimes a sound. Runs for the window's own
/// player and for each guest on the server (`guest_server_tick`); a remote
/// client leaves it to its server.
pub(super) fn tick_soul_speed(&mut self) -> Option<([f64; 3], [f64; 3])> {
const ID: &str = "minecraft:soul_speed";
let player = self.player.as_mut()?;
if player.client_side {
return None;
}
let level = self.armor[3]
.as_ref()
.map_or(0, |s| s.enchantment_level(ID));
let position = player.position;
let (on_ground, flying) = (player.ground.on_ground, player.flying);
let moved = position.subtract(player.old_position);
let (tick, width) = (player.health.tick_count, player.dimensions.width);
let fx = &mut player.location_effects;
let on_soul = fx.on_soul_speed_block;
let block = BlockPos::containing(position);
let changed_block = fx.block.replace(block) != Some(block);
let landed = std::mem::take(&mut fx.landed);
// Changed boots: `stopLocationBasedEffects`, then the effects run again.
let equipped = std::mem::replace(&mut fx.boots_level, level) != level;
let was_active = fx.applied_level;
let mut active = was_active > 0 && !equipped;
let mut wear = false;
if level > 0 && (changed_block || landed || equipped) {
let riding = self.riding.is_some();
let wanted = !riding && !flying && (on_soul || (active && !on_ground));
active = wanted;
// `random_chance` is the first term of the wear's `all_of`: it
// draws from the level random before the block is looked at.
let roll = self.world.random.next_float() < 0.04;
wear = roll && on_ground && on_soul;
}
fx.applied_level = if active { level } else { 0 };
// `Entity.getKnownMovement` horizontal speed of at least 1.0E-5.
let walking = moved.x * moved.x + moved.z * moved.z >= 1.0e-10;
let tick_effects = level > 0 && !flying && on_ground && walking && on_soul && tick % 5 == 0;
// The attribute effects were applied to the player before: swap them.
if was_active > 0 && (!active || equipped) {
for (attribute, id, _, op) in
soul_speed_modifiers(&self.res.stations().enchantments, was_active)
{
let _ = player.set_modifier(&attribute, &id, 0.0, &op, false);
}
}
if active && (was_active == 0 || equipped) {
for (attribute, id, amount, op) in
soul_speed_modifiers(&self.res.stations().enchantments, level)
{
let _ = player.set_modifier(&attribute, &id, amount, &op, true);
}
}
if wear && !self.creative {
self.wear_boots(1);
}
if level == 0 {
return None;
}
let r = &mut self.world.random;
// `spawn_particles`: in the bounding box, 0.1 above the feet, thrown
// back against the movement (-0.2) and up (0.1).
let particle = tick_effects.then(|| {
let x = position.x + (r.next_double() - 0.5) * f64::from(width);
let z = position.z + (r.next_double() - 0.5) * f64::from(width);
(
[x, position.y + 0.1, z],
[moved.x * -0.2, 0.1, moved.z * -0.2],
)
});
// `play_sound`: its `random_chance` (35%) comes first in its `all_of`,
// so the level random is drawn every tick the boots are worn; volume
// 0.6, pitch 0.6 to 1.0.
if r.next_float() < 0.35 && tick_effects {
let pitch = r.next_float() * 0.4 + 0.6;
self.audio.play(
"minecraft:particle.soul_escape",
Some([position.x, position.y, position.z]),
0.6,
pitch,
);
}
particle
}
/// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`):
/// Unbreaking applies, and they break when worn out.
fn wear_boots(&mut self, amount: u32) {
let Some(stack) = self.armor[3].clone() else {
return;
};
let loss = self.durability_loss(&stack, amount);
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |info| info.max_damage);
if max == 0 {
return;
}
let Some(boots) = self.armor[3].as_mut() else {
return;
};
boots.damage += loss;
if boots.damage >= max {
self.armor[3] = None;
if let Some(eye) = self.eye_position() {
self.audio.play(
"minecraft:entity.item.break",
Some(eye),
0.8,
0.8 + self.world.random.next_float() * 0.4,
);
}
self.apply_equipment();
}
}
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
/// so the same enchantment on two pieces stacks instead of replacing itself.
@@ -479,3 +613,28 @@ impl Game<'_> {
self.potions.enchant_modifiers = wanted;
}
}
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
fn soul_speed_modifiers(
enchantments: &Enchantments,
level: u32,
) -> Vec<(String, String, f64, String)> {
let Some(e) = enchantments.get("minecraft:soul_speed") else {
return Vec::new();
};
e.effects["minecraft:location_changed"][0]["effect"]["effects"]
.as_array()
.into_iter()
.flatten()
.filter(|a| a["type"] == "minecraft:attribute")
.map(|a| {
(
a["attribute"].as_str().unwrap_or("").to_owned(),
format!("{}/feet", a["id"].as_str().unwrap_or("")),
f64::from(level_value(&a["amount"], level)),
a["operation"].as_str().unwrap_or("add_value").to_owned(),
)
})
.collect()
}
+4 -4
View File
@@ -316,9 +316,6 @@ impl Game<'_> {
);
}
}
if outcome.hurt && !creative {
self.hurt_armor(amount);
}
if raised {
self.after_block(outcome, amount, None, false);
}
@@ -393,9 +390,11 @@ impl Game<'_> {
[feet[0] - w, feet[1], feet[2] - w],
[feet[0] + w, feet[1] + h, feet[2] + w],
);
// The exploding mob is the damage source's entity, so victims
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
attacker: None,
attacker: exploding.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
if hurtable && !m.damage(damage(dist, exposure), source) {
@@ -622,6 +621,7 @@ impl Game<'_> {
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
decay,
false,
);
for mut stack in drops {
if &*stack.item == "minecraft:air" || stack.count == 0 {
@@ -64,6 +64,25 @@ impl Game<'_> {
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
} else if self
.res
.facts()
.get(block.state)
.name
.starts_with("minecraft:suspicious_")
{
// BrushableBlock.onBrokenAfterFall.
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.level_events.push(LevelEvent::LevelEvent {
id: 2001,
pos: at,
data: block.state.0 as i32,
});
self.level_events.push(LevelEvent::GameEvent {
pos: at,
event: "minecraft:block_destroy",
state: None,
});
}
return;
}
@@ -108,16 +127,10 @@ impl Game<'_> {
} else {
amount
};
let hurt = if let Some(p) = self.player.as_mut()
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(kind)
{
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false)
} else {
false
};
if hurt {
self.hurt_armor(amount);
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
}
}
}
+58 -14
View File
@@ -7,12 +7,13 @@ use std::sync::Arc;
#[derive(Clone, Debug)]
pub(super) struct Fangs {
pos: [f64; 3],
yaw: f32,
pub id: u32,
pub pos: [f64; 3],
pub yaw: f32,
warmup: i32,
life: i32,
pub life: i32,
owner: u32,
started: bool,
pub started: bool,
}
#[derive(Default)]
pub(super) struct FangState {
@@ -21,6 +22,11 @@ pub(super) struct FangState {
}
impl Game<'_> {
pub(super) fn add_fangs(&mut self, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
self.next_entity += 1;
self.insert_fangs(self.next_entity, at, yaw, delay, owner);
}
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
if self.fangs.model.is_none() {
self.fangs.model = horizon_client::mobs::load_model(
&self.options.resources,
@@ -29,6 +35,7 @@ impl Game<'_> {
.map(Arc::new);
}
self.fangs.list.push(Fangs {
id,
pos: at,
yaw,
warmup: delay,
@@ -37,6 +44,30 @@ impl Game<'_> {
started: false,
});
}
/// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack.
pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) {
if event == 4
&& let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id)
{
f.started = true;
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
1.0,
);
}
}
/// `EvokerFangs.tick`: clients only advance animation, never damage or discard.
pub(super) fn tick_remote_fangs(&mut self) {
for f in &mut self.fangs.list {
if f.started {
f.life -= 1;
}
}
}
/// `EvokerFangs.tick`.
pub(super) fn tick_fangs(&mut self) {
let mut list = std::mem::take(&mut self.fangs.list);
@@ -50,7 +81,8 @@ impl Game<'_> {
}
if !f.started {
f.started = true;
self.audio.play(
self.lan_entity_event(f.id as i32, 4, f.pos);
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
@@ -81,16 +113,28 @@ impl Game<'_> {
};
bite(self);
self.each_guest_hit(bite);
// `AbstractIllager.considersEntityAsAlly`: with no teams, the illager
// tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose
// owner is allied (itself or another illager). Teams are not modelled.
let illager = |s: Species| {
matches!(
s,
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
)
};
let illagers: Vec<u32> = self
.mobs
.iter()
.filter(|m| illager(m.species()))
.map(|m| m.id)
.collect();
// A missing owner hurts everyone (`currentOwner == null`).
let owner_known = illagers.contains(&f.owner);
for m in &mut self.mobs {
let ally = matches!(
m.species(),
Species::Evoker
| Species::Illusioner
| Species::Vex
| Species::Pillager
| Species::Vindicator
| Species::Witch
);
let ally = owner_known
&& (illager(m.species())
|| m.species() == Species::Vex
&& illagers.contains(&(m.extra.counter2 as u32)));
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
m.damage(
6.0,
+2 -8
View File
@@ -372,17 +372,11 @@ impl Game<'_> {
if self.creative || self.spectator() {
return;
}
let hurt = if let Some(p) = self.player.as_mut()
if let Some(p) = self.player.as_mut()
&& !p.health.dead_or_dying()
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
{
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false)
} else {
false
};
if hurt {
self.hurt_armor(amount);
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
}
}
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
+53 -22
View File
@@ -126,28 +126,7 @@ impl Game<'_> {
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
let mut finder = match self.player_spawn {
Some(finder) => finder,
None if self.level.game_mode == 2 => {
// Adventure mode: the suggestion itself.
let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0);
finder.next_candidate();
finder
}
None => {
let mut radius = self.ui.rules.int("respawn_radius").max(0);
let to_border = self
.border
.distance(f64::from(suggestion[0]), f64::from(suggestion[2]))
.floor() as i32;
if to_border < radius {
radius = to_border;
}
if to_border <= 1 {
radius = 1;
}
PlayerSpawnFinder::new(suggestion, radius, |n| {
entity_random().next_int_bound(n).unwrap_or(0)
})
}
None => self.spawn_finder(suggestion),
};
let found = loop {
let Some(candidate) = finder.next_candidate() else {
@@ -176,6 +155,58 @@ impl Game<'_> {
self.player_spawn = found.is_none().then_some(finder);
Ok(found)
}
/// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut
/// by the world border; the suggestion itself in adventure mode.
fn spawn_finder(&self, suggestion: [i32; 3]) -> PlayerSpawnFinder {
if self.level.game_mode == 2 {
let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0);
finder.next_candidate();
return finder;
}
let mut radius = self.ui.rules.int("respawn_radius").max(0);
let to_border = self
.border
.distance(f64::from(suggestion[0]), f64::from(suggestion[2]))
.floor() as i32;
if to_border < radius {
radius = to_border;
}
if to_border <= 1 {
radius = 1;
}
PlayerSpawnFinder::new(suggestion, radius, |n| {
entity_random().next_int_bound(n).unwrap_or(0)
})
}
/// ServerPlayer.adjustSpawnLocation for a server's new player: where
/// PlayerSpawnFinder.findSpawn puts their feet around the world spawn
/// (`spawn`, the feet there), so that players new to a world don't all
/// stand in one spot. Vanilla waits for each candidate's chunk; a server
/// tick can't, so a candidate in a chunk not loaded yet is passed over,
/// and `spawn` stays when the search never reaches a loaded column.
pub(super) fn server_player_spawn(&self, spawn: [f64; 3]) -> [f64; 3] {
let suggestion = spawn.map(|v| v.floor() as i32);
let mut finder = self.spawn_finder(suggestion);
while let Some(candidate) = finder.next_candidate() {
let (x, z) = match candidate {
SpawnCandidate::Column(x, z) => (x, z),
SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]),
};
if !self.streaming.is_lit(x >> 4, z >> 4) {
continue;
}
let feet = match candidate {
SpawnCandidate::Column(x, z) => level_respawn_pos(&self.spawn_columns(), x, z)
.filter(|&pos| self.player_fits(pos))
.map(bottom_center),
SpawnCandidate::Suggestion => Some(self.fixup_spawn_height(suggestion)),
};
if let Some(feet) = feet {
return [feet.x, feet.y, feet.z];
}
}
spawn
}
/// PlayerSpawnFinder.noCollisionNoLiquid: a player's box standing at the
/// block's bottom centre touches no block or liquid.
fn player_fits(&self, pos: [i32; 3]) -> bool {
+28 -8
View File
@@ -24,11 +24,24 @@ use horizon_protocol::{
};
use std::{
collections::HashMap,
sync::{Arc, OnceLock},
sync::{Arc, Mutex},
};
/// The registries and tags are the same for every world: load them once.
static SYNC: OnceLock<std::result::Result<Arc<SyncData>, String>> = OnceLock::new();
static SYNC: Mutex<Option<std::result::Result<Arc<SyncData>, String>>> = Mutex::new(None);
/// The registries and tags a server sends, loaded from `root` the first time.
pub(super) fn sync_data(root: &std::path::Path) -> std::result::Result<Arc<SyncData>, String> {
let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner());
sync.get_or_insert_with(|| SyncData::load(root).map(Arc::new))
.clone()
}
/// Let go of what `sync_data` loaded: for a preload no world used
/// (game/world_loading.rs).
pub(super) fn forget_sync_data() {
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// The settings of the open world (WorldOptionsScreen's multiplayer section).
#[derive(Clone, Copy, Debug)]
@@ -573,10 +586,7 @@ impl Game<'_> {
config: HubConfig,
host: Option<u128>,
) -> Result<()> {
let root = self.options.resources.clone();
let sync = SYNC
.get_or_init(|| SyncData::load(&root).map(Arc::new))
.clone()?;
let sync = sync_data(&self.options.resources)?;
let biome_names: Vec<String> = self
.res
.biomes()
@@ -1139,8 +1149,17 @@ impl Game<'_> {
g.game_mode = default_mode;
}
if !known {
g.position = spawn;
g.previous_position = spawn;
// ServerPlayer.adjustSpawnLocation: a player new to the world
// stands where PlayerSpawnFinder puts them, not all on one spot
// (it reads the loaded level, which holds the spawn when it is
// the overworld).
let feet = if self.dim == super::Dim::Overworld {
self.server_player_spawn(spawn)
} else {
spawn
};
g.position = feet;
g.previous_position = feet;
g.health = 20.0;
g.food = 20;
g.dimension = super::Dim::Overworld.id().to_owned();
@@ -1420,6 +1439,7 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
m.extra.sprinting = g.sprinting;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
m.extra.effect_particles = visible()
+22 -3
View File
@@ -2183,6 +2183,7 @@ impl Game<'_> {
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let mut soul = None;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
@@ -2252,6 +2253,7 @@ impl Game<'_> {
w.player.shift_key_down = sneaking;
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
@@ -2279,6 +2281,9 @@ impl Game<'_> {
// LivingEntity.baseTick: outside the world border, then tickEffects.
game.border_damage();
game.tick_mob_effects();
// Soul Speed's effects (LivingEntity.baseTick's location_changed
// and tick effects); its particle goes to the players around.
soul = game.tick_soul_speed();
let Some(player) = game.player.as_mut() else {
return (Vec::new(), None);
};
@@ -2301,11 +2306,15 @@ impl Game<'_> {
let marked = std::mem::take(&mut player.health.sync_velocity);
let velocity = marked.then_some(player.velocity);
game.record_combat(&events);
game.wear_armor(&events);
(events, velocity)
});
let Some((events, marked)) = events else {
return;
};
if let Some((at, velocity)) = soul {
super::lan_particles::send_soul_particle(host, at, velocity);
}
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
for event in events {
match event {
@@ -2394,9 +2403,13 @@ impl Game<'_> {
}
if (next / 10) % 2 == 0
&& !creative
&& let Some(elytra) = game.armor[1].as_mut()
&& let Some(stack) = game.armor[1].clone()
{
elytra.damage += 1;
// `ItemStack.hurtAndBreak`: Unbreaking can save the wear.
let loss = game.durability_loss(&stack, 1);
if let Some(elytra) = game.armor[1].as_mut() {
elytra.damage += loss;
}
}
let ctx = game.player_context(None);
game.post_game_event("minecraft:elytra_glide", at, ctx);
@@ -2659,7 +2672,13 @@ impl Game<'_> {
.random
.next_int_bound(touching.len() as i32)
.unwrap_or(0) as usize;
let orb = self.orbs.remove(touching[pick]);
// A merged orb gives its value once per count and goes at zero.
let orb = self.orbs[touching[pick]].clone();
if orb.count <= 1 {
self.orbs.remove(touching[pick]);
} else {
self.orbs[touching[pick]].count -= 1;
}
play.xp_delay = 2;
host.hub.take_item(orb.id as i32, id, 1, at);
// ClientPacketListener.handleTakeItemEntity's sound, at the orb.
@@ -304,6 +304,9 @@ impl Game<'_> {
None => 0,
};
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
for f in &self.fangs.list {
out.push(state(f.id, "minecraft:evoker_fangs", f.pos, f.yaw, false));
}
// LightningBolt: each client draws and flashes its own.
for b in self.bolts.iter().filter(|b| b.id != 0) {
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
@@ -370,6 +370,7 @@ mod tests {
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
effects: Vec::new(),
uuid: [0; 4],
allies: Default::default(),
remote: false,
@@ -24,6 +24,35 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
[c[0], c[1], c[2]]
}
/// `ServerLevel.sendParticles` of one soul particle moving by `velocity`
/// (Soul Speed's `spawn_particles`): sent to the players within 32 blocks.
pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], velocity: [f64; 3]) {
let Some(id) = host
.hub
.registry_id("minecraft:particle_type", "minecraft:soul")
else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle: Particle { id, data: None },
override_limiter: false,
always_show: false,
pos: at,
dist: velocity.map(|v| v as f32),
max_speed: [1.0; 3],
count: 0,
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
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);
}
}
}
impl Game<'_> {
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
/// Keep the local event's rendering; connected players receive one at rest.
@@ -3,6 +3,7 @@
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
@@ -134,6 +135,68 @@ impl Game<'_> {
// Each guest plays it from the global packet.
self.audio.play_unshared(sound, at, volume, 1.0);
}
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
/// spell particles in the potion's colour `data`, flung out in a disc.
pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) {
let ty = if instant {
ParticleType::InstantEffect
} else {
ParticleType::Effect
};
// Vec3.atBottomCenterOf.
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y),
f64::from(pos.z) + 0.5,
];
let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1));
let r = self.particles.level_random();
let crumbs: Vec<[f64; 3]> = (0..8)
.map(|_| {
[
r.next_gaussian() * 0.15,
r.next_double() * 0.2,
r.next_gaussian() * 0.15,
]
})
.collect();
for v in crumbs {
self.particles.add(&crumb, at, v);
}
let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0;
let color = [channel(16), channel(8), channel(0)];
for _ in 0..100 {
let r = self.particles.level_random();
let dist = r.next_double() * 4.0;
let angle = r.next_double() * std::f64::consts::PI * 2.0;
let velocity = [
angle.cos() * dist,
0.01 + r.next_double() * 0.5,
angle.sin() * dist,
];
let brightness = 0.75 + r.next_float() * 0.25;
let options = ParticleOptions {
color: [
color[0] * brightness,
color[1] * brightness,
color[2] * brightness,
1.0,
],
scale: dist as f32,
..ParticleOptions::simple(ty)
};
self.particles.add(
&options,
[
at[0] + velocity[0] * 0.1,
at[1] + 0.3,
at[2] + velocity[2] * 0.1,
],
velocity,
);
}
}
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
self.level_events.push(LevelEvent::LevelEvent {
+19 -1
View File
@@ -109,6 +109,19 @@ pub(super) struct Levels {
parked: HashMap<Dim, Parked>,
}
impl Levels {
/// The chunks of each level not in place (capability `world` lends
/// them to server mods for a call).
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) fn parked_chunks(
&mut self,
) -> impl Iterator<Item = (Dim, &mut horizon_client::chunk_map::ChunkMap)> {
self.parked
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
}
impl Game<'_> {
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
fn open_level(&self, dim: Dim) -> Result<Parked> {
@@ -179,6 +192,7 @@ impl Game<'_> {
let mut locator = self.end.locator();
swap(&mut locator, &mut p.locator);
self.end.set_locator(locator);
self.share_spawn_conditions();
swap(&mut self.save, &mut p.save);
swap(&mut self.block_ticks, &mut p.block_ticks);
swap(&mut self.tick_chunks, &mut p.tick_chunks);
@@ -225,6 +239,7 @@ impl Game<'_> {
swap(&mut a.pots, &mut b.pots);
swap(&mut a.gateways, &mut b.gateways);
swap(&mut a.gateway_servers, &mut b.gateway_servers);
swap(&mut a.conduit_servers, &mut b.conduit_servers);
self.raiding.swap_level(&mut p.raiding);
self.sculk.swap_level(&mut p.sculk);
self.creaking.swap_level(&mut p.creaking);
@@ -313,6 +328,8 @@ impl Game<'_> {
};
self.update_dedicated_streaming();
self.update_world();
// What server mods set in this level since its last tick.
self.apply_mod_block_writes();
result = if paused {
self.tick_lan(true);
Ok(())
@@ -383,7 +400,8 @@ impl Game<'_> {
}
*next += self.clock.tick_duration();
if *next > now {
// A `/pregen` task keeps the workers fed until the next tick.
// A `/pregen` task or force-loaded chunks keep the workers fed
// until the next tick.
self.pump_pregen(*next);
let now = Instant::now();
if *next > now {
+62 -2
View File
@@ -74,14 +74,74 @@ impl Game<'_> {
self.spawn_bolt(at, trap);
}
}
/// `findLightningTargetAround`: a living entity under open sky in the
/// column (3 blocks around), else the top of the column.
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
/// lightning rod points of interest within 128 blocks of `center` whose
/// block is the top (`WORLD_SURFACE`) of its column; the strike lands one
/// above it. Horizon keeps no point-of-interest index, so this scans the
/// loaded chunks the way `/locate poi` does.
fn find_lightning_rod(&self, center: [i32; 3]) -> Option<[i32; 3]> {
const RANGE: i32 = 128;
let facts = self.res.facts();
let mut is_rod: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
let mut best: Option<(i64, [i32; 3])> = None;
let (cx0, cz0) = ((center[0] - RANGE) >> 4, (center[2] - RANGE) >> 4);
let (cx1, cz1) = ((center[0] + RANGE) >> 4, (center[2] + RANGE) >> 4);
for cx in cx0..=cx1 {
for cz in cz0..=cz1 {
let Some(chunk) = self.world.chunks.get(&(cx, cz)) else {
continue;
};
for (i, section) in chunk.sections.iter().enumerate() {
let mut rod = |state: u32| {
*is_rod.entry(state).or_insert_with(|| {
facts
.get(horizon_world::registry::BlockStateId(state))
.name
.ends_with("lightning_rod")
})
};
if !section.blocks.maybe_has(&mut rod) {
continue;
}
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
for j in 0..4096 {
if !rod(section.get(j)) {
continue;
}
let p = [
cx * 16 + (j & 15) as i32,
base_y + (j >> 8) as i32,
cz * 16 + ((j >> 4) & 15) as i32,
];
let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]
.map(|d| i64::from(d) * i64::from(d))
.iter()
.sum::<i64>();
if d > i64::from(RANGE * RANGE) || best.is_some_and(|(b, _)| d >= b) {
continue;
}
let surface = self.world.world_surface(p[0], p[2], |s| facts.is_air(s));
if p[1] == surface - 1 {
best = Some((d, p));
}
}
}
}
}
best.map(|(_, p)| [p[0], p[1] + 1, p[2]])
}
/// `findLightningTargetAround`: the closest lightning rod, else a living
/// entity under open sky in the column (3 blocks around), else the top of
/// the column.
fn lightning_target(&mut self, x: i32, z: i32) -> Option<[i32; 3]> {
if !self.world.loaded(x, z) {
return None;
}
let top =
self.with_level(|b, level, _| b.motion_blocking_top(level, BlockPos::new(x, 0, z)));
if let Some(rod) = self.find_lightning_rod([x, top.y, z]) {
return Some(rod);
}
let sees_sky = |g: &Self, p: [i32; 3]| g.world.light(p[0], p[1], p[2]) >> 4 == 15;
let mut candidates: Vec<[i32; 3]> = self
.mobs
@@ -0,0 +1,485 @@
//! Capability `ui`'s panel beside container screens (WIT `interface
//! browser`, docs/mods.md): the items a recipe viewer mod lists, drawn as
//! the game's own item slots in pages right of the screen's window, with a
//! search box under them. The mod says what the panel holds; the game pages
//! it and tells the mod what the player clicked, typed, and which of the
//! mod's keys went down over an item. With no mod showing a panel, each
//! hook here returns after one check.
use super::Game;
#[cfg(feature = "mods")]
use {
super::Screen,
horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
ui::EditBox,
},
horizon_mods::{BrowserClick, BrowserEvent, BrowserStack},
};
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn draw_mod_browser(&mut self, _batch: &mut horizon_client::hud::HudBatch) {}
pub(super) fn mod_browser_press(&mut self, _right: bool) -> bool {
false
}
pub(super) fn mod_browser_typing(&self) -> bool {
false
}
pub(super) fn mod_browser_key(&mut self, _event: &winit::event::KeyEvent) {}
pub(super) fn mod_browser_text(&mut self, _text: Option<&str>, _backspace: bool) {}
pub(super) fn mod_browser_closed(&mut self) {}
}
/// One slot of the panel, in GUI pixels.
#[cfg(feature = "mods")]
const SLOT: f32 = 18.0;
/// The most columns the panel takes, and the fewest it shows with.
#[cfg(feature = "mods")]
const COLUMNS: (usize, usize) = (12, 2);
/// The gap between the window and the panel, and the screen's edges.
#[cfg(feature = "mods")]
const MARGIN: f32 = 8.0;
/// The panel's state in the game.
#[cfg(feature = "mods")]
#[derive(Default)]
pub(super) struct Browser {
/// The items' owner and version they were made from.
of: Option<(usize, u64)>,
/// The mod's items as stacks; `None` for an item the game doesn't have.
stacks: Vec<Option<ItemStack>>,
page: usize,
search: Option<EditBox>,
/// The search text the mod last heard.
sent: String,
/// `set-recipes`' recipes, shown instead of the items.
pub(super) recipes: Option<super::mod_browser_recipes::Pages>,
}
/// Where the panel's parts are this frame.
#[cfg(feature = "mods")]
pub(super) struct Layout {
/// The panel's whole area, for clicks that hit none of its parts.
pub(super) area: [f32; 4],
/// The grid's top left corner, its columns and rows.
pub(super) grid: (f32, f32),
pub(super) columns: usize,
pub(super) rows: usize,
/// The page arrows' row.
pub(super) arrows_y: f32,
pub(super) search: [f32; 4],
}
#[cfg(feature = "mods")]
impl Layout {
fn per_page(&self) -> usize {
self.columns * self.rows
}
pub(super) fn width(&self) -> f32 {
self.columns as f32 * SLOT
}
fn slot(&self, n: usize) -> (f32, f32) {
(
self.grid.0 + (n % self.columns) as f32 * SLOT,
self.grid.1 + (n / self.columns) as f32 * SLOT,
)
}
/// The page arrows: backward, then forward.
pub(super) fn arrows(&self) -> [(f32, f32); 2] {
[
(self.grid.0, self.arrows_y),
(self.grid.0 + self.width() - 12.0, self.arrows_y),
]
}
}
#[cfg(feature = "mods")]
pub(super) fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool {
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
}
#[cfg(feature = "mods")]
impl Game<'_> {
fn browser_batch(&self) -> Option<HudBatch> {
let r = self.renderer.as_ref()?;
Some(HudBatch::new([r.config.width, r.config.height]))
}
/// The mod showing a panel, with its items made into stacks once per
/// `set-items`; none while no mod shows one or no container screen is up.
fn browser_sync(&mut self) -> Option<usize> {
if self.screen != Screen::Inventory || self.title.is_some() {
return None;
}
let Some((owner, version)) = self.mods.mods.browser_items().map(|(o, v, _)| (o, v)) else {
self.mods.browser.of = None;
self.mods.browser.stacks.clear();
return None;
};
if self.mods.browser.of != Some((owner, version)) {
let items = self.res.items();
let stacks = self
.mods
.mods
.browser_items()
.map_or_else(Vec::new, |(_, _, list)| {
list.iter().map(|s| browser_stack(items, s)).collect()
});
self.mods.browser.stacks = stacks;
// The same title again (the mod updating its buttons) keeps the page.
let old = self.mods.browser.recipes.take();
self.mods.browser.recipes = self
.mods
.mods
.browser_recipes(owner)
.map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old));
self.mods.browser.of = Some((owner, version));
}
Some(owner)
}
/// The panel's parts: right of the window, from the top of the screen to
/// the search box at its foot. None when the screen has no room for it.
fn browser_layout(&self, batch: &HudBatch) -> Option<Layout> {
let image = self.container_image(batch);
let left = (image.left + image.width) as f32 + MARGIN;
let right = batch.width - MARGIN;
let columns = (((right - left) / SLOT).floor().max(0.0) as usize).min(COLUMNS.0);
if columns < COLUMNS.1 {
return None;
}
let x = (left + (right - left - columns as f32 * SLOT) / 2.0).floor();
let arrows_y = MARGIN;
let top = arrows_y + 17.0 + 4.0;
let search = [x, batch.height - MARGIN - 16.0, columns as f32 * SLOT, 16.0];
let rows = ((search[1] - 4.0 - top) / SLOT).floor().max(0.0) as usize;
(rows > 0).then_some(Layout {
area: [left, 0.0, right - left, batch.height],
grid: (x, top),
columns,
rows,
arrows_y,
search,
})
}
fn browser_pages(&self, layout: &Layout) -> usize {
self.mods
.browser
.stacks
.len()
.div_ceil(layout.per_page())
.max(1)
}
/// The stack under the pointer on the panel, an item's or a recipe's,
/// as the mod sent it and as a stack.
fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> {
if self.mods.browser.recipes.is_some() {
if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) {
return Some((sent, stack));
}
let (_, _, sent, stack) = self.recipe_slot_hovered(layout)?;
return Some((sent, stack));
}
let (index, stack) = self.browser_hovered(layout)?;
let (_, _, items) = self.mods.mods.browser_items()?;
Some((items.get(index)?.clone(), stack))
}
/// The panel's item under the pointer: its index and stack.
fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> {
if self.mods.browser.recipes.is_some() {
return None;
}
let first = self.mods.browser.page * layout.per_page();
(0..layout.per_page()).find_map(|n| {
let (x, y) = layout.slot(n);
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
return None;
}
let stack = self.mods.browser.stacks.get(first + n)?.clone()?;
Some((first + n, stack))
})
}
fn browser_search(&mut self, layout: &Layout) -> &mut EditBox {
let search = self.mods.browser.search.get_or_insert_with(|| {
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
edit.max_length = 50;
edit.focused = false;
edit
});
[search.x, search.y, search.width, search.height] = layout.search;
search
}
/// The items' page arrows and grid.
fn draw_browser_items(&mut self, batch: &mut HudBatch, layout: &Layout) {
let pages = self.browser_pages(layout);
self.mods.browser.page = self.mods.browser.page.min(pages - 1);
let page = self.mods.browser.page;
let cursor = self.cursor;
if pages > 1 {
let labels = [
self.translate("gui.recipebook.previous_page", "Previous Page"),
self.translate("gui.recipebook.next_page", "Next Page"),
];
let shown = [page > 0, page + 1 < pages];
let atlas = &self.gui.atlas;
for (((x, y), name), (label, shown)) in layout
.arrows()
.into_iter()
.zip(["page_backward", "page_forward"])
.zip(labels.iter().zip(shown))
{
if !shown {
continue;
}
let hover = inside(cursor, [x, y, 12.0, 17.0]);
let suffix = if hover { "_highlighted" } else { "" };
batch.sprite(
atlas,
&format!("gui/sprites/recipe_book/{name}{suffix}"),
x,
y,
);
horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true);
}
let text = format!("{}/{pages}", page + 1);
let w = HudBatch::text_width(atlas, &text);
let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor();
batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true);
}
let first = page * layout.per_page();
for n in 0..layout.per_page() {
let Some(stack) = self.mods.browser.stacks.get(first + n) else {
break;
};
let Some(stack) = stack.clone() else {
continue;
};
let (x, y) = layout.slot(n);
self.draw_item(batch, &stack, x + 1.0, y + 1.0);
let rect = [x + 1.0, y + 1.0, 16.0, 16.0];
if inside(cursor, [x, y, SLOT, SLOT]) {
batch.fill(
&self.gui.atlas,
x + 1.0,
y + 1.0,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
}
let name = self.pack.names.display_item(&stack.item);
horizon_client::widgets::record_as(
&name,
rect,
true,
horizon_client::widgets::Kind::Button,
);
}
}
/// Draws the panel over the open container screen, then the carried
/// stack or the tooltip again so they stay on top of it.
pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) {
if self.browser_sync().is_none() {
return;
}
let Some(layout) = self.browser_layout(batch) else {
return;
};
let cursor = self.cursor;
if self.mods.browser.recipes.is_some() {
self.draw_browser_recipes(batch, &layout);
} else {
self.draw_browser_items(batch, &layout);
}
let hint = self.translate("gui.recipebook.search_hint", "Search...");
let search = self.browser_search(&layout);
// The hint shows in an empty box without focus, as the recipe book's.
search.suggestion = (search.value.is_empty() && !search.focused).then_some(hint);
let mut search = search.clone();
search.draw(batch, &self.gui.atlas, None);
self.mods.browser.search = Some(search);
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
let lines = self.stack_tooltip(&stack.1);
self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1);
} else {
self.draw_slot_tooltip(batch);
}
}
/// A mouse press on a container screen: true when the panel took it
/// (its search box, page arrows, items or empty space; a click there
/// never throws the carried stack).
pub(super) fn mod_browser_press(&mut self, right: bool) -> bool {
let Some(owner) = self.browser_sync() else {
return false;
};
let Some(batch) = self.browser_batch() else {
return false;
};
let Some(layout) = self.browser_layout(&batch) else {
return false;
};
let cursor = self.cursor;
let shift = self.has_shift_down();
let atlas = &self.gui.atlas;
let search = self.mods.browser.search.get_or_insert_with(|| {
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
edit.max_length = 50;
edit
});
[search.x, search.y, search.width, search.height] = layout.search;
if inside(cursor, layout.search) {
search.focused = true;
search.click(atlas, cursor.0, shift, false);
search.reset_blink();
return true;
}
search.focused = false;
if self.mods.browser.recipes.is_some() {
return self.browser_recipes_press(&layout, owner, right)
|| inside(cursor, layout.area);
}
let pages = self.browser_pages(&layout);
let page = self.mods.browser.page;
let [back, forward] = layout.arrows();
let step = if pages > 1 && page > 0 && inside(cursor, [back.0, back.1, 12.0, 17.0]) {
Some(page - 1)
} else if pages > 1
&& page + 1 < pages
&& inside(cursor, [forward.0, forward.1, 12.0, 17.0])
{
Some(page + 1)
} else {
None
};
if let Some(to) = step {
self.mods.browser.page = to;
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return true;
}
if let Some((index, _)) = self.browser_hovered(&layout) {
let event = BrowserEvent::Clicked(BrowserClick {
index: index as u32,
right,
});
self.mods.mods.browser_event(owner, event);
return true;
}
inside(cursor, layout.area)
}
/// Whether keys go to the panel's search box.
pub(super) fn mod_browser_typing(&self) -> bool {
self.screen == Screen::Inventory
&& self.mods.browser.of.is_some()
&& self.mods.browser.search.as_ref().is_some_and(|s| s.focused)
}
/// A key press while the search box has focus.
pub(super) fn mod_browser_key(&mut self, event: &winit::event::KeyEvent) {
use winit::keyboard::PhysicalKey;
let shortcut = self.edit_shortcut(event);
let ctrl = self.has_control_down();
let shift = self.has_shift_down();
let Some(search) = self.mods.browser.search.as_mut() else {
return;
};
if let Some(shortcut) = shortcut {
search.shortcut(shortcut, &mut self.clipboard);
} else {
let handled = match event.physical_key {
PhysicalKey::Code(code) => search.key(code, ctrl, shift),
_ => false,
};
if !handled
&& !ctrl
&& let Some(text) = &event.text
{
let text: String = text.chars().filter(|c| !c.is_control()).collect();
search.insert_text(&text);
}
}
search.reset_blink();
self.browser_searched();
}
/// Text typed (or one backspace) into the search box (`horizon-ctl type`).
pub(super) fn mod_browser_text(&mut self, text: Option<&str>, backspace: bool) {
let Some(search) = self.mods.browser.search.as_mut() else {
return;
};
if backspace {
search.delete_chars(-1);
} else if let Some(text) = text {
search.insert_text(text);
}
self.browser_searched();
}
/// The mod hears the search text when it changed.
fn browser_searched(&mut self) {
let (Some((owner, _)), Some(search)) = (self.mods.browser.of, &self.mods.browser.search)
else {
return;
};
if search.value != self.mods.browser.sent {
let value = search.value.clone();
self.mods.browser.sent.clone_from(&value);
self.mods.browser.page = 0;
self.mods
.mods
.browser_event(owner, BrowserEvent::Search(value));
}
}
/// The container screen closed: the search box lets go of the keys.
pub(super) fn mod_browser_closed(&mut self) {
if let Some(search) = self.mods.browser.search.as_mut() {
search.focused = false;
}
}
/// The item under the pointer on a container screen, for the mods' keys
/// (`browser`'s `key` event): the panel's, else a slot's.
pub(super) fn mod_browser_pointed(&mut self) -> Option<BrowserStack> {
let batch = self.browser_batch()?;
let stack = match self
.browser_sync()
.and_then(|_| self.browser_layout(&batch))
{
// The panel's stacks as the mod sent them.
Some(layout) if inside(self.cursor, layout.area) => {
return Some(self.browser_pointed_stack(&layout)?.0);
}
_ => {
let slot = self.slot_at(&batch, self.cursor.0, self.cursor.1)?;
self.slot_view(slot)?
}
};
Some(BrowserStack {
item: stack.item.to_string(),
count: stack.count,
components: super::mod_recipes::components(&stack),
})
}
}
/// A mod's item as a stack: an item the game has, its components read as
/// `/give` reads them (ones it can't read are left out).
#[cfg(feature = "mods")]
pub(super) fn browser_stack(
items: &horizon_client::items::Items,
s: &BrowserStack,
) -> Option<ItemStack> {
items.get(&s.item)?;
let (damage, extra) = if s.components.is_empty() {
(0, None)
} else {
horizon_client::item_data::command_components(&s.components).map_or((0, None), |tag| {
horizon_client::item_data::decode_components(Some(&tag))
})
};
Some(ItemStack {
item: s.item.as_str().into(),
count: s.count.clamp(1, 99),
damage,
extra,
})
}
@@ -0,0 +1,523 @@
//! `browser.set-recipes` (docs/mods.md): a mod's recipes shown in its item
//! panel's place, laid out by the game with its own slots, arrows and
//! buttons, paged to the panel's height, under a title and a back button.
//! The mod hears clicks on their slots, their action buttons, the back
//! button and page turns.
use super::{
Game,
mod_browser::{Layout, browser_stack, inside},
};
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
};
use horizon_mods::{BrowserEvent, BrowserLayout, BrowserSlotClick, BrowserStack, RecipePage};
use std::time::Instant;
const SLOT: f32 = 18.0;
/// The title row's height, and the gap under each recipe.
const TITLE: f32 = 18.0;
/// A tab's step: an 18 px slot and a gap.
const TAB: f32 = 20.0;
const GAP: f32 = 6.0;
/// One recipe as the game draws it.
pub(super) struct Shown {
layout: BrowserLayout,
width: usize,
height: usize,
/// Each slot's stacks (the result last), as the mod sent them and as
/// stacks; a stack the game doesn't have is left out of the cycle.
slots: Vec<Vec<(BrowserStack, ItemStack)>>,
note: String,
action: Option<bool>,
}
/// The recipes a panel shows, and its page.
pub(super) struct Pages {
title: String,
/// The tabs' icons and the selected one.
tabs: Vec<(BrowserStack, Option<ItemStack>)>,
tab: usize,
recipes: Vec<Shown>,
page: usize,
since: Instant,
}
impl Pages {
pub(super) fn new(
items: &horizon_client::items::Items,
shown: &RecipePage,
old: Option<Pages>,
) -> Self {
let (title, list, tab) = (&shown.title, &shown.recipes, shown.tab as usize);
let kept = old.filter(|o| o.title == *title && o.tab == tab);
let cycle = |stacks: &[BrowserStack]| -> Vec<(BrowserStack, ItemStack)> {
stacks
.iter()
.filter_map(|s| Some((s.clone(), browser_stack(items, s)?)))
.collect()
};
let recipes = list
.iter()
.map(|r| {
let mut slots: Vec<_> = r.slots.iter().map(|s| cycle(s)).collect();
slots.push(cycle(&r.results));
Shown {
layout: r.layout,
width: r.width as usize,
height: r.height as usize,
slots,
note: r.note.clone(),
action: r.action,
}
})
.collect();
Self {
title: title.clone(),
tabs: shown
.tabs
.iter()
.map(|t| (t.clone(), browser_stack(items, t)))
.collect(),
tab,
recipes,
page: kept.as_ref().map_or(0, |o| o.page),
since: kept.map_or_else(Instant::now, |o| o.since),
}
}
}
impl Shown {
/// The inputs' block: its size and each input slot's place in it.
fn inputs(&self) -> ((f32, f32), Vec<(f32, f32)>) {
let row = |n: usize| (0..n).map(|i| (i as f32 * SLOT, 0.0)).collect();
match self.layout {
BrowserLayout::Grid => {
let (w, h) = (self.width.max(1), self.height.max(1));
let at = (0..w * h)
.map(|i| ((i % w) as f32 * SLOT, (i / w) as f32 * SLOT))
.collect();
((w as f32 * SLOT, h as f32 * SLOT), at)
}
// The input over the flame.
BrowserLayout::Cooking => ((SLOT, 2.0 * SLOT), vec![(0.0, 0.0)]),
BrowserLayout::Single => ((SLOT, SLOT), row(1)),
BrowserLayout::Smithing => ((3.0 * SLOT, SLOT), row(3)),
BrowserLayout::Brewing => ((2.0 * SLOT, SLOT), row(2)),
}
}
/// The height of its slots, arrow and button, `room` wide.
fn block(&self, room: f32) -> f32 {
let h = self.inputs().0.1;
let (_, _, action) = self.places(room);
match self.action {
Some(_) => h.max(action.1 + 12.0),
None => h,
}
}
fn height(&self, room: f32) -> f32 {
let note = if self.note.is_empty() { 0.0 } else { 11.0 };
self.block(room) + note + GAP
}
/// Every slot's place from the recipe's corner (the result last), the
/// arrow's and the action button's: beside the result, or under it
/// when the panel is `room` wide and that doesn't fit.
fn places(&self, room: f32) -> (Vec<(f32, f32)>, (f32, f32), (f32, f32)) {
let ((w, h), mut at) = self.inputs();
// The mod's slots beyond the layout's are not shown.
at.truncate(self.slots.len() - 1);
let arrow = (w + 4.0, ((h - 16.0) / 2.0).floor());
let result = (w + 32.0, ((h - SLOT) / 2.0).floor());
let action = if result.0 + SLOT + 16.0 <= room {
(result.0 + SLOT + 4.0, ((h - 12.0) / 2.0).floor())
} else {
(result.0 + 3.0, result.1 + SLOT + 2.0)
};
while at.len() < self.slots.len() - 1 {
at.push((f32::NAN, f32::NAN));
}
at.push(result);
(at, arrow, action)
}
}
/// The tabs' places, in rows under the title row.
fn tab_places(pages: &Pages, layout: &Layout) -> Vec<(f32, f32)> {
let per_row = ((layout.width() / TAB).floor() as usize).max(1);
(0..pages.tabs.len())
.map(|i| {
(
layout.grid.0 + (i % per_row) as f32 * TAB,
layout.grid.1 + TITLE + 3.0 + (i / per_row) as f32 * TAB,
)
})
.collect()
}
/// Where the recipes start: under the title row and the tabs.
fn recipes_top(pages: &Pages, layout: &Layout) -> f32 {
let tabs = tab_places(pages, layout);
tabs.last()
.map_or(layout.grid.1 + TITLE, |&(_, y)| y + TAB + 3.0)
}
impl Game<'_> {
/// The first recipe of each page, filling the panel from the tabs to
/// the search box.
fn recipe_pages(pages: &Pages, layout: &Layout) -> Vec<usize> {
let room = layout.search[1] - 4.0 - recipes_top(pages, layout);
let mut starts = vec![0];
let mut used = 0.0;
for (i, r) in pages.recipes.iter().enumerate() {
let h = r.height(layout.width());
if used > 0.0 && used + h > room {
starts.push(i);
used = 0.0;
}
used += h;
}
starts
}
/// The recipes on the open page, each with its corner.
fn recipes_shown(&self, layout: &Layout) -> Vec<(usize, (f32, f32))> {
let Some(pages) = &self.mods.browser.recipes else {
return Vec::new();
};
let starts = Self::recipe_pages(pages, layout);
let page = pages.page.min(starts.len() - 1);
let end = starts.get(page + 1).copied().unwrap_or(pages.recipes.len());
let mut y = recipes_top(pages, layout);
(starts[page]..end)
.map(|i| {
let at = (layout.grid.0, y);
y += pages.recipes[i].height(layout.width());
(i, at)
})
.collect()
}
fn back_button(&self, layout: &Layout) -> ([f32; 4], String) {
let label = self.translate("gui.back", "Back");
let w = HudBatch::text_width(&self.gui.atlas, &label) + 8.0;
([layout.grid.0, layout.grid.1, w, 14.0], label)
}
/// What a slot of a shown recipe holds now: its stacks cycle once a second.
fn recipe_slot_now(&self, recipe: usize, slot: usize) -> Option<&(BrowserStack, ItemStack)> {
let pages = self.mods.browser.recipes.as_ref()?;
let stacks = pages.recipes.get(recipe)?.slots.get(slot)?;
let n = pages.since.elapsed().as_secs() as usize;
stacks.get(n % stacks.len().max(1))
}
/// The tab under the pointer: its index and icon.
pub(super) fn recipe_tab_hovered(
&self,
layout: &Layout,
) -> Option<(usize, BrowserStack, ItemStack)> {
let pages = self.mods.browser.recipes.as_ref()?;
let places = tab_places(pages, layout);
places.iter().enumerate().find_map(|(i, &(x, y))| {
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
return None;
}
let (sent, stack) = pages.tabs.get(i)?;
Some((i, sent.clone(), stack.clone()?))
})
}
/// The recipe slot under the pointer: recipe, slot, and its stack now.
pub(super) fn recipe_slot_hovered(
&self,
layout: &Layout,
) -> Option<(usize, usize, BrowserStack, ItemStack)> {
let pages = self.mods.browser.recipes.as_ref()?;
self.recipes_shown(layout)
.into_iter()
.find_map(|(i, (x, y))| {
let (at, _, _) = pages.recipes[i].places(layout.width());
at.iter().enumerate().find_map(|(k, &(sx, sy))| {
if !inside(self.cursor, [x + sx, y + sy, SLOT, SLOT]) {
return None;
}
let (mod_stack, stack) = self.recipe_slot_now(i, k)?.clone();
Some((i, k, mod_stack, stack))
})
})
}
/// Draws the title row, the page's recipes and the page arrows.
pub(super) fn draw_browser_recipes(&mut self, batch: &mut HudBatch, layout: &Layout) {
let Some(pages) = &self.mods.browser.recipes else {
return;
};
let cursor = self.cursor;
let starts = Self::recipe_pages(pages, layout);
let page = pages.page.min(starts.len() - 1);
let (title, pages_n) = (pages.title.clone(), starts.len());
let (back, label) = self.back_button(layout);
let atlas = &self.gui.atlas;
let hover = inside(cursor, back);
let sprite = if hover {
"gui/sprites/widget/button_highlighted"
} else {
"gui/sprites/widget/button"
};
horizon_client::ui::nine_slice(
batch,
atlas,
sprite,
back[0],
back[1],
back[2],
back[3],
u32::MAX,
);
batch.text(atlas, &label, back[0] + 4.0, back[1] + 3.0, u32::MAX, true);
horizon_client::widgets::record(&label, back, true);
let room = layout.width() - back[2] - 4.0;
// Cut to the panel with vanilla's ellipsis (CommonComponents.ELLIPSIS).
let shown = if atlas.font.fit(&title, room) >= title.chars().count() {
title
} else {
let fit = atlas.font.fit(&title, room - atlas.font.width("..."));
let kept: String = title.chars().take(fit).collect();
format!("{}...", kept.trim_end())
};
batch.text(
atlas,
&shown,
back[0] + back[2] + 4.0,
back[1] + 3.0,
u32::MAX,
true,
);
self.draw_recipe_tabs(batch, layout);
if pages_n > 1 {
let labels = [
self.translate("gui.recipebook.previous_page", "Previous Page"),
self.translate("gui.recipebook.next_page", "Next Page"),
];
let visible = [page > 0, page + 1 < pages_n];
let atlas = &self.gui.atlas;
for (((x, y), name), (label, visible)) in layout
.arrows()
.into_iter()
.zip(["page_backward", "page_forward"])
.zip(labels.iter().zip(visible))
{
if !visible {
continue;
}
let suffix = if inside(cursor, [x, y, 12.0, 17.0]) {
"_highlighted"
} else {
""
};
batch.sprite(
atlas,
&format!("gui/sprites/recipe_book/{name}{suffix}"),
x,
y,
);
horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true);
}
let text = format!("{}/{pages_n}", page + 1);
let w = HudBatch::text_width(atlas, &text);
let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor();
batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true);
}
for (i, (x, y)) in self.recipes_shown(layout) {
let Some(r) = self.mods.browser.recipes.as_ref().map(|p| &p.recipes[i]) else {
break;
};
let (at, arrow, action) = r.places(layout.width());
let h = r.block(layout.width());
let (cooking, note, button) =
(r.layout == BrowserLayout::Cooking, r.note.clone(), r.action);
let atlas = &self.gui.atlas;
for &(sx, sy) in at.iter().filter(|p| p.0.is_finite()) {
batch.sprite(atlas, "gui/sprites/container/slot", x + sx, y + sy);
}
if cooking {
batch.sprite(
atlas,
"gui/sprites/container/furnace/lit_progress",
x + 2.0,
y + SLOT + 2.0,
);
}
batch.sprite(
atlas,
"gui/sprites/container/furnace/burn_progress",
x + arrow.0,
y + arrow.1,
);
if let Some(enabled) = button {
let rect = [x + action.0, y + action.1, 12.0, 12.0];
let sprite = match (enabled, inside(cursor, rect)) {
(false, _) => "gui/sprites/widget/button_disabled",
(true, true) => "gui/sprites/widget/button_highlighted",
(true, false) => "gui/sprites/widget/button",
};
horizon_client::ui::nine_slice(
batch,
atlas,
sprite,
rect[0],
rect[1],
12.0,
12.0,
u32::MAX,
);
let color = if enabled {
u32::MAX
} else {
rgba(160, 160, 160, 255)
};
batch.text(atlas, "+", rect[0] + 3.0, rect[1] + 2.0, color, true);
horizon_client::widgets::record(&format!("+ {i}"), rect, enabled);
}
if !note.is_empty() {
batch.text(atlas, &note, x, y + h + 2.0, rgba(224, 224, 224, 255), true);
}
for (k, &(sx, sy)) in at.iter().enumerate().filter(|(_, p)| p.0.is_finite()) {
let Some((_, stack)) = self.recipe_slot_now(i, k).cloned() else {
continue;
};
self.draw_item(batch, &stack, x + sx + 1.0, y + sy + 1.0);
if inside(cursor, [x + sx, y + sy, SLOT, SLOT]) {
batch.fill(
&self.gui.atlas,
x + sx + 1.0,
y + sy + 1.0,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
}
let name = self.pack.names.display_item(&stack.item);
horizon_client::widgets::record_as(
&format!("{name} {i}:{k}"),
[x + sx + 1.0, y + sy + 1.0, 16.0, 16.0],
true,
horizon_client::widgets::Kind::Button,
);
}
}
}
/// The tabs, on slots; the selected one framed.
fn draw_recipe_tabs(&mut self, batch: &mut HudBatch, layout: &Layout) {
let Some(pages) = &self.mods.browser.recipes else {
return;
};
let places = tab_places(pages, layout);
let tabs: Vec<Option<ItemStack>> = pages.tabs.iter().map(|t| t.1.clone()).collect();
let selected = pages.tab;
for (i, (&(x, y), stack)) in places.iter().zip(tabs).enumerate() {
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/container/slot", x, y);
let Some(stack) = stack else {
continue;
};
self.draw_item(batch, &stack, x + 1.0, y + 1.0);
// The selected tab: a white frame around its slot.
if i == selected {
let white = rgba(255, 255, 255, 255);
let atlas = &self.gui.atlas;
batch.fill(atlas, x - 1.0, y - 1.0, SLOT + 2.0, 1.0, white);
batch.fill(atlas, x - 1.0, y + SLOT, SLOT + 2.0, 1.0, white);
batch.fill(atlas, x - 1.0, y, 1.0, SLOT, white);
batch.fill(atlas, x + SLOT, y, 1.0, SLOT, white);
}
if inside(self.cursor, [x, y, SLOT, SLOT]) {
batch.fill(
&self.gui.atlas,
x + 1.0,
y + 1.0,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
}
let name = self.pack.names.display_item(&stack.item);
horizon_client::widgets::record_as(
&format!("{name} tab"),
[x + 1.0, y + 1.0, 16.0, 16.0],
true,
horizon_client::widgets::Kind::Tab(i == selected),
);
}
}
/// A press on the recipes: true when it hit one of their parts.
pub(super) fn browser_recipes_press(
&mut self,
layout: &Layout,
owner: usize,
right: bool,
) -> bool {
let Some(pages) = &self.mods.browser.recipes else {
return false;
};
let cursor = self.cursor;
let starts = Self::recipe_pages(pages, layout);
let page = pages.page.min(starts.len() - 1);
let [back_arrow, forward] = layout.arrows();
let turn = if page > 0 && inside(cursor, [back_arrow.0, back_arrow.1, 12.0, 17.0]) {
Some(page - 1)
} else if page + 1 < starts.len() && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) {
Some(page + 1)
} else {
None
};
if let Some(to) = turn {
if let Some(p) = self.mods.browser.recipes.as_mut() {
p.page = to;
}
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.mods
.mods
.browser_event(owner, BrowserEvent::Page(starts[to] as u32));
return true;
}
if let Some((tab, _, _)) = self.recipe_tab_hovered(layout) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.mods
.mods
.browser_event(owner, BrowserEvent::Tab(tab as u32));
return true;
}
if inside(cursor, self.back_button(layout).0) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.mods.mods.browser_event(owner, BrowserEvent::Back);
return true;
}
if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) {
let event = BrowserEvent::SlotClicked(BrowserSlotClick {
recipe: recipe as u32,
slot: slot as u32,
stack,
right,
});
self.mods.mods.browser_event(owner, event);
return true;
}
let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| {
let r = &pages.recipes[i];
let (_, _, (ax, ay)) = r.places(layout.width());
r.action.is_some() && inside(cursor, [x + ax, y + ay, 12.0, 12.0])
});
if let Some((i, _)) = action {
if pages.recipes[i].action == Some(true) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.mods
.mods
.browser_event(owner, BrowserEvent::Action(i as u32));
}
return true;
}
false
}
}
@@ -111,6 +111,18 @@ impl Game<'_> {
if repeat || !self.mods.mods.has_keys() {
return;
}
// Over an item on a container screen, the mods' keys are `browser`
// events (a recipe viewer's R and U).
if pressed
&& self.screen == Screen::Inventory
&& self.title.is_none()
&& !self.mod_browser_typing()
&& let Some(name) = horizon_client::keybinds::key_name(key)
&& let Some(stack) = self.mod_browser_pointed()
{
self.mods.mods.browser_key(name, &stack);
return;
}
if pressed && (self.screen != Screen::Playing || self.title.is_some()) {
return;
}
@@ -0,0 +1,274 @@
//! Capability `recipes` (docs/mods.md): every item and recipe the game
//! knows, made once for the mods that read them, and the recipe book's
//! known displays. The recipes are the window's own data: the world's in
//! single player, vanilla's on a server (a client is sent no others), while
//! the known displays follow the server's book as the recipe book does.
use super::Game;
use horizon_client::{
crafting::Ingredient,
items::ItemStack,
recipe_book::{Category, Display},
stations::Smithing,
};
use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
use std::{collections::HashMap, sync::Arc};
/// Item id -> its index in [`Recipes::items`].
struct Index(HashMap<Arc<str>, u32>);
impl Index {
fn stack(&self, item: &str, count: u32, components: String) -> Option<RecipeStack> {
Some(RecipeStack {
item: *self.0.get(item)?,
count,
components,
})
}
fn of(&self, s: &ItemStack) -> Option<RecipeStack> {
self.stack(&s.item, s.count, components(s))
}
fn any(&self, i: &Ingredient) -> Vec<RecipeStack> {
i.1.iter()
.filter_map(|id| self.stack(id, 1, String::new()))
.collect()
}
}
/// A stack's components beyond its item's, as vanilla's item syntax writes
/// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "".
pub(super) fn components(s: &ItemStack) -> String {
use horizon_world::nbt::Tag;
let Some(Tag::Compound(entries)) = horizon_client::item_data::encode_components(s) else {
return String::new();
};
let parts: Vec<String> = entries
.iter()
.map(|(k, v)| {
format!(
"{}={}",
k.to_string_lossy(),
horizon_client::snbt::to_snbt(v)
)
})
.collect();
format!("[{}]", parts.join(","))
}
fn recipe(id: String, kind: RecipeKind) -> Recipe {
Recipe {
id,
kind,
category: String::new(),
group: String::new(),
width: 0,
height: 0,
slots: Vec::new(),
results: Vec::new(),
cooking_time: 0,
experience: 0.0,
book_id: -1,
}
}
impl Game<'_> {
/// `recipes.give`: a creative player takes the stack as from the
/// creative inventory; a server hears it as `SetCreativeModeSlot`
/// through the inventory's sync, and refuses it unless creative.
pub(super) fn mod_give(&mut self, item: &str, count: u32, components: &str) {
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
return;
};
if !self.creative {
return;
}
let (damage, extra) = horizon_client::item_data::command_components(components)
.map_or((0, None), |tag| {
horizon_client::item_data::decode_components(Some(&tag))
});
let _ = self.give(ItemStack {
item: item.into(),
// One stack at most, as the creative inventory hands out.
count: count.min(max),
damage,
extra,
});
}
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
if !self.mods.mods.wants_recipes() {
return (None, Arc::from([]));
}
if self.mods.recipes.is_none() {
self.mods.recipes = Some(Arc::new(self.make_mod_recipes()));
}
let (version, known) = self.recipe_book_displays();
if self.mods.known_recipes.0 != Some(version) {
self.mods.known_recipes = (Some(version), known.into());
}
(self.mods.recipes.clone(), self.mods.known_recipes.1.clone())
}
fn make_mod_recipes(&mut self) -> Recipes {
let names = &self.pack.names;
let items = self.res.items();
let mut tags: HashMap<&str, Vec<String>> = HashMap::new();
for (tag, members) in &items.item_tags {
for m in members {
tags.entry(m).or_default().push(tag.clone());
}
}
let table: Vec<RecipeItem> = items
.registry_order
.iter()
.map(|id| RecipeItem {
id: id.to_string(),
name: names.display_item(id),
tags: tags.remove(&**id).map_or_else(Vec::new, |mut t| {
t.sort();
t
}),
})
.collect();
let index = Index(
items
.registry_order
.iter()
.enumerate()
.map(|(i, id)| (id.clone(), i as u32))
.collect(),
);
let mut out = Vec::new();
let cooking: HashMap<String, (u32, f32)> = self
.res
.recipes()
.cooking
.iter()
.map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience)))
.collect();
for e in self.recipe_book_entries() {
let furnace = |c: Category| match c.name().split('_').next() {
Some("blast") => RecipeKind::Blasting,
Some("smoker") => RecipeKind::Smoking,
_ => RecipeKind::Smelting,
};
let kind = match &e.display {
Display::Shaped { .. } => RecipeKind::CraftingShaped,
Display::Shapeless(_) => RecipeKind::CraftingShapeless,
Display::Furnace(_) => furnace(e.category),
};
let mut r = recipe(e.id.clone(), kind);
r.category = e.category.name().to_owned();
r.group = e.group.clone().unwrap_or_default();
r.book_id = e.display_id;
r.slots = match &e.display {
Display::Shaped {
width,
height,
cells,
} => {
(r.width, r.height) = (*width as u32, *height as u32);
cells
.iter()
.map(|c| c.as_ref().map_or_else(Vec::new, |i| index.any(i)))
.collect()
}
Display::Shapeless(list) => list.iter().map(|i| index.any(i)).collect(),
Display::Furnace(i) => vec![index.any(i)],
};
// Requirements shown as other stacks (an arrow's potions): the
// requirement's index counts the filled slots only.
let filled: Vec<usize> = (0..r.slots.len())
.filter(
|&k| !matches!(&e.display, Display::Shaped { cells, .. } if cells[k].is_none()),
)
.collect();
for (req, stacks) in &e.stacks {
if let Some(&k) = filled.get(*req) {
r.slots[k] = stacks.iter().filter_map(|s| index.of(s)).collect();
}
}
r.results = e.results().iter().filter_map(|s| index.of(s)).collect();
if let Some(&(time, xp)) = cooking.get(&e.id) {
(r.cooking_time, r.experience) = (time, xp);
}
out.push(r);
}
for c in &self.res.recipes().cooking {
if c.kind == "minecraft:campfire_cooking" {
let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire);
r.group = c.group.clone();
r.slots = vec![index.any(&c.ingredient)];
r.results = index.of(&c.result).into_iter().collect();
(r.cooking_time, r.experience) = (c.time, c.experience);
out.push(r);
}
}
let stations = self.res.stations();
for s in &stations.recipes.stonecutting {
let mut r = recipe(s.id.clone(), RecipeKind::Stonecutting);
r.slots = vec![index.any(&s.input)];
r.results = index.of(&s.result).into_iter().collect();
out.push(r);
}
for s in &stations.recipes.smithing {
let (id, template, base, addition, results) = match s {
Smithing::Transform {
id,
template,
base,
addition,
result,
} => (
id,
template,
base,
addition,
index.stack(result, 1, String::new()).into_iter().collect(),
),
Smithing::Trim {
id,
template,
base,
addition,
..
} => (id, template, base, addition, index.any(base)),
};
let mut r = recipe(id.clone(), RecipeKind::Smithing);
r.slots = vec![index.any(template), index.any(base), index.any(addition)];
r.results = results;
out.push(r);
}
for b in &stations.brewing.recipes {
let potion = |p: &horizon_client::brewing::PotionIngredient| -> Vec<RecipeStack> {
let mut item_ids: Vec<&String> = p.items.iter().collect();
item_ids.sort();
let mut potions: Vec<&String> = p.potions.iter().flatten().collect();
potions.sort();
item_ids
.into_iter()
.flat_map(|item| {
let one = |c: String| index.stack(item, 1, c);
if potions.is_empty() {
one(String::new()).into_iter().collect::<Vec<_>>()
} else {
potions
.iter()
.filter_map(|p| {
one(format!("[minecraft:potion_contents={{potion:\"{p}\"}}]"))
})
.collect()
}
})
.collect()
};
let mut r = recipe(b.id.clone(), RecipeKind::Brewing);
r.slots = vec![potion(&b.input), potion(&b.reagent)];
r.results = index.of(&b.output).into_iter().collect();
out.push(r);
}
Recipes {
items: table,
recipes: out,
}
}
}
+45 -3
View File
@@ -63,6 +63,14 @@ pub(super) struct ModsState {
/// What players did this tick, for server mods at its end.
#[cfg(feature = "mods")]
pub(super) events: Vec<ModEvent>,
/// A running server mod has capability `world`: its calls get the
/// levels' chunks to read.
#[cfg(feature = "mods")]
pub(super) world: bool,
/// Blocks server mods set, by dimension, made before that level's next
/// tick.
#[cfg(feature = "mods")]
pub(super) block_writes: Vec<(String, horizon_mods::BlockChange)>,
/// `command.run` lines for this window's own world, run on the next tick:
/// run at once, their chat lines would reach the mods while their output
/// is still being shown, and a mod answering its own lines would recurse.
@@ -81,6 +89,15 @@ pub(super) struct ModsState {
/// The HUD font's advances for `render.text-width`, made once.
#[cfg(feature = "mods")]
font_widths: std::sync::Arc<[f32]>,
/// Capability `recipes`'s tables, made once a mod reads them.
#[cfg(feature = "mods")]
pub(super) recipes: Option<std::sync::Arc<horizon_mods::Recipes>>,
/// The recipe book's known displays, and the book's version they are of.
#[cfg(feature = "mods")]
pub(super) known_recipes: (Option<u64>, std::sync::Arc<[i32]>),
/// Capability `ui`'s item panel beside container screens.
#[cfg(feature = "mods")]
pub(super) browser: super::mod_browser::Browser,
/// Extras that are official mods now, being installed.
#[cfg(feature = "mods")]
pub(super) moved: super::moved_extras::MovedState,
@@ -293,6 +310,8 @@ impl Game<'_> {
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
// Entries were replaced wholesale: a screen's owner index is stale.
self.mods.screen = None;
// Content mods may have changed the items: remade on the next read.
self.mods.recipes = None;
self.list_mods();
self.sync_mod_commands();
self.show_mod_output();
@@ -309,6 +328,7 @@ impl Game<'_> {
/// The Mods page's rows, from what loaded.
fn list_mods(&mut self) {
self.mods.server = self.mods.mods.runs_server();
self.mods.world = self.mods.mods.wants_world();
let dev = &self.mods.dev;
self.settings.mods.installed = self
.mods
@@ -351,7 +371,10 @@ impl Game<'_> {
}
/// The chat's tree after the running mods changed.
fn sync_mod_commands(&mut self) {
let mut names = self.mods.mods.commands().peekable();
// In-frame, the window runs server mods too: their names, free text
// here (the server parses their arguments).
let server = self.mods.mods.server_commands().map(|c| c.name.as_str());
let mut names = self.mods.mods.commands().chain(server).peekable();
self.mods.tree = names
.peek()
.is_some()
@@ -440,6 +463,7 @@ impl Game<'_> {
.map(|c| char::from_u32(c).map_or(6.0, |c| atlas.glyph_cell(c).1))
.collect();
}
let (recipes, known_recipes) = self.mod_recipes();
Snapshot {
player,
clock,
@@ -451,6 +475,10 @@ impl Game<'_> {
font_widths: self.mods.font_widths.clone(),
players,
view_hit: self.mod_view_hit(),
recipes,
known_recipes,
placing: self.mod_recipe_screen(),
creative: self.creative,
}
}
/// The first block along the player's view within
@@ -673,8 +701,10 @@ impl Game<'_> {
}
Text::literal(line).with_color(text::RED)
}
server
@ (Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. }) => {
server @ (Output::Tell { .. }
| Output::Broadcast { .. }
| Output::Reply { .. }
| Output::SetBlocks { .. }) => {
self.route_server_output(host.as_deref_mut(), server);
continue;
}
@@ -686,6 +716,18 @@ impl Game<'_> {
self.mods.shake.add(strength, seconds);
continue;
}
Output::PlaceRecipe { book_id, all } => {
self.mod_place_recipe(book_id, all);
continue;
}
Output::Give {
item,
count,
components,
} => {
self.mod_give(&item, count, &components);
continue;
}
Output::CloseScreen { owner } => {
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
self.close_mod_screen(false);
+7 -2
View File
@@ -3,7 +3,7 @@
use super::Game;
use horizon_client::{
blocks::Fluid,
music::{BackgroundMusic, Music, Situation},
music::{BackgroundMusic, Music, MusicFrequency, Situation},
sound_engine::TickContext,
};
@@ -11,6 +11,11 @@ impl Game<'_> {
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
pub(super) fn tick_music(&mut self) {
let (music, volume) = self.situational_music();
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
if frequency != self.music.frequency {
self.music.set_frequency(frequency, music.as_ref());
}
// LevelLoadingScreen: no new track before the player is in the world.
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
self.music
@@ -91,7 +96,7 @@ impl Game<'_> {
})
}
/// `isEyeInFluid(WATER)`: the eye inside the water of its block column.
fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
pub(super) fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32);
let f = self.res.facts().get(self.world.block(bx, by, bz));
if f.fluid != Fluid::Water {
+10 -2
View File
@@ -357,7 +357,8 @@ impl Game<'_> {
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
self.next_entity += 1;
let uuid = entity_nbt::uuid(&tag);
// Structure entities placed with finalizeEntities.
// Structure mobs that generation left to finish: saves from before
// they were finalized at placement, or a placement that could not.
let mut tag = tag;
let mut finalize = false;
if let Tag::Compound(c) = &mut tag {
@@ -449,7 +450,8 @@ impl Game<'_> {
}
}
/// An entity of a type Horizon does not simulate, kept as its compound.
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
fn load_other(&mut self, mut tag: Tag, kind: &str) -> Option<u32> {
strip_finalize(&mut tag);
let position = entity_nbt::position(&tag)?;
let rotation = match tag.get("Rotation") {
Some(Tag::List(l)) => {
@@ -1014,3 +1016,9 @@ impl Game<'_> {
self.save.save_entities(chunks)
}
}
/// Only a mob is finalized; another entity drops the request.
fn strip_finalize(tag: &mut Tag) {
if let Tag::Compound(c) = tag {
c.remove(&"horizon:finalize".into());
}
}
@@ -155,6 +155,8 @@ mod tests {
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
}))
}
+192 -33
View File
@@ -4,18 +4,172 @@
use super::Game;
use horizon_client::{
items::ItemStack,
mobs::{Species, Who, piglin::ADMIRE},
mobs::{
Mob, Species, Who,
finalize::{
SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
stored_drop_chance,
},
piglin::{ADMIRE, ATE_RECENTLY},
},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
fn piglin_loved(&self, item: &str) -> bool {
fn item_in_tag(&self, tag: &str, item: &str) -> bool {
self.res
.items()
.item_tags
.get("minecraft:piglin_loved")
.get(tag)
.is_some_and(|t| t.contains(item))
}
fn piglin_loved(&self, item: &str) -> bool {
self.item_in_tag("minecraft:piglin_loved", item)
}
/// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`).
fn piglin_wanted(&self, item: &str) -> bool {
self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item)
}
/// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes
/// to its slot, anything else to the main hand.
fn piglin_slot(&self, item: &str) -> usize {
let slot = self
.res
.items()
.get(item)
.and_then(|i| i.equip_slot.as_deref())
.filter(|s| matches!(*s, "head" | "chest" | "legs" | "feet"))
.unwrap_or("mainhand");
SLOT_NAMES.iter().position(|n| *n == slot).unwrap_or(0)
}
/// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a wanted
/// item replaces an unwanted one and never the other way round. Vanilla's
/// armour and weapon comparison for two equal-interest items is left out
/// (the piglin keeps what it has).
fn piglin_can_replace(&self, m: &Mob, item: &str, slot: usize) -> bool {
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
return true;
};
self.piglin_wanted(item) && !self.piglin_wanted(&current)
}
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
m.extra.inventory.len() < 8
|| m.extra.inventory.iter().any(|s| {
s.same_item_same_components(stack) && s.count < self.piglin_max_stack(&s.item)
})
}
fn piglin_max_stack(&self, item: &str) -> u32 {
self.res.items().get(item).map_or(64, |i| i.max_stack)
}
/// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without
/// the baby rule, which babies skip entirely here.
fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool {
let item = &*stack.item;
if self.item_in_tag("minecraft:piglin_repellents", item) {
return false;
}
let holds_loved = m
.extra
.off_hand
.as_deref()
.is_some_and(|o| self.piglin_loved(o));
if item == "minecraft:gold_ingot" {
return !holds_loved;
}
let space = self.piglin_can_add(m, stack);
if item == "minecraft:gold_nugget" {
space
} else if self.item_in_tag("minecraft:piglin_food", item) {
m.extra.ints[0] <= 0 && space
} else if !self.piglin_loved(item) {
self.piglin_can_replace(m, item, self.piglin_slot(item))
} else {
!holds_loved && space
}
}
/// `Mob.equipItemIfPossible` for a piglin: put it in its slot when that
/// slot takes it (an armour piece that cannot replace what is worn goes
/// to an empty main hand), dropping what it replaces by drop chance.
fn piglin_equip(&mut self, index: usize, stack: &ItemStack) -> bool {
let item = &*stack.item;
let mut slot = self.piglin_slot(item);
let m = &self.mobs[index];
let mut replace = self.piglin_can_replace(m, item, slot);
if slot > 1 && !replace {
slot = 0;
replace = m.extra.main_hand.is_none();
}
if !replace {
return false;
}
let m = &mut self.mobs[index];
let old = horizon_client::mobs::finalize::item(m, slot);
let chance = stored_drop_chance(m, slot);
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
let old_enchantments = slot_enchantments(m, slot);
set_item(m, slot, item);
// The piglin keeps what the stack carried (Protection IV stays on).
set_slot_enchantments(m, slot, stack.enchantments().to_vec());
// setItemSlotAndDropWhenKilled, then persistenceRequired.
set_drop_chance(m, slot, 2.0);
m.persistent = true;
let at = [m.position.x, m.position.y, m.position.z];
if let (true, Some(old)) = (drop_old, old) {
let mut dropped = ItemStack::new(&old, 1);
dropped.set_crafting_enchantments(old_enchantments);
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
}
true
}
/// `PiglinAi.putInInventory`: what does not fit is thrown at a random
/// spot nearby.
fn piglin_stash(&mut self, index: usize, mut stack: ItemStack) {
let max = self.piglin_max_stack(&stack.item);
let m = &mut self.mobs[index];
for s in &mut m.extra.inventory {
if stack.count > 0 && s.same_item_same_components(&stack) && s.count < max {
let n = (max - s.count).min(stack.count);
s.count += n;
stack.count -= n;
}
}
while stack.count > 0 && m.extra.inventory.len() < 8 {
let n = stack.count.min(max);
m.extra.inventory.push(ItemStack {
count: n,
..stack.clone()
});
stack.count -= n;
}
if stack.count == 0 {
return;
}
let from = [m.position.x, m.position.y + 1.3, m.position.z];
let r = &mut self.world.random;
let to = [
from[0] + f64::from(r.next_float() * 8.0 - 4.0),
from[1],
from[2] + f64::from(r.next_float() * 8.0 - 4.0),
];
self.piglin_throw(from, to, stack);
}
/// `PiglinAi.stopHoldingOffHandItem` for something that is not
/// bartering currency: wear it if it can, else keep it in the inventory.
fn piglin_keep(&mut self, index: usize, stack: ItemStack) {
// Nuggets keep their whole stack in the inventory (a hand slot here
// holds one item id only).
if &*stack.item == "minecraft:gold_nugget" || !self.piglin_equip(index, &stack) {
self.piglin_stash(index, stack);
}
}
/// `BehaviorUtils.throwItem`: toss a stack from a point toward another.
fn piglin_throw(&mut self, from: [f64; 3], to: [f64; 3], stack: ItemStack) {
let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3];
self.spawn_item(stack, from, v, 40);
}
/// `PiglinAi.isWearingSafeArmor`.
pub(super) fn wearing_gold(&self) -> bool {
let tag = self
@@ -28,15 +182,13 @@ impl Game<'_> {
.flatten()
.any(|s| tag.is_some_and(|t| t.contains(&*s.item)))
}
/// Piglins notice gold on the ground within 9 blocks, walk to it and
/// take it (`GoToWantedItem`, `Piglin.pickUpItem`).
/// Piglins notice wanted items on the ground within 9 blocks (`PiglinAi.wantsToPickup`:
/// gold and other loved items, nuggets, food, gear), walk to them and
/// take them (`GoToWantedItem`, `Piglin.pickUpItem`).
pub(super) fn piglin_pickups(&mut self) {
if !self.ui.rules.bool("mob_griefing") {
return;
}
// Whether each item is piglin-loved, kept until an item is added or
// removed (added ones are pushed at the end under a new id).
let mut loved: (Option<(usize, Option<u32>)>, Vec<bool>) = (None, Vec::new());
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Piglin
@@ -44,27 +196,17 @@ impl Game<'_> {
|| m.is_baby()
|| m.extra.using_item > 0
|| m.extra.egg_time > 0
|| m.extra.off_hand.is_some()
{
continue;
}
let pos = m.position;
let key = (self.dropped.len(), self.dropped.last().map(|e| e.id));
if loved.0 != Some(key) {
loved.0 = Some(key);
loved.1 = self
.dropped
.iter()
.map(|e| self.piglin_loved(&e.stack.item))
.collect();
}
let best = self
.dropped
.iter()
.enumerate()
.filter(|&(j, _)| loved.1[j])
.map(|(j, e)| (j, e.position.distance_squared(pos)))
.filter(|(_, d)| *d <= 81.0)
.filter(|&(j, _)| self.piglin_wants(m, &self.dropped[j].stack))
.min_by(|a, b| a.1.total_cmp(&b.1));
let Some((j, _)) = best else {
self.mobs[i].extra.point = None;
@@ -79,24 +221,38 @@ impl Game<'_> {
continue;
}
// Nuggets come whole, anything else one at a time.
let item = e.stack.item.clone();
let (taken_item, id) = (e.clone(), self.mobs[i].id);
let item = e.stack.item.clone();
self.add_pickup(
super::pickups::Taken::Item(Box::new(taken_item)),
super::pickups::Collector::Mob(id),
);
let taken = if &*item == "minecraft:gold_nugget" {
self.dropped.remove(j).stack.count
let stack = if &*item == "minecraft:gold_nugget" {
self.dropped.remove(j).stack
} else {
let e = &mut self.dropped[j];
e.stack.count -= 1;
let one = ItemStack {
count: 1,
..e.stack.clone()
};
if e.stack.count == 0 {
self.dropped.remove(j);
}
1
one
};
let _ = taken;
self.start_admiring(i, item);
if self.piglin_loved(&item) {
self.start_admiring(i, item);
} else if self.item_in_tag("minecraft:piglin_food", &item)
&& self.mobs[i].extra.ints[0] <= 0
{
// PiglinAi.eat: the food is gone and it will not eat for a while.
self.mobs[i].extra.ints[0] = ATE_RECENTLY;
self.mobs[i].nav_stop();
} else {
self.mobs[i].nav_stop();
self.piglin_keep(i, stack);
}
}
}
fn start_admiring(&mut self, index: usize, item: std::sync::Arc<str>) {
@@ -109,14 +265,15 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.piglin.admiring_item", Some(at), 1.0, 1.0);
}
/// `PiglinAi.mobInteract`: hand a piglin something it loves.
/// `PiglinAi.mobInteract`: hand a piglin a gold ingot (only piglin
/// currency is accepted from the hand; other loved items are passed over).
pub(super) fn give_piglin(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
if m.species() != Species::Piglin
|| m.is_baby()
|| m.extra.using_item > 0
|| m.extra.egg_time > 0
|| !self.piglin_loved(item)
|| item != "minecraft:gold_ingot"
{
return false;
}
@@ -124,7 +281,8 @@ impl Game<'_> {
true
}
/// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a
/// roll of the bartering table, thrown toward the player.
/// roll of the bartering table, thrown toward the player; any other loved
/// item it picked up is kept (worn if it can be, else stored).
pub(super) fn finish_admiring(&mut self, mob: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
@@ -132,9 +290,13 @@ impl Game<'_> {
let Some(item) = self.mobs[i].extra.off_hand.take() else {
return;
};
if &*item != "minecraft:gold_ingot" {
self.piglin_keep(i, ItemStack::new(&item, 1));
return;
}
let m = &self.mobs[i];
let from = [m.position.x, m.position.y + 1.3, m.position.z];
let items = if &*item == "minecraft:gold_ingot" && !m.is_baby() {
let items = if !m.is_baby() {
self.res.items().loot_table(
"minecraft:gameplay/piglin_bartering",
false,
@@ -176,10 +338,7 @@ impl Game<'_> {
from[2] + f64::from(r.next_float() * 8.0 - 4.0),
]
});
let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3];
self.spawn_item(stack, from, v, 40);
self.piglin_throw(from, to, stack);
}
}
/// `PiglinAi.angerNearbyPiglins`.
+37 -47
View File
@@ -276,7 +276,8 @@ impl Game<'_> {
} else if let Some(rest) = self.give(filled)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
// `player.drop(stack, false)`.
self.throw_from_eyes(rest, eye, false);
}
}
/// Drinking a potion while right-click is held; true when handled.
@@ -400,8 +401,28 @@ impl Game<'_> {
for action in actions {
self.apply_effect_action(action);
}
self.turtle_helmet_tick();
self.apply_effect_status();
}
/// `Player.tick` -> `turtleHelmetTick`: with a turtle helmet on the head
/// and the eyes out of the water, Water Breathing for 200 ticks (no
/// particles, an icon).
fn turtle_helmet_tick(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let eye_y = p.position.y + f64::from(p.dimensions.eye_height);
if !p.health.dead_or_dying()
&& self.armor[0]
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:turtle_helmet")
&& !self.eye_in_water(p.position.x, eye_y, p.position.z)
{
let mut effect = EffectInstance::new("water_breathing", 200, 0);
effect.visible = false;
self.potions.effects.add(effect);
}
}
/// A client's own effects (`LivingEntity.tickEffects` on the client,
/// `MobEffectInstance.tickClient`): they count down, and the server
/// removes them. Their modifiers and flags move the predicted player.
@@ -674,62 +695,31 @@ impl Game<'_> {
}
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
// ThrownExperienceBottle.onHit: the water-coloured splash (level
// event 2002) and 3 to 11 experience.
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.splash_potion.break",
Some([at.x, at.y, at.z]),
1.0,
pitch,
);
for _ in 0..8 {
let v = [
f64::from(self.world.random.next_float() - 0.5) * 0.3,
0.2,
f64::from(self.world.random.next_float() - 0.5) * 0.3,
];
let p = horizon_client::particles::Particle::terrain(
&mut self.world.random,
[at.x, at.y, at.z],
v,
"item/splash_potion".into(),
0x38_5DC6,
);
self.particles.push(p);
}
// event 2002, its sound 1053) and 3 to 11 experience.
self.level_event_at(2002, block, 0xFF38_5DC6u32 as i32);
self.level_event_at(1053, block, 0);
let r = &mut self.world.random;
let value = 3 + r.next_int_bound(5).unwrap_or(0) + r.next_int_bound(5).unwrap_or(0);
self.spawn_orbs([at.x, at.y, at.z], value as u32);
return;
}
let lingering = &*stack.item == "minecraft:lingering_potion";
let event = if lingering {
"minecraft:entity.lingering_potion.break"
} else {
"minecraft:entity.splash_potion.break"
};
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(event, Some([at.x, at.y, at.z]), 1.0, pitch);
// AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a
// potion with instant effects, 2002 and 1053 otherwise.
let potion = stack.potion().and_then(brewing::potion);
let instant = potion.is_some_and(|d| {
d.effects
.iter()
.any(|(id, ..)| brewing::effect(id).is_some_and(|e| e.instant))
});
let color = brewing::potion_color(stack.potion());
for _ in 0..8 {
let v = [
f64::from(self.world.random.next_float() - 0.5) * 0.3,
0.2,
f64::from(self.world.random.next_float() - 0.5) * 0.3,
];
let p = horizon_client::particles::Particle::terrain(
&mut self.world.random,
[at.x, at.y, at.z],
v,
"item/splash_potion".into(),
color,
);
self.particles.push(p);
}
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
let scale = if lingering { 0.25 } else { 1.0 };
let Some(def) = stack.potion().and_then(brewing::potion) else {
let Some(def) = potion else {
return;
};
// Every player in range (`ThrownSplashPotion.applySplash` over
+3 -1
View File
@@ -235,7 +235,7 @@ impl Game<'_> {
}
out
}
fn difficulty_id(&self) -> i32 {
pub(super) fn difficulty_id(&self) -> i32 {
use horizon_world::food::Difficulty;
match self.world.health_rules.difficulty {
Difficulty::Peaceful => 0,
@@ -796,6 +796,8 @@ impl Game<'_> {
let s = m.extra.raider.get_or_insert_with(Box::default);
s.rider = has_rider;
s.in_village = in_village;
// Vindicator.customServerAiStep: `ServerLevel.isRaided`.
s.raided = species == Species::Vindicator && raids.nearby(block).is_some();
if s.raid_id.is_some_and(|id| raids.get(id).is_none()) {
s.raid_id = None;
s.ticks_outside_raid = 0;
@@ -338,6 +338,25 @@ impl Game<'_> {
ui.client_known = Some(known.clone());
ui.client_version += 1;
}
/// Every entry of the book, in its order (capability `recipes`).
#[cfg(feature = "mods")]
pub(super) fn recipe_book_entries(&mut self) -> Vec<Entry> {
self.ensure_book();
self.recipe_ui
.book
.as_ref()
.map_or_else(Vec::new, |b| b.entries.clone())
}
/// The display ids the player's book holds, and a number that changes
/// whenever they do (capability `recipes`).
#[cfg(feature = "mods")]
pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec<i32>) {
self.sync_client_book();
(
self.recipe_ui.client_version,
self.recipe_ui.client.displays(),
)
}
fn ensure_book(&mut self) {
if self.recipe_ui.book.is_none() {
let mut book = Book::build(self.res.recipes(), self.res.items());
@@ -826,6 +845,34 @@ impl Game<'_> {
self.recipe_ui.ghost.clear();
self.place_recipe(book_type, entry_index, use_max);
}
/// Whether the open screen takes `recipes.place`: a crafting grid's
/// book (the inventory's or a crafting table's).
#[cfg(feature = "mods")]
pub(super) fn mod_recipe_screen(&self) -> bool {
self.screen == Screen::Inventory
&& self.active_book() == Some(BookType::Crafting)
&& !self.spectator()
}
/// `recipes.place`: the book's click on the recipe with this display
/// id, refused as `handlePlaceRecipe` refuses it (no crafting screen,
/// not in the player's book; an impossible one in `place_recipe`).
#[cfg(feature = "mods")]
pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) {
if !self.mod_recipe_screen() || !self.recipe_ui.client.displays().contains(&display_id) {
return;
}
self.ensure_book();
let Some(index) = self
.recipe_ui
.book
.as_ref()
.and_then(|b| b.entries.iter().position(|e| e.display_id == display_id))
else {
return;
};
self.recipe_ui.ghost.clear();
self.place_recipe(BookType::Crafting, index, all);
}
/// `ServerboundPlaceRecipePacket`: the server places the recipe
/// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost
/// recipe when the items are missing.
+14 -11
View File
@@ -1283,6 +1283,8 @@ impl Game<'_> {
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
});
}
if let Some(t) = mob_type {
@@ -1973,9 +1975,9 @@ impl Game<'_> {
}
}
}
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
// player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag).
// Entity.DATA_SHARED_FLAGS_ID's crouching and sprinting bits:
// another player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag), and the dust a runner raises.
if (mannequin
|| self
.remote
@@ -1986,6 +1988,7 @@ impl Game<'_> {
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
m.extra.crouching = flags & 2 != 0;
m.extra.sprinting = flags & 8 != 0;
}
}
}
@@ -2064,7 +2067,10 @@ impl Game<'_> {
{
self.add_pickup(super::pickups::Taken::Orb(Box::new(orb)), collector);
}
self.remove_entity(p.item);
// An orb stays: the server may still hold merged ones in it.
if !orb {
self.remove_entity(p.item);
}
}
// ClientPacketListener.handleSetEntityMotion: knockback on this player.
C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
@@ -2106,6 +2112,7 @@ impl Game<'_> {
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
self.remote_firework_explosion(p.id);
}
self.remote_fangs_event(p.id as u32, p.event);
// 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)
@@ -4051,15 +4058,11 @@ impl Game<'_> {
world_border: false,
sequence: seq,
}));
let target = [
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
];
let ticks = self.ticks;
// Only a predicted placement (predict_place) marks a cell whose
// resend plays a place sound: an empty hand or a non-block item
// places nothing (BlockItem.place is where the sound comes from).
if let Some(r) = self.remote.as_mut() {
r.use_target = Some(hit.pos);
r.placing = Some((target, ticks));
}
if reacts {
self.predict_use(&changes, seq);
@@ -95,6 +95,7 @@ impl Game<'_> {
let (yaw, pitch) = rotation;
self.remove_remote_projectile(id);
match kind {
"minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0),
"minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt {
id,
position: at,
@@ -296,6 +297,7 @@ impl Game<'_> {
/// One tick of interpolation, as `tick_remote_objects`.
pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
self.tick_remote_fangs();
// PrimedTnt.tick runs on a client too: it falls, bounces and burns its
// fuse down (the swell and flash follow it) between the server's
// updates (every 10 ticks), and a client discards it when the fuse
@@ -458,6 +460,7 @@ impl Game<'_> {
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
self.fangs.list.retain(|f| f.id != id);
self.tnts.retain(|t| t.id != id);
self.falling.retain(|f| f.id != id);
self.creatures.projectiles.retain(|p| p.id != id);
@@ -473,6 +476,7 @@ impl Game<'_> {
/// Every one a server sent (a new connection, level or dimension).
pub(super) fn clear_remote_projectiles(&mut self) {
self.fangs.list.clear();
self.tnts.clear();
self.falling.clear();
self.creatures.projectiles.clear();
@@ -493,6 +497,12 @@ impl Game<'_> {
use serde_json::json;
let at = |p: Vec3| [p.x, p.y, p.z];
let mut out = Vec::new();
for f in &self.fangs.list {
out.push(
json!({"id": f.id, "kind": "minecraft:evoker_fangs", "position": f.pos,
"yaw": f.yaw, "started": f.started, "life": f.life}),
);
}
for t in &self.tnts {
out.push(json!({"id": t.id, "kind": "minecraft:tnt", "position": at(t.position), "fuse": t.fuse}));
}
@@ -19,33 +19,7 @@ pub const HELP: &[&str] = &[
impl Game<'_> {
/// Parse `name[k=v,...]` into a registry state (defaults for missing properties).
fn parse_state(&self, text: &str) -> Result<BlockStateId> {
let (name, props) = match text.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (text, ""),
};
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let registry = &self.pack.assets.registry;
let default = registry
.default_state(&name)
.ok_or_else(|| format!("unknown block {name}"))?;
let mut state = registry
.state(default)
.ok_or("missing default state")?
.clone();
for pair in props.split(',').filter(|p| !p.is_empty()) {
let (k, v) = pair
.split_once('=')
.ok_or_else(|| format!("bad property {pair}"))?;
if !state.properties.contains_key(k) {
return Err(format!("{name} has no property {k}").into());
}
state.properties.insert(k.to_owned(), v.to_owned());
}
Ok(registry.resolve(&state)?)
parse_state(self.pack.assets.registry, text)
}
pub(super) fn render_command(&mut self, words: &[&str]) -> Result<Option<Value>> {
let int = |i: usize| -> Result<i32> {
@@ -137,3 +111,36 @@ impl Game<'_> {
}
}
}
/// Parse `name[k=v,...]` into a registry state (defaults for missing properties).
pub(super) fn parse_state(
registry: &horizon_world::registry::BlockRegistry,
text: &str,
) -> Result<BlockStateId> {
let (name, props) = match text.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (text, ""),
};
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let default = registry
.default_state(&name)
.ok_or_else(|| format!("unknown block {name}"))?;
let mut state = registry
.state(default)
.ok_or("missing default state")?
.clone();
for pair in props.split(',').filter(|p| !p.is_empty()) {
let (k, v) = pair
.split_once('=')
.ok_or_else(|| format!("bad property {pair}"))?;
if !state.properties.contains_key(k) {
return Err(format!("{name} has no property {k}").into());
}
state.properties.insert(k.to_owned(), v.to_owned());
}
Ok(registry.resolve(&state)?)
}
+223 -3
View File
@@ -47,19 +47,129 @@ impl Game<'_> {
) -> bool {
false
}
pub(super) fn apply_mod_block_writes(&mut self) {}
}
#[cfg(feature = "mods")]
mod run {
use super::{Game, LanHost, ModEvent, Who};
use horizon_client::chunk_map::ChunkMap;
use horizon_client::{
command::args::GameMode as Mode,
lan::data::component_nbt,
text::{self, Text},
};
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer};
use horizon_mods::{
ArgumentKind, BlockPos, GameMode, Output, ServerCommand, ServerPlayer, WorldView,
};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::BlockStateId;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::{Value, json};
/// Blocks queued for levels that don't tick (another dimension, in-frame)
/// are kept up to this many; the oldest go first.
const MAX_QUEUED_WRITES: usize = 1 << 16;
/// Capability `world`'s view during a call: the levels' chunks, moved out
/// of the game for the call and back after it (no copy).
struct LentWorld {
registry: &'static BlockRegistry,
biomes: std::sync::Arc<horizon_client::biome::Biomes>,
dims: Vec<(&'static str, ChunkMap)>,
}
impl WorldView for LentWorld {
fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
let chunk = chunks.get(&(x >> 4, z >> 4))?;
Some(state_string(self.registry, chunk.block(x & 15, y, z & 15)))
}
fn biome(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
let chunk = chunks.get(&(x >> 4, z >> 4))?;
Some(self.biomes.get(chunk.biome(x, y, z)).name.clone())
}
fn has_dimension(&self, dimension: &str) -> bool {
matches!(
dimension,
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
)
}
fn check_state(&self, state: &str) -> Result<(), String> {
crate::game::render_control::parse_state(self.registry, state)
.map(drop)
.map_err(|e| format!("{state}: {e}"))
}
}
/// Server mods' commands as a root of the commands report
/// (`lan::commands`): a literal per command, then its arguments in a
/// chain, each node runnable where only optional arguments follow.
fn command_tree<'a>(commands: impl Iterator<Item = &'a ServerCommand>) -> Value {
let mut root = serde_json::Map::new();
for c in commands {
let mut next: Option<(String, Value)> = None;
for (i, a) in c.arguments.iter().enumerate().rev() {
let executable = c.arguments.get(i + 1).is_none_or(|b| b.optional);
let (parser, properties) = match a.kind {
ArgumentKind::Word => ("brigadier:string", json!({"type": "word"})),
ArgumentKind::Text => ("brigadier:string", json!({"type": "greedy"})),
ArgumentKind::Integer((min, max)) => {
let mut p = serde_json::Map::new();
if let Some(min) = min {
p.insert("min".into(), min.into());
}
if let Some(max) = max {
p.insert("max".into(), max.into());
}
("brigadier:integer", Value::Object(p))
}
ArgumentKind::Number => ("brigadier:double", Value::Null),
ArgumentKind::Boolean => ("brigadier:bool", Value::Null),
ArgumentKind::Player => (
"minecraft:entity",
json!({"amount": "single", "type": "players"}),
),
ArgumentKind::BlockPos => ("minecraft:block_pos", Value::Null),
};
let mut node = json!({
"type": "argument",
"parser": parser,
"properties": properties,
"executable": executable,
});
if let Some((name, child)) = next.take() {
node["children"] = json!({ name: child });
}
next = Some((a.name.clone(), node));
}
let executable = c.arguments.first().is_none_or(|a| a.optional);
let mut literal = json!({"type": "literal", "executable": executable});
if let Some((name, child)) = next {
literal["children"] = json!({ name: child });
}
root.insert(c.name.clone(), literal);
}
if root.is_empty() {
return Value::Null;
}
json!({"type": "root", "children": root})
}
/// `minecraft:oak_log[axis=y]`.
fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String {
let Some(s) = registry.state(state) else {
return "minecraft:air".into();
};
if s.properties.is_empty() {
return s.name.clone();
}
let props: Vec<String> = s
.properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
format!("{}[{}]", s.name, props.join(","))
}
/// `UUID.toString()`.
fn uuid_string(u: u128) -> String {
@@ -138,12 +248,14 @@ mod run {
// A server has no frames to check its dev mods in.
self.reload_dev_mods();
}
self.send_server_mod_commands();
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);
self.lend_world();
let before = std::mem::take(&mut self.mods.players);
for p in before
.iter()
@@ -197,8 +309,94 @@ mod run {
}
}
self.mods.mods.server_tick(self.ticks);
self.return_world();
self.mods.players = players;
self.show_mod_output();
self.apply_mod_block_writes();
}
/// Players' command trees list the running server mods' commands
/// after vanilla's, whenever they change (or a hub opens).
fn send_server_mod_commands(&mut self) {
let key = self.mods.mods.generation();
let Some(host) = self.lan.as_mut() else {
return;
};
if host.hub.mod_commands_key() == Some(key) {
return;
}
let tree = command_tree(self.mods.mods.server_commands());
host.hub.set_mod_commands(key, tree);
}
/// Lends the levels' chunks to capability `world` for the calls up to
/// [`Game::return_world`], when a running server mod has it.
fn lend_world(&mut self) {
if !self.mods.world {
return;
}
let mut dims = vec![(self.dim.id(), std::mem::take(&mut self.world.chunks))];
for (dim, chunks) in self.levels.parked_chunks() {
dims.push((dim.id(), std::mem::take(chunks)));
}
let registry = self.pack.assets.registry;
let biomes = self.res.biomes().clone();
self.mods.mods.lend_world(Box::new(LentWorld {
registry,
biomes,
dims,
}));
}
/// Puts back the chunks [`Game::lend_world`] lent.
fn return_world(&mut self) {
let Some(view) = self.mods.mods.take_world() else {
return;
};
let view: Box<dyn std::any::Any> = view;
let Ok(lent) = view.downcast::<LentWorld>() else {
return;
};
let mut dims = lent.dims;
if let Some(i) = dims.iter().position(|(d, _)| *d == self.dim.id()) {
self.world.chunks = dims.swap_remove(i).1;
}
for (dim, chunks) in self.levels.parked_chunks() {
if let Some(i) = dims.iter().position(|(d, _)| *d == dim.id()) {
*chunks = dims.swap_remove(i).1;
}
}
}
/// Makes the blocks server mods set in the level in place, in order,
/// as `/setblock` does. Those of other dimensions wait for their level.
pub(in crate::game) fn apply_mod_block_writes(&mut self) {
if self.mods.block_writes.is_empty() {
return;
}
let dim = self.dim.id();
let (now, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.mods.block_writes)
.into_iter()
.partition(|(d, _)| d == dim);
self.mods.block_writes = later;
let registry = self.pack.assets.registry;
for (_, b) in now {
let state = match crate::game::render_control::parse_state(registry, &b.state) {
Ok(state) => state,
Err(e) => {
log::warn!("Horizon: a server mod set {}: {e}", b.state);
continue;
}
};
if let Err(e) = self.place_state([b.x, b.y, b.z], state, false) {
log::debug!(
"Horizon: a server mod's block at {} {} {}: {e:?}",
b.x,
b.y,
b.z
);
}
}
}
/// A player broke (or placed) `state` at `pos`: the player acting now.
@@ -240,8 +438,11 @@ mod run {
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
self.lend_world();
let owned = self.mods.mods.server_command(&me, line);
self.return_world();
self.show_mod_output();
self.apply_mod_block_writes();
owned
}
@@ -266,10 +467,14 @@ mod run {
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
if !self.mods.mods.server_command(&me, line) {
self.lend_world();
let owned = self.mods.mods.server_command(&me, line);
self.return_world();
if !owned {
return false;
}
self.route_mod_output(Some(host));
self.apply_mod_block_writes();
true
}
@@ -349,6 +554,21 @@ mod run {
self.tell_server_player(host.as_deref_mut(), &p.id, line);
}
}
Output::SetBlocks {
name,
dimension,
blocks,
} => {
let queued = &mut self.mods.block_writes;
queued.extend(blocks.into_iter().map(|b| (dimension.clone(), b)));
if queued.len() > MAX_QUEUED_WRITES {
let over = queued.len() - MAX_QUEUED_WRITES;
queued.drain(..over);
log::warn!(
"Horizon: mod {name} set more blocks than wait for their level: {over} dropped"
);
}
}
_ => {}
}
}
+6 -6
View File
@@ -171,8 +171,8 @@ pub(super) struct Spawn {
pub(super) struct Spill {
pub(super) spawns: Vec<Spawn>,
/// Experience orbs (`ExperienceOrb.award`): where and how much, each
/// having drawn its merge id from the level random.
pub(super) orbs: Vec<([f64; 3], u32)>,
/// and the merge id it drew from the level random.
pub(super) orbs: Vec<([f64; 3], u32, i32)>,
/// Block entities the behaviour removed, for the game's own follow-ups
/// (open screens, playing songs).
pub(super) removed: Vec<([i32; 3], BlockEntity)>,
@@ -342,8 +342,8 @@ impl GameLevel<'_> {
while amount > 0 {
let value = removal::orb_value(amount);
amount -= value;
let _merge_id = self.world.random.next_int_bound(40);
self.spill.orbs.push((at, value));
let merge_id = self.world.random.next_int_bound(40).unwrap_or(0);
self.spill.orbs.push((at, value, merge_id));
}
}
/// `BlockEntity.preRemoveSideEffects` and `removeBlockEntity` for the block
@@ -583,8 +583,8 @@ impl Game<'_> {
let motion = s.motion.unwrap_or_else(new_item_motion);
self.spawn_item(s.stack, s.pos, motion, s.delay);
}
for (at, value) in spill.orbs {
self.add_orb(at, value);
for (at, value, merge_id) in spill.orbs {
self.award_orb(at, value, merge_id);
}
for (pos, entity) in spill.removed {
let key = (pos[0], pos[1], pos[2]);
+2 -1
View File
@@ -713,7 +713,8 @@ impl Game<'_> {
if let Some(rest) = self.give(stack)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40);
// Inventory.placeItemBackInInventory: `player.drop(stack, false)`.
self.throw_from_eyes(rest, eye, false);
}
}
/// `Player.giveExperienceLevels`.
+49
View File
@@ -33,6 +33,55 @@ impl Game<'_> {
self.container = Some(key);
self.ender_open = Some(key);
}
/// The block above `key` is a redstone conductor.
fn conductor_above(&self, key: (i32, i32, i32)) -> bool {
let above = self.world.block(key.0, key.1 + 1, key.2);
self.res.behaviours().facts(above).flags
& horizon_world::level::data::state_flags::CONDUCTOR
!= 0
}
/// `ChestBlock.isChestBlockedAt`: a redstone conductor above the chest or
/// a cat sitting on it.
fn chest_blocked_at(&self, key: (i32, i32, i32)) -> bool {
if self.conductor_above(key) {
return true;
}
// isCatSittingOnChest: a cat whose box meets the block above.
let area = horizon_core::geometry::Aabb::new(
horizon_core::geometry::Vec3::new(
f64::from(key.0),
f64::from(key.1 + 1),
f64::from(key.2),
),
horizon_core::geometry::Vec3::new(
f64::from(key.0 + 1),
f64::from(key.1 + 2),
f64::from(key.2 + 1),
),
);
self.mobs.iter().any(|m| {
m.species() == horizon_client::mobs::Species::Cat
&& m.alive()
&& horizon_client::mobs::tame::cat_sitting(m)
&& m.bounds().intersects(area)
})
}
/// `ChestBlock.getMenuProvider` (`combine(.., false)`) and
/// `EnderChestBlock.useWithoutItem`: a blocked chest, or a double chest
/// with either half blocked, opens no menu.
pub(super) fn chest_menu_blocked(&self, key: (i32, i32, i32), name: &str) -> bool {
match name {
"minecraft:chest" | "minecraft:trapped_chest" => {
self.chest_blocked_at(key)
|| self
.chest_partner(key)
.is_some_and(|(other, _)| self.chest_blocked_at(other))
}
// The ender chest only looks at the block above (no cat check).
"minecraft:ender_chest" => self.conductor_above(key),
_ => false,
}
}
/// Closing it copies the items back and leaves no block-entity contents.
pub(super) fn close_ender_chest(&mut self) {
let Some(key) = self.ender_open.take() else {
@@ -46,6 +46,10 @@ impl WorldRequest {
options: &Options,
settings: &Settings,
) -> Result<PreparedWorld> {
// Before any chunk is read: a loaded container with a loot table is rolled as its chunk
// is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world
// opens. Every world opens here: in-frame, on the server thread, on a dedicated server.
horizon_client::structure_blocks::loot_data(&options.resources);
let Self {
save_root,
folder,
@@ -192,6 +196,82 @@ impl WorldLoader {
}
}
/// How long the preloaded world data outlives the world screens: a player
/// who goes back to the title or to Multiplayer and opens no world for this
/// long gets the memory back.
const PRELOAD_KEPT: std::time::Duration = std::time::Duration::from_secs(60);
enum Preload {
Idle,
/// Read, or being read, on the "World data preload" thread. `left`: when
/// the player last left the world screens.
Started {
thread: std::thread::JoinHandle<()>,
left: Option<std::time::Instant>,
},
/// A world opened: the data is the world's now, and stays.
Used,
}
static PRELOAD: std::sync::Mutex<Preload> = std::sync::Mutex::new(Preload::Idle);
/// Each frame of the title: read what every world opening loads and keeps
/// for the rest of the process (the worldgen bundle, the loot tables and the
/// registries the server sends) on a helper thread while the player is on the
/// world screens (`on_world_screens`: Singleplayer, Create World and their
/// dialogs), so it overlaps the time spent choosing a world. A world opened
/// meanwhile waits for the part being read instead of reading it again. Not
/// at the title screen itself: many players only go to Multiplayer, and
/// small machines need the memory. Dropped after `PRELOAD_KEPT` away from
/// the world screens without a world opening.
pub(super) fn preload_world_data(
on_world_screens: bool,
resources: &std::path::Path,
registry: &'static horizon_world::registry::BlockRegistry,
) {
let mut preload = PRELOAD.lock().unwrap_or_else(|e| e.into_inner());
match &mut *preload {
Preload::Used => {}
Preload::Idle if on_world_screens => {
let resources = resources.to_owned();
let spawned = std::thread::Builder::new()
.name("World data preload".into())
.spawn(move || {
horizon_threads::behind_server();
// Errors are left for the world opening to report.
let _ = horizon_client::vanillagen::shared_data(
&resources.join("worldgen"),
registry,
);
let _ = horizon_client::structure_blocks::loot_data(&resources);
let _ = super::lan::sync_data(&resources);
});
match spawned {
Ok(thread) => *preload = Preload::Started { thread, left: None },
Err(e) => log::warn!("Horizon: world data preload: {e}"),
}
}
Preload::Idle => {}
Preload::Started { left, .. } if on_world_screens => *left = None,
Preload::Started { thread, left } => {
let left = *left.get_or_insert_with(std::time::Instant::now);
if left.elapsed() >= PRELOAD_KEPT && thread.is_finished() {
horizon_client::vanillagen::forget_shared_data();
horizon_client::structure_blocks::forget_loot_data();
super::lan::forget_sync_data();
horizon_threads::release_free_memory();
*preload = Preload::Idle;
log::info!("Horizon: preloaded world data dropped, no world opened");
}
}
}
}
/// A world opens: what `preload_world_data` read is the world's from now on.
pub(super) fn world_data_used() {
*PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used;
}
pub(super) fn make_streaming(
res: &Resources,
pack: &PackResources,
@@ -0,0 +1,134 @@
//! `Zombie.hurtServer`'s reinforcements: a hurt zombie on Hard may call
//! another of its kind from 7 to 40 blocks away to fight its target.
use super::Game;
use horizon_client::mobs::{
MobType, Species, Who,
finalize::{self, Attr, Op},
spawn_rules::{self, Reason},
spawn_selection::{self, NaturalLevel},
spawner::SpawnerLevel,
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::food::Difficulty;
/// `Zombie.REINFORCEMENT_ATTEMPTS`.
const ATTEMPTS: usize = 50;
/// `Zombie.REINFORCEMENT_RANGE_MIN` and `REINFORCEMENT_RANGE_MAX`.
const RANGE_MIN: i32 = 7;
const RANGE_MAX: i32 = 40;
/// One axis of the offset: `Mth.nextInt(random, 7, 40) * Mth.nextInt(random, -1, 1)`.
fn offset(random: &mut LegacyRandom) -> i32 {
let distance = random
.next_int_between_inclusive(RANGE_MIN, RANGE_MAX)
.unwrap_or(RANGE_MIN);
distance * random.next_int_between_inclusive(-1, 1).unwrap_or(0)
}
impl Game<'_> {
/// `Zombie.hurtServer` after the hit: on Hard, with the zombie's chance
/// and monsters spawning, a copy of it appears out of sight and takes
/// `target`; both then call less often.
pub(super) fn call_reinforcements(&mut self, caller: u32, target: Who) {
let Some(index) = self.mobs.iter().position(|m| m.id == caller) else {
return;
};
if self.world.health_rules.difficulty != Difficulty::Hard {
return;
}
let zombie = &mut self.mobs[index];
if f64::from(zombie.random.next_float()) >= zombie.attrs.spawn_reinforcements {
return;
}
// ServerLevel.isSpawningMonsters.
if !self.ui.rules.bool("spawn_mobs") || !self.ui.rules.bool("spawn_monsters") {
return;
}
let kind = zombie.kind.clone();
let from = [
zombie.position.x.floor() as i32,
zombie.position.y.floor() as i32,
zombie.position.z.floor() as i32,
];
let mut own = std::mem::replace(&mut zombie.random, LegacyRandom::new(0));
let mut rules = std::mem::replace(&mut self.world.random, LegacyRandom::new(0));
let spot = self.reinforcement_spot(&kind, from, &mut own, &mut rules);
self.mobs[index].random = own;
self.world.random = rules;
let Some(spot) = spot else {
return;
};
let Some(id) = self.spawn_mob(&kind.id, spot.map(f64::from), false) else {
return;
};
if let Some(reinforcement) = self.mobs.iter_mut().find(|m| m.id == id) {
reinforcement.ai.target = Some(target);
// ZOMBIE_REINFORCEMENT_CALLEE_CHARGE.
finalize::add_modifier(
reinforcement,
Attr::SpawnReinforcements,
"minecraft:reinforcement_callee_charge",
-f64::from(0.05f32),
Op::AddValue,
);
}
// The caller's charge grows by 0.05 each time it calls.
let caller = &mut self.mobs[index];
let charged = finalize::modifier_amount(
caller,
Attr::SpawnReinforcements,
"minecraft:reinforcement_caller_charge",
)
.unwrap_or(0.0);
finalize::add_modifier(
caller,
Attr::SpawnReinforcements,
"minecraft:reinforcement_caller_charge",
charged - 0.05,
Op::AddValue,
);
}
/// The block `Zombie.hurtServer` puts the reinforcement at: the first of
/// 50 random offsets with a valid spawn surface, no alive player within
/// 7 blocks, and room for the zombie standing on the block's corner.
fn reinforcement_spot(
&self,
kind: &MobType,
from: [i32; 3],
own: &mut LegacyRandom,
rules: &mut LegacyRandom,
) -> Option<[i32; 3]> {
let level = self.natural_level();
let (width, height) = (kind.width as f32, kind.height as f32);
for _ in 0..ATTEMPTS {
let [x, y, z] = from.map(|v| v + offset(own));
if !spawn_rules::is_spawn_position_ok(kind.species, &level, x, y, z)
|| !spawn_rules::check_spawn_rules(
kind.species,
&level,
Reason::Reinforcement,
rules,
x,
y,
z,
)
{
continue;
}
// setPos(x, y, z): the box is centred on the block's corner.
let bounds =
spawn_selection::spawn_box(f64::from(x), f64::from(y), f64::from(z), width, height);
// Drowned.canSpawnInLiquids.
let wet_ok = kind.species == Species::Drowned
|| !spawn_selection::contains_any_liquid(&level, bounds);
if !level.alive_player_near(f64::from(x), f64::from(y), f64::from(z), 7.0)
&& level.unobstructed(bounds)
&& level.no_collision(bounds)
&& wet_ok
{
return Some([x, y, z]);
}
}
None
}
}
+2 -1
View File
@@ -118,8 +118,9 @@ impl Style {
(x, y, w): (f32, f32, f32),
label: &Text,
hover: bool,
active: bool,
) {
ui::button(batch, atlas, x, y, w, 20.0, label, true, hover);
ui::button(batch, atlas, x, y, w, 20.0, label, active, hover);
}
pub fn slider(
&self,
+32 -1
View File
@@ -468,6 +468,10 @@ fn max_workers() -> u32 {
}
impl Item {
/// False for a setting whose system is not in Horizon yet (`Opt::available`).
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
@@ -510,6 +514,9 @@ impl Item {
}
}
pub fn set_slider(self, s: &mut Settings, v: f32) {
if !self.available() {
return;
}
match self {
Item::Vanilla(o) => o.set_slider(s, v),
Item::Horizon(h) => h.set_slider(s, v),
@@ -517,6 +524,9 @@ impl Item {
self.touched(s);
}
pub fn cycle(self, s: &mut Settings, back: bool, framebuffer: [i32; 2]) {
if !self.available() {
return;
}
match self {
Item::Vanilla(o) => o.cycle(s, back, framebuffer),
Item::Horizon(h) => h.cycle(s, back),
@@ -689,6 +699,7 @@ impl GraphicsUi {
})
.collect();
let mut index = 0;
let mut tooltip = None;
for wd in &widgets {
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
@@ -700,6 +711,13 @@ impl GraphicsUi {
continue;
}
let label = item.label(s);
let live = item.available();
if hover && !live {
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
}
match item.slider(s) {
Some(value) => STYLE.slider(
batch,
@@ -709,7 +727,14 @@ impl GraphicsUi {
value,
hover || self.dragging == Some(item),
),
None => STYLE.button(batch, atlas, (wd.x, wd.y, wd.w), &label, hover),
None => STYLE.button(
batch,
atlas,
(wd.x, wd.y, wd.w),
&label,
hover && live,
live,
),
}
STYLE.description(
batch,
@@ -732,6 +757,7 @@ impl GraphicsUi {
(wd.x, wd.y, wd.w),
&Text::literal(label),
hover,
true,
);
}
}
@@ -751,6 +777,9 @@ impl GraphicsUi {
Level::Advanced => "Graphics: Advanced",
};
STYLE.title(batch, atlas, &Text::literal(title));
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
}
}
/// A press (or a drag with `drag`) at `(x, y)`.
pub fn click(
@@ -788,6 +817,8 @@ impl GraphicsUi {
}
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if !item.available() => Vec::new(),
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
+14 -2
View File
@@ -111,6 +111,12 @@ pub enum HealthEvent {
amount: f32,
},
Phase(&'static str),
/// `Player.hurtArmor`: a hit that doesn't bypass armor wears the worn
/// pieces (`LivingEntity.getDamageAfterArmorAbsorb`); the game applies it.
ArmorHurt {
source: DamageTypeId,
amount: f32,
},
/// `ServerLevel.broadcastDamageEvent`: the client's
/// `LivingEntity.handleDamageEvent` plays the source's hurt sound.
DamageBroadcast {
@@ -139,6 +145,7 @@ impl HealthEvent {
format!("record-damage {:08x}", amount.to_bits())
}
Self::Phase(name) => name.into(),
Self::ArmorHurt { amount, .. } => format!("hurt-armor {:08x}", amount.to_bits()),
Self::DamageBroadcast { .. } => "broadcast-damage".into(),
Self::Criterion { .. } => "hurt-criterion".into(),
}
@@ -534,9 +541,14 @@ impl ServerDamageEnvironment<Attributes> for Environment<'_> {
fn bypasses(&mut self, kind: Bypass) -> io::Result<bool> {
Ok(self.source.bypasses(kind))
}
fn hurt_armor(&mut self, _: &mut Attributes, _: f32) -> io::Result<()> {
fn hurt_armor(&mut self, _: &mut Attributes, damage: f32) -> io::Result<()> {
// The worn armor lives in the game, which wears it from this event.
self.event(HealthEvent::ArmorHurt {
source: self.source.type_id(),
amount: damage,
});
Ok(())
} // empty equipment
}
fn has_resistance(&mut self) -> io::Result<bool> {
Ok(self.status.resistance.is_some())
}
+78 -3
View File
@@ -113,6 +113,9 @@ pub struct ItemInfo {
pub swappable: bool,
/// `equippable.dispensable` (dispensers may put it on an entity).
pub equip_dispensable: bool,
/// `equippable.damage_on_hurt`: the item wears when its wearer is hurt
/// (false for the elytra).
pub equip_damage_on_hurt: bool,
/// `equippable.allowed_entities` resolved to entity type ids; None allows all.
pub equip_allowed: Option<HashSet<String>>,
pub equip_sound: Option<String>,
@@ -400,6 +403,36 @@ impl Items {
self.get(&stack.item).is_some_and(|i| i.fire_resistant)
}
/// `ItemStack.canBeHurtBy` for the cactus damage type: a `damage_resistant`
/// component naming `minecraft:cactus`, or a damage type tag that holds it.
/// (`#minecraft:is_fire`, as on netherite gear, does not.)
pub fn resists_cactus(&self, stack: &ItemStack) -> bool {
const CACTUS_TAGS: [&str; 5] = [
"#minecraft:bypasses_shield",
"#minecraft:no_knockback",
"#minecraft:panic_environmental_causes",
"#minecraft:panic_causes",
"#minecraft:sulfur_cube_with_block_immune_to",
];
let Some((_, v)) = stack.data().and_then(|data| {
data.other
.iter()
.find(|(key, _)| key == "minecraft:damage_resistant")
}) else {
return false;
};
match v.get("types") {
Some(horizon_world::nbt::Tag::String(id)) => id
.to_string()
.is_ok_and(|id| id == "minecraft:cactus" || CACTUS_TAGS.contains(&id.as_str())),
Some(horizon_world::nbt::Tag::List(ids)) => ids.iter().any(|t| {
matches!(t, horizon_world::nbt::Tag::String(id)
if id.to_string().is_ok_and(|id| id == "minecraft:cactus"))
}),
_ => false,
}
}
pub fn load(root: &Path, registry: &BlockRegistry, facts: &BlockFacts) -> Result<Self> {
let mut block_for_item: HashMap<&str, BlockStateId> = HashMap::new();
for (i, s) in facts.states.iter().enumerate() {
@@ -596,6 +629,9 @@ impl Items {
equip_dispensable: c["minecraft:equippable"]["dispensable"]
.as_bool()
.unwrap_or(true),
equip_damage_on_hurt: c["minecraft:equippable"]["damage_on_hurt"]
.as_bool()
.unwrap_or(true),
equip_allowed: match &c["minecraft:equippable"]["allowed_entities"] {
Value::String(spec) => Some(entity_spec(&entity_tags, spec)),
Value::Array(list) => Some(
@@ -834,6 +870,7 @@ impl Items {
held: None,
killed_by_player: false,
explosion_radius: None,
this_entity: false,
};
ctx.evaluate(table, random)
}
@@ -898,10 +935,26 @@ impl Items {
random: &mut impl RandomSource,
sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>,
) -> Vec<ItemStack> {
self.block_drops_exploded(registry, state, held, dynamic, random, sequences, None)
self.block_drops_exploded(
registry, state, held, dynamic, random, sequences, None, false,
)
}
/// `block_drops_with` for a block a player breaks (`Block.playerDestroy`):
/// the player is the loot context's `this` entity, so snow and chorus
/// flower tables see an entity.
pub fn block_drops_by_player(
&self,
registry: &BlockRegistry,
state: BlockStateId,
held: Option<&ItemStack>,
dynamic: &[ItemStack],
random: &mut impl RandomSource,
) -> Vec<ItemStack> {
self.block_drops_exploded(registry, state, held, dynamic, random, None, None, true)
}
/// `block_drops_full` for a block an explosion destroys: `explosion`
/// is its radius when the explosion decays drops (`DESTROY_WITH_DECAY`).
/// is its radius when the explosion decays drops (`DESTROY_WITH_DECAY`);
/// `breaker` is whether an entity (the player) broke the block.
#[allow(clippy::too_many_arguments)]
pub fn block_drops_exploded(
&self,
@@ -912,8 +965,10 @@ impl Items {
random: &mut impl RandomSource,
sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>,
explosion: Option<f32>,
breaker: bool,
) -> Vec<ItemStack> {
let drops = self.block_drops_raw(registry, state, held, random, sequences, explosion);
let drops =
self.block_drops_raw(registry, state, held, random, sequences, explosion, breaker);
if !drops.iter().any(|d| d.item.starts_with(DYNAMIC)) {
return drops;
}
@@ -928,6 +983,7 @@ impl Items {
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn block_drops_raw(
&self,
registry: &BlockRegistry,
@@ -936,6 +992,7 @@ impl Items {
random: &mut impl RandomSource,
sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>,
explosion_radius: Option<f32>,
breaker: bool,
) -> Vec<ItemStack> {
let Some(block) = registry.state(state) else {
return vec![];
@@ -969,6 +1026,7 @@ impl Items {
held,
killed_by_player: false,
explosion_radius,
this_entity: breaker,
};
return ctx.evaluate(table, random);
}
@@ -979,6 +1037,7 @@ impl Items {
held,
killed_by_player: false,
explosion_radius,
this_entity: breaker,
};
ctx.evaluate(table, random)
}
@@ -1002,6 +1061,7 @@ impl Items {
held,
killed_by_player,
explosion_radius: None,
this_entity: false,
};
ctx.evaluate(table, random)
}
@@ -1034,6 +1094,7 @@ impl Items {
held: None,
killed_by_player: false,
explosion_radius: None,
this_entity: false,
};
match table.get("random_sequence").and_then(Value::as_str) {
Some(sequence) => ctx.evaluate(table, sequences.get(sequence, seed)),
@@ -1059,6 +1120,7 @@ impl Items {
held,
killed_by_player,
explosion_radius: None,
this_entity: false,
};
ctx.evaluate(table, random)
}
@@ -1081,6 +1143,7 @@ impl Items {
held,
killed_by_player,
explosion_radius: None,
this_entity: false,
};
ctx.evaluate(table, random)
}
@@ -1122,6 +1185,8 @@ struct LootContext<'a> {
killed_by_player: bool,
/// `LootContextParams.EXPLOSION_RADIUS`: an explosion that decays drops.
explosion_radius: Option<f32>,
/// `LootContextParams.THIS_ENTITY` is set (a player broke the block).
this_entity: bool,
}
impl LootContext<'_> {
/// Level of an enchantment on the tool / killer's weapon.
@@ -1282,6 +1347,15 @@ impl LootContext<'_> {
};
random.next_float() < chance
}
// `this` with no checks (snow, chorus flower) holds when an entity
// broke the block (LootItemEntityPropertyCondition needs the entity).
"minecraft:entity_properties"
if self.this_entity
&& c["entity"].as_str() == Some("this")
&& c["predicate"].as_object().is_none_or(|p| p.is_empty()) =>
{
true
}
// Entity flags, equipment and damage-source predicates: none hold
// for an unenchanted player kill without fire or lightning.
"minecraft:entity_properties" | "minecraft:damage_source_properties" => false,
@@ -1667,6 +1741,7 @@ mod tests {
held,
killed_by_player: block.is_empty(),
explosion_radius: None,
this_entity: false,
}
}
+1 -35
View File
@@ -2,7 +2,6 @@
//! defaults, `key_<name>:key.keyboard.x` persistence, display names, and the
//! translation of rebound physical keys back to the default keys the game
//! logic listens for.
use crate::text::Text;
use std::collections::BTreeMap;
use winit::{event::MouseButton, keyboard::KeyCode};
@@ -225,40 +224,7 @@ pub fn mouse_button(name: &str) -> Option<MouseButton> {
_ => return None,
})
}
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
&& !crate::lang::has(key)
{
return Text::translate("key.mouse", &[Text::literal(n)]);
}
Text::translate(key, &[])
}
pub use horizon_client_core::key_display::display_name;
/// Current bindings by mapping name.
#[derive(Clone, Debug, PartialEq, Eq)]
+1
View File
@@ -66,6 +66,7 @@ pub fn block_entity_type(name: &str) -> Option<&'static str> {
"sculk_catalyst" => "minecraft:sculk_catalyst",
"sculk_shrieker" => "minecraft:sculk_shrieker",
"crafter" => "minecraft:crafter",
"potent_sulfur" => "minecraft:potent_sulfur",
"chiseled_bookshelf" => "minecraft:chiseled_bookshelf",
"trial_spawner" => "minecraft:trial_spawner",
"vault" => "minecraft:vault",
+53 -10
View File
@@ -86,6 +86,18 @@ impl CommandTree {
/// The ClientboundCommandsPacket for a player with `level` permissions.
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
self.packet_with(level, dedicated, builtin, &Value::Null)
}
/// [`CommandTree::packet`] with more commands after vanilla's: `extra`
/// is a root in the report's format (server mods' commands).
pub fn packet_with(
&self,
level: u8,
dedicated: bool,
builtin: &Builtin,
extra: &Value,
) -> Commands {
let mut nodes = vec![Node {
name: String::new(),
kind: 0,
@@ -99,16 +111,18 @@ impl CommandTree {
let suggestions: HashMap<&str, &'static str> = SUGGESTIONS.iter().copied().collect();
let mut paths: HashMap<Vec<String>, usize> = HashMap::new();
paths.insert(Vec::new(), 0);
fill(
&self.root,
0,
&mut Vec::new(),
level,
dedicated,
&suggestions,
&mut nodes,
&mut paths,
);
for root in [&self.root, extra] {
fill(
root,
0,
&mut Vec::new(),
level,
dedicated,
&suggestions,
&mut nodes,
&mut paths,
);
}
// enumerateNodes: breadth first from the root, each node's children
// (in registration order) then its redirect target.
let mut ids = vec![None; nodes.len()];
@@ -365,4 +379,33 @@ mod tests {
);
assert!(op.nodes[3].restricted);
}
#[test]
fn server_mods_commands_follow_vanillas() {
let report: Value = serde_json::from_str(
r#"{"type":"root","children":{"me":{"type":"literal","children":{"action":{"type":"argument","parser":"minecraft:message","executable":true}}}}}"#,
)
.unwrap();
let mods: Value = serde_json::from_str(
r#"{"type":"root","children":{"beacon":{"type":"literal","executable":true,"children":{
"height":{"type":"argument","parser":"brigadier:integer","properties":{"min":1,"max":64},"executable":true}}}}}"#,
)
.unwrap();
let tree = CommandTree { root: report };
let packet = tree.packet_with(0, true, &Builtin::default(), &mods);
let names: Vec<_> = packet.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["", "me", "beacon", "action", "height"]);
let beacon = &packet.nodes[2];
assert!(beacon.executable && !beacon.restricted);
assert_eq!(beacon.children, [4]);
let height = packet.nodes[4].parser.as_ref().unwrap();
assert_eq!(height.1, vec![3, 0, 0, 0, 1, 0, 0, 0, 64]);
// No mods: vanilla's tree as it is.
assert_eq!(
tree.packet_with(0, true, &Builtin::default(), &Value::Null)
.nodes
.len(),
3
);
}
}
+28 -1
View File
@@ -177,6 +177,9 @@ pub struct Hub {
entities: std::collections::HashMap<String, Entities>,
/// What one tick of entity tracking sends each guest, kept between ticks.
entity_out: Vec<Vec<Vec<u8>>>,
/// Server mods' commands, a root in the commands report's format, sent
/// after vanilla's; the key the game last set them for.
mod_commands: (serde_json::Value, Option<u64>),
}
fn string(s: &str) -> Tag {
@@ -341,6 +344,7 @@ impl Hub {
entity_types: Default::default(),
entities: Default::default(),
entity_out: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
teams: Vec::new(),
}
}
@@ -849,13 +853,36 @@ impl Hub {
}
}
/// The key [`Hub::set_mod_commands`] last had: the game compares it
/// with its server mods' each tick.
pub fn mod_commands_key(&self) -> Option<u64> {
self.mod_commands.1
}
/// Server mods' commands changed: every player gets their tree again.
pub fn set_mod_commands(&mut self, key: u64, commands: serde_json::Value) {
let resend = self.mod_commands.0 != commands;
self.mod_commands = (commands, Some(key));
if resend {
for i in 0..self.guests.len() {
if self.guests[i].placed {
self.send_commands(i);
}
}
}
}
/// Commands.sendCommands: the command tree for this player.
fn send_commands(&self, i: usize) {
let g = &self.guests[i];
let packet = match &self.sync.commands {
Some(tree) => {
let dedicated = self.config.dedicated && !self.config.integrated;
tree.packet(g.permission, dedicated, &self.sync.builtin)
tree.packet_with(
g.permission,
dedicated,
&self.sync.builtin,
&self.mod_commands.0,
)
}
None => play::Commands {
nodes: vec![play::CommandNode {
+12 -12
View File
@@ -19,13 +19,11 @@ pub mod biome;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_item;
pub mod block_entity_nbt;
pub mod block_entity_render;
pub mod block_transform;
pub mod blocks;
pub mod border_render;
pub mod boss_overlay;
pub mod bossbar;
pub mod breaking;
pub mod brewing;
pub mod bundle;
@@ -38,10 +36,8 @@ pub mod clipboard;
pub mod clock;
pub mod clouds;
pub mod collisions;
pub mod command;
pub mod command_block;
pub mod command_storage;
pub mod component;
pub mod confirm_screen;
pub mod container_screen;
pub mod containers;
@@ -71,7 +67,6 @@ pub mod extras;
pub mod extras_hud;
pub mod falling_block;
pub mod fast_hash;
pub mod fastutil_order;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
@@ -80,7 +75,6 @@ pub mod font;
pub mod friends_screen;
pub mod game_mode_switcher;
pub mod gamepad;
pub mod gamerules;
pub mod gfx;
pub mod gizmos;
pub mod gossip;
@@ -109,7 +103,6 @@ pub mod keybinds;
pub mod keymapping;
pub mod lan;
pub mod landing;
pub mod lang;
pub mod level_loading;
pub mod light;
pub mod locator_bar;
@@ -131,7 +124,6 @@ pub mod mouse;
pub mod moved_extras;
pub mod multiplayer;
pub mod music;
pub mod nbt_path;
pub mod net;
pub mod nether_portal;
pub mod offline_name;
@@ -160,7 +152,6 @@ pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
pub mod save;
pub mod scoreboard;
pub mod scoreboard_cmd;
pub mod screen_draw;
pub mod screen_effects;
@@ -173,7 +164,6 @@ pub mod shader_pack_list;
pub mod shadows;
pub mod signs;
pub mod skins;
pub mod snbt;
pub mod sound_engine;
pub mod sound_events;
pub mod spans;
@@ -186,7 +176,6 @@ pub mod structure_blocks;
pub mod tab_list;
pub mod telemetry;
pub mod testgen;
pub mod text;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
@@ -206,4 +195,15 @@ pub mod world;
pub mod world_spawn;
pub mod worldborder;
pub mod zip;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_command as command;
+85 -3
View File
@@ -25,6 +25,7 @@ pub mod creaking;
pub mod creaking_heart;
pub mod cube;
pub mod data;
pub mod doors;
pub mod drops;
pub mod enderman;
pub mod equine;
@@ -68,6 +69,7 @@ pub mod tame;
pub mod trader;
pub mod transport_items;
pub mod trial_spawner;
pub mod turtle;
pub mod undead;
pub mod vex;
pub mod villager;
@@ -515,6 +517,8 @@ pub enum DamageKind {
InFire,
Lava,
Drown,
/// `dry_out`: an axolotl out of water for too long.
DryOut,
Magic,
Sting,
Cactus,
@@ -573,7 +577,11 @@ impl Damage {
pub fn causes_panic(&self) -> bool {
!matches!(
self.kind,
DamageKind::Fall | DamageKind::Drown | DamageKind::Generic | DamageKind::InWall
DamageKind::Fall
| DamageKind::Drown
| DamageKind::DryOut
| DamageKind::Generic
| DamageKind::InWall
)
}
/// DamageTypeTags.BYPASSES_ARMOR
@@ -606,6 +614,7 @@ impl Damage {
| DamageKind::InFire
| DamageKind::Lava
| DamageKind::Drown
| DamageKind::DryOut
| DamageKind::Magic
| DamageKind::Cactus
| DamageKind::Generic
@@ -636,6 +645,8 @@ pub struct Attrs {
pub struct Extra {
/// Players: crouching and riding (HumanoidModel's poses).
pub crouching: bool,
/// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise.
pub sprinting: bool,
pub passenger: bool,
/// AgeableMob.age: negative while a baby (a tadpole counts up to 24000).
pub age: i32,
@@ -665,6 +676,8 @@ pub struct Extra {
pub chested: bool,
/// The chest contents of a donkey or mule (15 slots).
pub items: Vec<Option<crate::items::ItemStack>>,
/// `Piglin.inventory` (`SimpleContainer(8)`): what it picked up and kept.
pub inventory: Vec<crate::items::ItemStack>,
pub tame: bool,
pub sitting: bool,
/// The owner's UUID (tamed animals).
@@ -979,6 +992,14 @@ pub enum MobEvent {
owner: u32,
},
Shoot([f64; 3], [f64; 3]),
/// The mob the `Explode` right after this comes from
/// (`ServerExplosion.source`), and whether it is a charged creeper: the
/// exploding creeper is already out of the mob list when the game applies
/// the blast, so its victims' loot cannot look it up.
ExplosionSource {
mob: u32,
charged: bool,
},
Explode([f64; 3], f32),
/// Melee or ranged damage to another mob.
AttackMob {
@@ -1015,6 +1036,24 @@ pub enum MobEvent {
pos: [i32; 3],
open: bool,
},
/// `BreakDoorGoal.tick` at its end: the door is removed (`removeBlock`),
/// then level events 1021 and 2001 with the state that is left there.
BreakDoor {
pos: [i32; 3],
},
/// `Level.destroyBlockProgress` for a mob's crack on a block (`-1`
/// clears it).
DestroyProgress {
mob: u32,
pos: [i32; 3],
progress: i32,
},
/// `Level.levelEvent`: the game plays and sends the event.
LevelEvent {
id: i32,
pos: [i32; 3],
data: i32,
},
/// Destroy a block, dropping nothing (sheep eating grass becomes dirt via SetBlock).
BreakBlock {
pos: [i32; 3],
@@ -1147,8 +1186,10 @@ pub enum MobEvent {
target: Who,
aggressive: bool,
},
/// Drop the loot of a gift table (chicken eggs, armadillo scutes).
/// Drop the loot of a gift table (chicken eggs, sniffer seeds) with the
/// mob as `this` (`LivingEntity.dropFromGiftLootTable`).
GiftLoot {
mob: u32,
table: &'static str,
at: [f64; 3],
},
@@ -1172,6 +1213,18 @@ pub enum MobEvent {
DigParticles {
at: [f64; 3],
},
/// `Zombie.hurtServer`: a zombie hurt with a target in sight (its own,
/// else the attacker) may call a reinforcement against it on Hard.
Reinforcements {
mob: u32,
target: Who,
},
/// `LevelEvent.PARTICLES_DESTROY_BLOCK` (2001) at `pos` for the block at
/// `block`: a turtle's digging.
BlockBreakEffect {
pos: [i32; 3],
block: [i32; 3],
},
/// `Warden.applyDarknessAround`: darkness for survival players nearby.
Darkness {
at: [f64; 3],
@@ -1268,6 +1321,14 @@ pub struct Seen {
pub sitting: bool,
/// `Entity.isVehicle` or `isPassenger`.
pub ridden: bool,
/// The active effects, without namespace (`slowness`, `poison`...).
pub effects: Vec<&'static str>,
}
impl Seen {
/// `LivingEntity.hasEffect`, by id without namespace.
pub fn has_effect(&self, effect: &str) -> bool {
self.effects.contains(&effect)
}
}
impl Mob {
pub fn burning_time(&self) -> f64 {
@@ -1342,6 +1403,12 @@ impl Mob {
],
sitting: m.extra.sitting,
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
effects: m
.extra
.effects
.iter()
.map(|e| e.0.trim_start_matches("minecraft:"))
.collect(),
}
}
/// `EntityType.getCategory`'s name.
@@ -1486,6 +1553,8 @@ pub struct PlayerView {
pub yaw: f32,
/// The Hero of the Village effect.
pub hero: bool,
/// The active effects, without namespace (`slowness`, `poison`...).
pub effects: Vec<&'static str>,
/// The player's UUID (villagers' gossip is keyed by it).
pub uuid: [i32; 4],
/// The mobs on the player's team (`isAlliedTo`): combat targeting
@@ -1495,6 +1564,12 @@ pub struct PlayerView {
/// (`EntitySelector.pushableBy` on the client).
pub remote: bool,
}
impl PlayerView {
/// `LivingEntity.hasEffect`, by id without namespace.
pub fn has_effect(&self, effect: &str) -> bool {
self.effects.contains(&effect)
}
}
#[derive(Clone)]
pub struct MobWorld<'a> {
pub world: &'a World,
@@ -1820,6 +1895,12 @@ impl Mob {
}
}
}
// Squid.hurtServer: a squid hurt after a living entity hit it squirts ink.
if matches!(self.species(), Species::Squid | Species::GlowSquid)
&& self.ai.last_hurt_by.is_some()
{
aquatic::spawn_ink(self);
}
true
}
/// LivingEntity.knockback(power, xd, zd) towards the negative direction.
@@ -1870,7 +1951,8 @@ impl Mob {
/// Movement speed attribute bonus (baby zombies, sprinting modifiers).
/// MOVEMENT_SPEED including effects (speed +20%/level, slowness -15%/level).
pub fn movement_speed(&self) -> f64 {
let mut v = strider::movement_speed(self, self.attrs.speed) * self.speed_bonus();
let mut v = strider::movement_speed(self, witch::speed(self, self.attrs.speed))
* self.speed_bonus();
for (e, _, amp) in &self.extra.effects {
match *e {
"minecraft:speed" => v *= 1.0 + 0.2 * f64::from(amp + 1),
+57 -4
View File
@@ -183,6 +183,9 @@ pub fn register(m: &mut Mob) {
if m.species() == Species::Rabbit {
super::rabbit::register(m);
}
if m.species() == Species::Turtle {
super::turtle::register(m);
}
}
Family::Inert => {}
Family::Witch => super::witch::register(m),
@@ -441,6 +444,34 @@ pub fn tick_swing(m: &mut Mob) {
}
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
}
/// `LivingEntity.baseTick` refills a breather's air by 4, then
/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills
/// it, anywhere else it runs down from the pre-tick value, and at -20 the
/// axolotl dries out.
fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
let before = m.air;
m.air = (m.air + 4).min(super::axolotl::MAX_AIR);
if m.no_ai {
return;
}
// Entity.isInRain: rain at the block, or at the top of the box.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let top = (m.position.y + f64::from(m.height())).floor() as i32;
let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z);
if m.fluids.in_water || in_rain {
m.air = super::axolotl::MAX_AIR;
} else {
m.air = before - 1;
if m.air <= -20 {
m.air = 0;
m.damage(2.0, Damage::of(DamageKind::DryOut));
}
}
}
/// Entity.baseTick + LivingEntity.baseTick + Mob.baseTick.
fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.was_eye_in_water = m.fluids.eye_in_water;
@@ -500,7 +531,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.damage(1.0, Damage::of(DamageKind::InWall));
}
// Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs).
if m.kind.flags.needs_water {
if m.species() == Species::Axolotl {
axolotl_air_supply(m, w);
} else if m.kind.flags.needs_water {
if !m.fluids.in_water {
m.air -= 1;
if m.air == -20 {
@@ -712,6 +745,14 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
// setAge at the age boundary: refreshDimensions.
if m.extra.age >= 0 {
m.fudge_position_after_size_change(w, bw, bh);
// Turtle.ageBoundaryReached: a grown turtle drops a scute.
if m.species() == Species::Turtle {
events.push(MobEvent::GiftLoot {
mob: m.id,
table: "minecraft:gameplay/turtle_grow",
at: [m.position.x, m.position.y, m.position.z],
});
}
}
} else if m.extra.age > 0 {
m.extra.age -= 1;
@@ -746,7 +787,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() {
m.ignite_for_ticks(160);
}
species_ai_step(m, w, events);
species_ai_step(m, w, level_random, events);
}
impl Mob {
/// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`):
@@ -944,6 +985,7 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
| Species::Parched
| Species::WitherSkeleton
| Species::ZombifiedPiglin => undead::custom_server_ai_step(m, cx),
Species::Vindicator => super::illager::vindicator_server_ai_step(m),
Species::Enderman => super::enderman::custom_server_ai_step(m, cx),
Species::Blaze => super::nether::blaze_server_ai_step(m, cx),
Species::ElderGuardian => super::guardian::elder_server_ai_step(m, cx),
@@ -955,7 +997,12 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
_ => {}
}
}
fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
fn species_ai_step(
m: &mut Mob,
w: &MobWorld,
level_random: &mut LegacyRandom,
events: &mut Vec<MobEvent>,
) {
if family(m.species()) == Family::Aquatic {
aquatic::ai_step(m, w, events);
}
@@ -988,7 +1035,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
Species::Shulker => {
super::shulker::tick(m, w, events);
super::shulker::ai_step(m, w, events);
super::shulker::ai_step(m, w, level_random, events);
}
Species::SnowGolem => super::golem::snow_ai_step(m, w, events),
Species::CopperGolem => super::copper_golem::ai_step(m, w, events),
@@ -999,6 +1046,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events),
Species::Chicken => farm::chicken_ai_step(m, w, events),
Species::Turtle => super::turtle::ai_step(m, w, events),
Species::Sheep if m.extra.anim_ticks > 0 => {
m.extra.anim_ticks -= 1;
}
@@ -1324,6 +1372,11 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) {
Species::Warden => super::warden::hurt(m, source),
Species::Wolf => super::tame::after_hurt(m, source),
Species::Nautilus | Species::ZombieNautilus => super::nautilus::after_hurt(m, source),
Species::Zombie
| Species::Husk
| Species::Drowned
| Species::ZombieVillager
| Species::ZombifiedPiglin => super::undead::after_hurt(m, source),
// NeutralMob: an iron golem hit by the player stays angry a while.
// PiglinAi.wasHurtBy: drop what is being admired, no admiring for
// a while after a player's hit, and anger.
+27 -12
View File
@@ -248,18 +248,24 @@ pub enum ArmPose {
pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) {
humanoid(p, s, &Hum::new(ArmPose::Empty, ArmPose::Empty));
}
/// `AnimationUtils.swingWeaponDown` with the right arm as the main arm.
pub(super) fn swing_weapon_down(p: &mut Poses, swing: f32, age: f32) {
/// `AnimationUtils.swingWeaponDown`: the main arm swings down, the other
/// one hangs and bobs.
pub(super) fn swing_weapon_down(p: &mut Poses, swing: f32, age: f32, left_main: bool) {
let attack2 = (swing * PI).msin();
let attack = ((1.0 - (1.0 - swing) * (1.0 - swing)) * PI).msin();
let (main, off) = if left_main {
("left_arm", "right_arm")
} else {
("right_arm", "left_arm")
};
p.zrot("right_arm", 0.0);
p.zrot("left_arm", 0.0);
p.yrot("right_arm", PI / 20.0);
p.yrot("left_arm", -PI / 20.0);
p.xrot("right_arm", -1.884_955_8 + (age * 0.09).mcos() * 0.15);
p.xrot("left_arm", -0.0 + (age * 0.19).mcos() * 0.5);
p.get("right_arm")[3] += attack2 * 2.2 - attack * 0.4;
p.get("left_arm")[3] += attack2 * 1.2 - attack * 0.4;
p.xrot(main, -1.884_955_8 + (age * 0.09).mcos() * 0.15);
p.xrot(off, -0.0 + (age * 0.19).mcos() * 0.5);
p.get(main)[3] += attack2 * 2.2 - attack * 0.4;
p.get(off)[3] += attack2 * 1.2 - attack * 0.4;
bob(p, "right_arm", age, 1.0);
bob(p, "left_arm", age, -1.0);
}
@@ -286,6 +292,8 @@ pub struct Hum {
pub using_item: bool,
/// The arm the current swing moves (`Some(true)` for the right one).
pub swing_right_arm: Option<bool>,
/// `mainArm` is the left one (`Mob.isLeftHanded`): a mob's swing uses it.
pub left_main: bool,
}
impl Hum {
pub fn new(right: ArmPose, left: ArmPose) -> Self {
@@ -303,6 +311,7 @@ impl Hum {
swim: 0.0,
using_item: false,
swing_right_arm: None,
left_main: false,
}
}
}
@@ -315,7 +324,7 @@ impl ArmPose {
}
}
/// `AnimationUtils.animateCrossbowHold`.
fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
pub(super) fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
let (hold, shoot) = if in_right {
("right_arm", "left_arm")
} else {
@@ -327,7 +336,7 @@ fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
p.xrot(shoot, -1.5 + hx);
}
/// `AnimationUtils.animateCrossbowCharge`.
fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: bool) {
pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: bool) {
let (hold, pull) = if in_right {
("right_arm", "left_arm")
} else {
@@ -418,13 +427,17 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
pose_arm(p, h.right, true, h);
}
}
// setupAttackAnimation (WHACK swing with the right arm).
// setupAttackAnimation (WHACK swing with the arm of the swinging hand).
let hx = p.get("head")[3];
let swing = s.swing;
let swing_left = h.swing_right_arm.map_or(h.left_main, |right| !right);
if swing > 0.0 && h.weapon_swing {
swing_weapon_down(p, swing, s.age);
swing_weapon_down(p, swing, s.age, h.left_main);
} else if swing > 0.0 {
let body_y = (swing.sqrt() * PI * 2.0).msin() * 0.2;
let mut body_y = (swing.sqrt() * PI * 2.0).msin() * 0.2;
if swing_left {
body_y = -body_y;
}
p.yrot("body", body_y);
let age_scale = if s.baby { 0.5 } else { 1.0 };
{
@@ -444,7 +457,7 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
let eased = 1.0 - (1.0 - swing).powi(4);
let aa = (eased * PI).msin();
let bb = (swing * PI).msin() * -(hx - 0.7) * 0.75;
let r = p.get("right_arm");
let r = p.get(if swing_left { "left_arm" } else { "right_arm" });
r[3] -= aa * 1.2 + bb;
r[4] += body_y * 2.0;
r[5] += (swing * PI).msin() * -0.4;
@@ -652,6 +665,7 @@ pub fn setup(
} else {
Hum::new(main, ArmPose::Empty)
};
h.left_main = m.main_arm_left();
h.zombie = Some(m.ai.aggressive);
h.holding = m.extra.main_hand.is_some();
h.crouching = m.extra.crouching;
@@ -715,6 +729,7 @@ pub fn setup(
h.swim = m.extra.swim.amount(s.partial);
h.using_item = m.extra.using_item > 0;
h.swing_right_arm = m.extra.swing_right_arm;
h.left_main = m.main_arm_left();
humanoid(p, s, &h);
if m.ai.aggressive && !bow {
// SkeletonModel melee pose.
+53
View File
@@ -24,6 +24,58 @@ const Z_BODY_ROT: usize = 3;
const TENTACLE_SPEED: usize = 4;
const ROTATE_SPEED: usize = 5;
/// `Squid.spawnInk`: the squirt sound and 30 ink particles shot from under
/// the squid, turned by its body rotation.
pub fn spawn_ink(m: &mut Mob) {
let sound = if m.species() == Species::GlowSquid {
"entity.glow_squid.squirt"
} else {
"entity.squid.squirt"
};
let kind = if m.species() == Species::GlowSquid {
"glow_squid_ink"
} else {
"squid_ink"
};
// makeSound draws the voice pitch even for a silent squid.
let pitch = m.voice_pitch();
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
m.outbox.push(MobEvent::Sound(
sound,
at,
super::ai::sound_volume(m),
pitch,
));
}
// rotateVector: xRot(xBodyRotO), then yRot(-yBodyRotO).
let to_radians = (std::f64::consts::PI / 180.0) as f32;
let (x_rot, y_rot) = (
m.anim.vo[X_BODY_ROT] * to_radians,
-m.previous_body_yaw * to_radians,
);
let rotate = |v: Vec3| v.rotate_x(x_rot).rotate_y(y_rot);
let pos = rotate(Vec3::new(0.0, -1.0, 0.0)).add_vector(m.position);
let offset_scale: f32 = if m.is_baby() { 0.1 } else { 0.3 };
for _ in 0..30 {
let x = f64::from(m.random.next_float()) * 0.6 - 0.3;
let z = f64::from(m.random.next_float()) * 0.6 - 0.3;
let dir = rotate(Vec3::new(x, -1.0, z));
let d = dir.scale(f64::from(offset_scale + m.random.next_float() * 2.0));
// ServerLevel.sendParticles with a count of 0 and speed 0.1: one
// particle moving at the float offset times the float speed.
let speed = f64::from(0.1f32);
m.outbox.push(MobEvent::ParticleAt {
kind,
at: [pos.x, pos.y + 0.5, pos.z],
velocity: [
speed * f64::from(d.x as f32),
speed * f64::from(d.y as f32),
speed * f64::from(d.z as f32),
],
});
}
}
pub fn register(m: &mut Mob) {
use Species as S;
match m.species() {
@@ -36,6 +88,7 @@ pub fn register(m: &mut Mob) {
m.anim.v[TENTACLE_SPEED] = speed;
}
S::Axolotl => {
m.air = super::axolotl::MAX_AIR;
// Native controller scaling is essential: the base speed attribute is 1.0.
m.ai.mov.kind = MoveKind::SmoothSwimming {
max_turn_x: 85.0,
@@ -3,6 +3,8 @@ use super::{Mob, MobEvent, MobWorld, Species, Who, animal_brain, keyframes::Anim
use horizon_core::random::RandomSource;
pub const PLAY_DEAD_TICKS: i32 = 200;
pub const HUNTING_COOLDOWN: i64 = 2400;
/// `Axolotl.getMaxAirSupply`.
pub const MAX_AIR: i32 = 6000;
/// AxolotlAttackablesSensor: always-hostile targets bypass the hunting cooldown.
pub fn prey_eligible(
+2 -2
View File
@@ -969,8 +969,8 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
s,
&Turtle {
on_land: !m.fluids.in_water && m.on_ground,
laying_egg: false,
has_egg: false,
laying_egg: m.extra.flag2,
has_egg: m.extra.flag,
},
),
Species::Strider => strider(p, s, m.rider.is_some()),
+252
View File
@@ -0,0 +1,252 @@
//! Doors a mob gets through on its own: `DoorInteractGoal` with its two
//! users, `BreakDoorGoal` (zombies that can break doors, vindicators in a
//! raid) and `OpenDoorGoal` (`AbstractIllager.RaiderOpenDoorGoal`).
use super::{
Mob, MobEvent, MobWorld, Species,
goals::{Cx, Goal, MOVE, goal_clone, reduced},
nav::NavKind,
raider,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
/// `DefaultDoorBreakTime`: `BreakDoorGoal.getDoorBreakTime` is
/// `max(240, doorBreakTime)`, and the vindicator's 6 is below it.
const DOOR_BREAK_TIME: i32 = 240;
/// `GoalUtils.hasGroundPathNavigation`: ground, wall-climbing and
/// amphibious navigations walk.
pub fn can_navigate_ground(m: &Mob) -> bool {
matches!(
m.ai.nav.kind,
NavKind::Ground | NavKind::WallClimber | NavKind::Amphibious
)
}
/// `Zombie.setCanBreakDoors`: only a mob that walks can break doors, and
/// then its navigation opens them.
pub fn set_can_break_doors(m: &mut Mob, can_break: bool) {
let can_break = can_break && can_navigate_ground(m);
m.extra.can_break_doors = can_break;
m.ai.nav.can_open_doors = can_break;
}
/// The species that extend `Zombie` (`CanBreakDoors` is theirs to save).
pub fn is_zombie(s: Species) -> bool {
matches!(
s,
Species::Zombie
| Species::Husk
| Species::Drowned
| Species::ZombieVillager
| Species::ZombifiedPiglin
)
}
/// `DoorBlock.isWoodenDoor`: a door a hand opens, which is any but iron.
fn wooden_door(w: &MobWorld, p: [i32; 3]) -> bool {
let name = w.block_name(p[0], p[1], p[2]);
name.ends_with("_door") && name != "minecraft:iron_door"
}
/// `DoorInteractGoal`'s door: where it is and whether one is there.
#[derive(Clone, Debug, Default)]
struct Interact {
door: [i32; 3],
has_door: bool,
}
impl Interact {
/// `DoorInteractGoal.canUse`: the mob bumped into something and a
/// wooden door is on its path within 1.5 blocks, or at its own head.
fn can_use(&mut self, m: &Mob, w: &MobWorld) -> bool {
if !can_navigate_ground(m) || !m.horizontal_collision {
return false;
}
let Some(path) = m.ai.nav.path.as_ref().filter(|p| !p.is_done()) else {
return false;
};
for node in path
.nodes
.iter()
.take((path.next + 2).min(path.nodes.len()))
{
self.door = [node.x, node.y + 1, node.z];
let dx = m.position.x - f64::from(node.x);
let dz = m.position.z - f64::from(node.z);
if dx * dx + dz * dz <= 2.25 {
self.has_door = wooden_door(w, self.door);
if self.has_door {
return true;
}
}
}
let at = m.position;
self.door = [
at.x.floor() as i32,
at.y.floor() as i32 + 1,
at.z.floor() as i32,
];
self.has_door = wooden_door(w, self.door);
self.has_door
}
/// `DoorInteractGoal.isOpen`: the door is still there and open.
fn is_open(&mut self, w: &MobWorld) -> bool {
if !self.has_door {
return false;
}
let d = self.door;
if !w.block_name(d[0], d[1], d[2]).ends_with("_door") {
self.has_door = false;
return false;
}
w.prop(d[0], d[1], d[2], "open") == Some("true")
}
/// `DoorInteractGoal.setOpen`.
fn set_open(&self, cx: &mut Cx, open: bool) {
let d = self.door;
if self.has_door && cx.w.block_name(d[0], d[1], d[2]).ends_with("_door") {
cx.events.push(MobEvent::SetDoorOpen { pos: d, open });
}
}
}
/// `BlockPos.closerToCenterThan`.
fn closer_to_center(p: [i32; 3], at: Vec3, distance: f64) -> bool {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]) + 0.5,
f64::from(p[2]) + 0.5,
)
.distance_squared(at)
< distance * distance
}
/// `BreakDoorGoal`: crack the door in the way for 240 ticks, then remove it.
/// A zombie does it on hard only; `Vindicator.VindicatorBreakDoorGoal` on
/// normal and hard, in a raid, and holds the mob's movement.
#[derive(Clone, Debug)]
pub struct BreakDoor {
interact: Interact,
vindicator: bool,
break_time: i32,
last_break_progress: i32,
}
impl BreakDoor {
/// `Zombie.breakDoorGoal`, added when the zombie can break doors.
pub fn zombie() -> Self {
Self::new(false)
}
/// `Vindicator.VindicatorBreakDoorGoal`.
pub fn vindicator() -> Self {
Self::new(true)
}
fn new(vindicator: bool) -> Self {
Self {
interact: Interact::default(),
vindicator,
break_time: 0,
last_break_progress: -1,
}
}
/// `DOOR_BREAKING_PREDICATE`.
fn valid_difficulty(&self, w: &MobWorld) -> bool {
if self.vindicator {
w.difficulty.normal || w.difficulty.hard
} else {
w.difficulty.hard
}
}
/// `BreakDoorGoal.canUse` after `DoorInteractGoal.canUse`.
fn can_break(&mut self, m: &Mob, w: &MobWorld) -> bool {
self.interact.can_use(m, w)
&& w.mob_griefing
&& self.valid_difficulty(w)
&& !self.interact.is_open(w)
}
}
impl Goal for BreakDoor {
goal_clone!();
fn flags(&self) -> u8 {
if self.vindicator { MOVE } else { 0 }
}
fn every_tick(&self) -> bool {
true
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if self.vindicator {
return raider::has_active_raid(m)
&& m.random.next_int_bound(reduced(10)).unwrap_or(1) == 0
&& self.can_break(m, cx.w);
}
m.extra.can_break_doors && self.can_break(m, cx.w)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
(!self.vindicator || raider::has_active_raid(m))
&& self.break_time <= DOOR_BREAK_TIME
&& !self.interact.is_open(cx.w)
&& closer_to_center(self.interact.door, m.position, 2.0)
&& self.valid_difficulty(cx.w)
}
fn start(&mut self, m: &mut Mob, _cx: &mut Cx) {
self.break_time = 0;
if self.vindicator {
m.ai.no_action_time = 0;
}
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
cx.events.push(MobEvent::DestroyProgress {
mob: m.id,
pos: self.interact.door,
progress: -1,
});
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
let door = self.interact.door;
if m.random.next_int_bound(20).unwrap_or(1) == 0 {
cx.events.push(MobEvent::LevelEvent {
id: 1019,
pos: door,
data: 0,
});
if !m.ai.swinging {
m.swing();
}
}
self.break_time += 1;
let progress = (self.break_time as f32 / DOOR_BREAK_TIME as f32 * 10.0) as i32;
if progress != self.last_break_progress {
cx.events.push(MobEvent::DestroyProgress {
mob: m.id,
pos: door,
progress,
});
self.last_break_progress = progress;
}
if self.break_time == DOOR_BREAK_TIME && self.valid_difficulty(cx.w) {
cx.events.push(MobEvent::BreakDoor { pos: door });
}
}
}
/// `AbstractIllager.RaiderOpenDoorGoal` (`OpenDoorGoal` that does not wait
/// to close): open the door in the way during a raid. It never continues, so
/// it closes the door again as soon as it stops.
#[derive(Clone, Debug, Default)]
pub struct RaiderOpenDoor {
interact: Interact,
}
impl Goal for RaiderOpenDoor {
goal_clone!();
fn flags(&self) -> u8 {
0
}
fn every_tick(&self) -> bool {
true
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.interact.can_use(m, cx.w) && raider::has_active_raid(m)
}
fn can_continue(&mut self, _m: &mut Mob, _cx: &mut Cx) -> bool {
false
}
fn start(&mut self, _m: &mut Mob, cx: &mut Cx) {
self.interact.set_open(cx, true);
}
fn stop(&mut self, _m: &mut Mob, cx: &mut Cx) {
self.interact.set_open(cx, false);
}
}
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more