mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Merge branch 'feat/mod-recipe-actions' into feat/official-recipes-mod
# Conflicts: # Cargo.toml
This commit is contained in:
commit
92e3f496f0
470 files changed
+18193
-18600
No files matched your search
@@ -2,7 +2,6 @@
|
||||
target/
|
||||
.local/
|
||||
**/node_modules/
|
||||
website/
|
||||
benchmarks/
|
||||
packs/
|
||||
mods/
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
# where Git for Windows' core.autocrlf would check them out with CRLF.
|
||||
docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
website/src/tokens.css text eol=lf
|
||||
crates/horizon-theme/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
@@ -73,6 +73,42 @@ jobs:
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
launcher:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request change the launcher or a crate it builds?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: launcher
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust and the headers GPUI links against
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check the launcher's formatting, lint and tests
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
working-directory: launcher
|
||||
run: |
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
|
||||
@@ -48,6 +48,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
|
||||
- name: The harnesses the merges since then can affect
|
||||
id: harnesses
|
||||
# A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
|
||||
continue-on-error: true
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
@@ -55,8 +58,8 @@ jobs:
|
||||
echo "Merges since ${BASE_SHA}:"
|
||||
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
|
||||
- name: The merge that broke them
|
||||
if: failure()
|
||||
- name: A flake, or the merge that broke them
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARITY_HOSTS: ''
|
||||
@@ -65,7 +68,7 @@ jobs:
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-main-logs
|
||||
|
||||
@@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
|
||||
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
|
||||
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
|
||||
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
|
||||
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
|
||||
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
|
||||
|
||||
Generated
+16
@@ -216,6 +216,13 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "beacons"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -1368,6 +1375,7 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -1426,6 +1434,14 @@ dependencies = [
|
||||
"spake2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-hdr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/recipes", "mods/official/zoom", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask", "mods/official/recipes"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+68
-54
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
|
||||
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
|
||||
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -160,6 +160,66 @@ Translatable components resolved through the selected language for items, contai
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
@@ -250,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
|
||||
|
||||
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
|
||||
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -540,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -876,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
|
||||
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -902,56 +966,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
|
||||
|
||||
@@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
|
||||
|
||||
`crates/horizon-theme/icons/pixel/*.svg` are icons from
|
||||
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
|
||||
the same grid.
|
||||
@@ -266,6 +266,11 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::SetBlocks {
|
||||
dimension, blocks, ..
|
||||
} => {
|
||||
format!("set {} block(s) in {dimension}", blocks.len())
|
||||
}
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
|
||||
@@ -1031,19 +1031,22 @@ pub struct ItemModels {
|
||||
}
|
||||
|
||||
impl ItemModels {
|
||||
pub fn components(&self, stack: &StackView) -> Components {
|
||||
let mut out = self
|
||||
.prototypes
|
||||
.get(&stack.item)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
/// The stack's components: its item's defaults with the stack's patch on top. A stack
|
||||
/// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
|
||||
pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
|
||||
static NONE: Components = BTreeMap::new();
|
||||
let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
|
||||
if stack.patch.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(prototype);
|
||||
}
|
||||
let mut out = prototype.clone();
|
||||
for (key, value) in &stack.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
}
|
||||
out
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
|
||||
|
||||
@@ -12,6 +12,7 @@ horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
item_data::StackData,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
let items = Items::catalog(&root)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let mut stack = ItemStack::new(
|
||||
row["item"].as_str().unwrap(),
|
||||
row["count"].as_u64().unwrap() as u32,
|
||||
);
|
||||
match row["tool"].as_str().unwrap() {
|
||||
"default" => {}
|
||||
mode => {
|
||||
let mut data = StackData::default();
|
||||
let tool = if mode == "implicit" {
|
||||
Tag::compound([] as [(&str, Tag); 0])
|
||||
} else {
|
||||
Tag::compound([(
|
||||
"can_destroy_blocks_in_creative",
|
||||
Tag::Byte(i8::from(mode == "allow")),
|
||||
)])
|
||||
};
|
||||
data.set_other("minecraft:tool", Some(tool));
|
||||
stack.extra = Some(Box::new(data));
|
||||
}
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap())
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
cushion::{self, World},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
struct Cell {
|
||||
outline: VoxelShape,
|
||||
collision: VoxelShape,
|
||||
suffocating: bool,
|
||||
}
|
||||
struct Fixture(HashMap<[i32; 3], Cell>);
|
||||
impl World for Fixture {
|
||||
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
|
||||
self.0
|
||||
.get(&p)?
|
||||
.outline
|
||||
.bounds()
|
||||
.map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)))
|
||||
}
|
||||
fn collision_shapes(&self, _: Aabb) -> Vec<VoxelShape> {
|
||||
self.0
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
c.collision
|
||||
.moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn suffocating(&self, p: [i32; 3]) -> bool {
|
||||
self.0.get(&p).is_some_and(|c| c.suffocating)
|
||||
}
|
||||
}
|
||||
fn shape(value: &Value) -> Result<VoxelShape> {
|
||||
let mut shape = VoxelShape::empty();
|
||||
for b in value.as_array().unwrap() {
|
||||
let f = |i: usize| b[i].as_f64().unwrap();
|
||||
shape = shape.join(
|
||||
&VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(f(0), f(1), f(2)),
|
||||
Vec3::new(f(3), f(4), f(5)),
|
||||
))?,
|
||||
ShapeOp::OR,
|
||||
)?;
|
||||
}
|
||||
Ok(shape)
|
||||
}
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let mut world = Fixture(HashMap::new());
|
||||
for c in row["cells"].as_array().unwrap() {
|
||||
let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32);
|
||||
world.0.insert(
|
||||
p,
|
||||
Cell {
|
||||
outline: shape(&c[3])?,
|
||||
collision: shape(&c[4])?,
|
||||
suffocating: c[5].as_bool().unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let p = &row["pos"];
|
||||
let (x, y, z) = (
|
||||
p[0].as_f64().unwrap(),
|
||||
p[1].as_f64().unwrap(),
|
||||
p[2].as_f64().unwrap(),
|
||||
);
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(x - 0.5, y, z - 0.5),
|
||||
Vec3::new(x + 0.5, y + 0.25, z + 0.5),
|
||||
);
|
||||
writeln!(
|
||||
out,
|
||||
"{} {}",
|
||||
cushion::can_place(&world, bounds),
|
||||
cushion::survives(&world, bounds)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::{
|
||||
Mob, MobData, MobTypes, Part, Species, allay,
|
||||
anim::{self, Poses, State},
|
||||
aquatic, body_anim as b_anim, equine, guardian,
|
||||
aquatic, bat, body_anim as b_anim, equine, guardian,
|
||||
keyframes::AnimationState,
|
||||
render, shulker, sniffer, species, tame,
|
||||
};
|
||||
@@ -108,6 +108,22 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
|
||||
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
|
||||
match species {
|
||||
Species::Bat => {
|
||||
mob.extra.flag = flag("isResting");
|
||||
mob.anim.states[0].started = case["flyAnimationState"].as_f64().map(|v| v as f32);
|
||||
mob.anim.states[1].started = case["restAnimationState"].as_f64().map(|v| v as f32);
|
||||
if let Some(ticks) = case["@batTicks"].as_array() {
|
||||
for (i, flags) in ticks.iter().enumerate() {
|
||||
mob.age = i as u32 + 1;
|
||||
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
|
||||
if flag("@batInFrame") {
|
||||
bat::tick(mob);
|
||||
} else {
|
||||
bat::setup_animation_states(mob);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Species::Sheep => mob.extra.anim_ticks = num("eatTick") as i32,
|
||||
Species::Guardian | Species::ElderGuardian => {
|
||||
mob.anim.v[guardian::TAIL] = num("tailAnimation");
|
||||
@@ -377,7 +393,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
mob.extra.age = -24000;
|
||||
}
|
||||
mob.age = f("ageInTicks") as u32;
|
||||
let state = State {
|
||||
let mut state = State {
|
||||
pos: f("walkAnimationPos"),
|
||||
speed: f("walkAnimationSpeed"),
|
||||
age: f("ageInTicks"),
|
||||
@@ -389,6 +405,20 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
};
|
||||
let mut poses = Poses::new(&model);
|
||||
entity_state(&mut mob, def.species, case);
|
||||
if let Some(ticks) = case["@walkTicks"].as_array() {
|
||||
for speed in ticks {
|
||||
mob.previous_limb_speed = mob.limb_speed;
|
||||
mob.limb_speed += (speed.as_f64().unwrap() as f32 - mob.limb_speed) * 0.4;
|
||||
mob.limb_position += mob.limb_speed;
|
||||
}
|
||||
if !case["@alive"].as_bool().unwrap() {
|
||||
mob.health = 0.0;
|
||||
}
|
||||
let extracted = anim::state(&mob, f("@partial"));
|
||||
state.pos = extracted.pos;
|
||||
state.speed = extracted.speed;
|
||||
}
|
||||
|
||||
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
|
||||
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
|
||||
let started = |k: &str| AnimationState {
|
||||
@@ -456,14 +486,6 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
roll_amount: num("rollAmount"),
|
||||
},
|
||||
),
|
||||
Species::Bat => b_anim::bat(
|
||||
p,
|
||||
s,
|
||||
flag("isResting"),
|
||||
&started("flyAnimationState"),
|
||||
&started("restAnimationState"),
|
||||
&animations,
|
||||
),
|
||||
Species::Armadillo => b_anim::armadillo(
|
||||
p,
|
||||
s,
|
||||
@@ -590,7 +612,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
_ => anim::setup(&mob, s, p, &animations),
|
||||
}
|
||||
out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
|
||||
let mut result = emit(&model, &mut poses, case, layer);
|
||||
if case["@walkTicks"].is_array() {
|
||||
result["walk"] = json!([state.pos, state.speed]);
|
||||
}
|
||||
out.push_str(&format!("{result}\n"));
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
|
||||
@@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack {
|
||||
map: false,
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
|
||||
@@ -27,7 +27,9 @@ mod chat_screen_fixture;
|
||||
mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
mod emitter_particles_probe;
|
||||
mod enchanting_fixture;
|
||||
mod entity_anim_fixture;
|
||||
@@ -83,6 +85,7 @@ mod sound_engine_fixture;
|
||||
mod sound_event_fixture;
|
||||
mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
@@ -107,6 +110,9 @@ probes! {
|
||||
block_transform_fixture,
|
||||
breaking_fixture,
|
||||
camera_probe,
|
||||
squid_client_probe,
|
||||
creative_mining_probe,
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
cloud_fixture,
|
||||
|
||||
@@ -49,6 +49,7 @@ struct Scenario {
|
||||
name: String,
|
||||
ticks: i32,
|
||||
time: i64,
|
||||
difficulty: u8,
|
||||
rain: bool,
|
||||
thunder: bool,
|
||||
origin: [i32; 2],
|
||||
@@ -85,11 +86,13 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
"scenario" => {
|
||||
s.name = f[1].to_owned();
|
||||
s.time = 6000;
|
||||
s.difficulty = 2;
|
||||
for kv in &f[2..] {
|
||||
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
match k {
|
||||
"ticks" => s.ticks = v.parse().unwrap(),
|
||||
"time" => s.time = v.parse().unwrap(),
|
||||
"difficulty" => s.difficulty = v.parse().unwrap(),
|
||||
"rain" => s.rain = true,
|
||||
"thunder" => s.thunder = true,
|
||||
"levelseed" => {}
|
||||
@@ -954,6 +957,10 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for flag in &spec.flags {
|
||||
match flag.as_str() {
|
||||
"persistent" => m.persistent = true,
|
||||
"named" => {
|
||||
m.custom_name = Some("Peaceful regression".into());
|
||||
m.persistent = true;
|
||||
}
|
||||
"ridden" => {
|
||||
m.extra.head = Some("minecraft:red_harness".into());
|
||||
m.rider = Some(horizon_client::mobs::equine::Rider::default());
|
||||
@@ -1050,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: PLAYER_UUID,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
@@ -1090,7 +1098,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
|
||||
@@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::Fall(e) => Some(e.trace()),
|
||||
// The oracle's player wears no armor; the game applies this one.
|
||||
MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None,
|
||||
MotionEvent::Health(e) => Some(e.trace()),
|
||||
MotionEvent::Snowflake { position, velocity } => Some(format!(
|
||||
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
|
||||
|
||||
@@ -7,7 +7,7 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
@@ -161,7 +161,15 @@ fn scene(
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let ([x, y, z], [u, w]) = (v.pos, v.uv());
|
||||
json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
|
||||
json!([
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
u,
|
||||
w,
|
||||
argb(v.color | EMISSION_BITS),
|
||||
light_coords(v.light)
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
mobs::{Mob, MobTypes, aquatic},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
let types = MobTypes::load(&root)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let kind = Arc::new(
|
||||
types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(),
|
||||
);
|
||||
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
mob.fluids.in_water = row["water"].as_bool().unwrap();
|
||||
mob.velocity = Vec3::new(
|
||||
row["x"].as_f64().unwrap(),
|
||||
row["y"].as_f64().unwrap(),
|
||||
row["z"].as_f64().unwrap(),
|
||||
);
|
||||
mob.body_yaw = row["body"].as_f64().unwrap() as f32;
|
||||
for (i, value) in row["v"].as_array().unwrap().iter().enumerate() {
|
||||
mob.anim.v[i] = value.as_f64().unwrap() as f32;
|
||||
}
|
||||
if row["reset"].as_bool().unwrap() {
|
||||
aquatic::squid_cycle_event(&mut mob);
|
||||
}
|
||||
let velocity = mob.velocity;
|
||||
aquatic::squid_client_step(&mut mob);
|
||||
assert_eq!(
|
||||
velocity, mob.velocity,
|
||||
"a remote animation must preserve packet velocity"
|
||||
);
|
||||
write!(out, "{:08x}", mob.body_yaw.to_bits())?;
|
||||
for value in &mob.anim.v[..6] {
|
||||
write!(out, " {:08x}", value.to_bits())?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -58,7 +58,10 @@ enum Animate {
|
||||
BubbleColumn {
|
||||
drag: bool,
|
||||
},
|
||||
Fire,
|
||||
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
|
||||
Fire {
|
||||
soul: bool,
|
||||
},
|
||||
/// An active sculk sensor.
|
||||
SculkSensor,
|
||||
/// Potent sulfur that is not dry.
|
||||
@@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"bubble_column" => Animate::BubbleColumn {
|
||||
drag: prop("drag").as_deref() == Some("true"),
|
||||
},
|
||||
"fire" | "soul_fire" => Animate::Fire,
|
||||
"fire" => Animate::Fire { soul: false },
|
||||
"soul_fire" => Animate::Fire { soul: true },
|
||||
"dried_ghast" => Animate::DriedGhast {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
@@ -190,6 +194,8 @@ pub struct AnimateTables {
|
||||
power_transmitter: Vec<bool>,
|
||||
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
|
||||
dried_ghast_sounds: Vec<bool>,
|
||||
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
|
||||
burnable: Vec<bool>,
|
||||
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
|
||||
ambient: Vec<Vec<(T, f32)>>,
|
||||
}
|
||||
@@ -220,6 +226,21 @@ impl AnimateTables {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let behaviour: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
|
||||
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
|
||||
let burnable = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
// FireBlock.getIgniteOdds: a waterlogged state never burns.
|
||||
registry.state(BlockStateId(id)).is_some_and(|s| {
|
||||
s.properties.get("waterlogged").map(String::as_str) != Some("true")
|
||||
&& ignite_odds
|
||||
.and_then(|m| m.get(s.name.as_str()))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.is_some_and(|odds| odds > 0)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut ambient = vec![Vec::new(); biomes.list.len()];
|
||||
for (name, &id) in &biomes.index {
|
||||
let path = root.join(format!(
|
||||
@@ -248,6 +269,7 @@ impl AnimateTables {
|
||||
power_provider: by_state("minecraft:enchantment_power_provider"),
|
||||
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
|
||||
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
|
||||
burnable,
|
||||
ambient,
|
||||
})
|
||||
}
|
||||
@@ -1103,7 +1125,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Fire => {
|
||||
Animate::Fire { soul } => {
|
||||
let r = engine.random();
|
||||
if next_int(r, 24) == 0 {
|
||||
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
|
||||
@@ -1115,6 +1137,55 @@ fn block_animate_tick(
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
// BaseFireBlock.animateTick: three large smoke above the fire, or
|
||||
// two at each burnable neighbour's face when nothing burnable or
|
||||
// sturdy supports it (SoulFireBlock.canBurn is always true).
|
||||
let can_burn = |p: [i32; 3]| {
|
||||
soul || tables
|
||||
.burnable
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
|
||||
if !can_burn(below) && !sturdy {
|
||||
// (neighbour, coordinate axis, face at the axis' high end): the
|
||||
// west, east, north, south and above faces, in vanilla order.
|
||||
for (at, axis, high) in [
|
||||
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
|
||||
([pos[0] + 1, pos[1], pos[2]], 0, true),
|
||||
([pos[0], pos[1], pos[2] - 1], 2, false),
|
||||
([pos[0], pos[1], pos[2] + 1], 2, true),
|
||||
([pos[0], pos[1] + 1, pos[2]], 1, true),
|
||||
] {
|
||||
if !can_burn(at) {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let r = engine.random();
|
||||
let d = [r.next_double(), r.next_double(), r.next_double()];
|
||||
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
|
||||
// The face coordinate hugs the edge: `* 0.1F` (float).
|
||||
p[axis] = if high {
|
||||
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
|
||||
} else {
|
||||
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
|
||||
};
|
||||
add(engine, T::LargeSmoke, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
let p = [
|
||||
fx + r.next_double(),
|
||||
fy + r.next_double() * 0.5 + 0.5,
|
||||
fz + r.next_double(),
|
||||
];
|
||||
add(engine, T::LargeSmoke, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1157,13 +1228,19 @@ pub fn campfire_particles<'a>(
|
||||
if r.next_float() < 0.11 {
|
||||
let count = next_int(r, 2) + 2;
|
||||
for _ in 0..count {
|
||||
make_campfire_smoke(engine, pos, signal);
|
||||
make_campfire_smoke(engine, pos, signal, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter).
|
||||
pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: bool) {
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
|
||||
/// `smoking` adds the plain smoke puff at the logs.
|
||||
pub fn make_campfire_smoke(
|
||||
engine: &mut ParticleEngine,
|
||||
pos: [i32; 3],
|
||||
signal: bool,
|
||||
smoking: bool,
|
||||
) {
|
||||
let r = engine.random();
|
||||
let sign = |r: &mut LegacyRandom| if r.next_boolean() { 1.0 } else { -1.0 };
|
||||
let dx = r.next_double() / 3.0;
|
||||
@@ -1177,6 +1254,17 @@ pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: b
|
||||
T::CampfireCosySmoke
|
||||
};
|
||||
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
|
||||
if smoking {
|
||||
let r = engine.random();
|
||||
let dx = r.next_double() / 4.0 * sign(r);
|
||||
let dz = r.next_double() / 4.0 * sign(r);
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5 + dx,
|
||||
f64::from(pos[1]) + 0.4,
|
||||
f64::from(pos[2]) + 0.5 + dz,
|
||||
];
|
||||
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -156,7 +156,11 @@ pub struct ArrowState {
|
||||
/// `pickupItemStack`: what picking the arrow up gives.
|
||||
pub pickup_item: crate::items::ItemStack,
|
||||
pub no_physics: bool,
|
||||
/// `AbstractArrow.getPierceLevel`.
|
||||
pub pierce: u8,
|
||||
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
|
||||
/// through (cleared when it sticks in a block).
|
||||
pub pierced: Vec<Who>,
|
||||
/// `Entity.remainingFireTicks`.
|
||||
pub fire_ticks: i32,
|
||||
pub fall_distance: f64,
|
||||
@@ -199,6 +203,7 @@ impl ArrowState {
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
no_physics: false,
|
||||
pierce: 0,
|
||||
pierced: Vec::new(),
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
fluids: EntityFluidInteraction::new([WATER, LAVA]),
|
||||
@@ -362,6 +367,29 @@ fn bounds(at: Vec3) -> Aabb {
|
||||
Vec3::new(at.x + w, at.y + f64::from(HEIGHT), at.z + w),
|
||||
)
|
||||
}
|
||||
/// The bounding box of an arrow, or a thrown trident, at `at`.
|
||||
pub fn arrow_bounds(at: Vec3) -> Aabb {
|
||||
bounds(at)
|
||||
}
|
||||
/// The blocks an arrow that moved from `from` to `to` was inside
|
||||
/// (`Entity.checkInsideBlocks`: the sweep of its box, 16 steps at most), for
|
||||
/// the blocks that react to it (`entityInside`).
|
||||
pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec<BlockPos> {
|
||||
let deflated = bounds(to).deflate(Vec3::new(
|
||||
f64::from(1.0e-5f32),
|
||||
f64::from(1.0e-5f32),
|
||||
f64::from(1.0e-5f32),
|
||||
));
|
||||
let mut inside = Vec::new();
|
||||
let _ = sweep_intersected_blocks(from, to, deflated, 1_000_000, |p, iteration| {
|
||||
if iteration >= 16 {
|
||||
return false;
|
||||
}
|
||||
inside.push(p);
|
||||
true
|
||||
});
|
||||
inside
|
||||
}
|
||||
/// `Projectile.lerpRotation`.
|
||||
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
|
||||
while new - old < -180.0 {
|
||||
@@ -431,10 +459,19 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
|
||||
match outcome {
|
||||
EntityHit::Hurt => {
|
||||
ticker.hit_sound();
|
||||
ticker.st.removed = true;
|
||||
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
|
||||
if ticker.st.pierce == 0 {
|
||||
ticker.st.removed = true;
|
||||
}
|
||||
}
|
||||
EntityHit::Refused => ticker.turn_back(),
|
||||
EntityHit::Deflected => {}
|
||||
EntityHit::Deflected => {
|
||||
// The deflection comes before `onHitEntity` adds the entity to
|
||||
// `piercingIgnoreEntityIds`.
|
||||
if ticker.st.pierce > 0 {
|
||||
ticker.st.pierced.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
ticker.finish();
|
||||
p.stuck = st.in_ground;
|
||||
@@ -639,7 +676,8 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.life = 0;
|
||||
}
|
||||
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
|
||||
/// block (piercing is not modelled).
|
||||
/// block. A piercing arrow that hits an entity here goes on from it next
|
||||
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
|
||||
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
|
||||
let block = self.scene.clip(from, to);
|
||||
let end = block.map_or(to, |b| b.0);
|
||||
@@ -653,6 +691,10 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
|
||||
continue;
|
||||
}
|
||||
// canHitEntity: not an entity a piercing arrow has gone through.
|
||||
if self.st.pierced.contains(who) {
|
||||
continue;
|
||||
}
|
||||
if let Some(at) = self.entity_hit(b, from, end, margin) {
|
||||
// Sorted by the distance to the entity's position (the
|
||||
// stable sort keeps the first of equals).
|
||||
@@ -667,6 +709,15 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.p.position = next;
|
||||
self.apply_effects_from_blocks(from, next);
|
||||
if let Some((who, at, _)) = best {
|
||||
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
|
||||
// arrow is discarded before it hurts the next one.
|
||||
if self.st.pierce > 0 {
|
||||
if self.st.pierced.len() > usize::from(self.st.pierce) {
|
||||
self.st.removed = true;
|
||||
return Hit::None;
|
||||
}
|
||||
self.st.pierced.push(who);
|
||||
}
|
||||
return Hit::Entity { who, at };
|
||||
}
|
||||
let Some((at, pos, face)) = block else {
|
||||
@@ -752,6 +803,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.shake = 7;
|
||||
self.p.set_critical(false);
|
||||
self.st.pierce = 0;
|
||||
self.st.pierced.clear();
|
||||
}
|
||||
/// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`.
|
||||
fn may_interact(&self) -> bool {
|
||||
|
||||
@@ -323,10 +323,15 @@ const QUIET_MESHES_PER_S: f64 = 100.0;
|
||||
/// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are
|
||||
/// drawn once no stage has held work for half a second: blocks changing after the load (fluids
|
||||
/// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet.
|
||||
///
|
||||
/// Right after a teleport (and at spawn) the game reports nothing queued and steady counts for over
|
||||
/// a second before it starts loading: the counts must change once, or stay full for 5 s, and the
|
||||
/// resident chunks reach [`view_chunks`], before "steady" means loaded.
|
||||
fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Instant, String> {
|
||||
let deadline = Instant::now() + LOAD_LIMIT;
|
||||
let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now());
|
||||
let mut previous: Option<(Instant, f64)> = None;
|
||||
let (mut first, mut moved, began) = (None, false, Instant::now());
|
||||
while Instant::now() < deadline {
|
||||
let s = control.status();
|
||||
let now = Instant::now();
|
||||
@@ -339,6 +344,19 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
|
||||
(last, changed, previous, meshing) = (None, now, None, now);
|
||||
} else {
|
||||
let counts = (s["chunks"].as_u64(), s["generated_chunks"].as_u64());
|
||||
let resident = counts.0.unwrap_or(0);
|
||||
let min_chunks = view_chunks(s["render_distance"].as_u64().unwrap_or(0));
|
||||
let first = *first.get_or_insert(counts);
|
||||
moved = moved
|
||||
|| counts != first
|
||||
|| (min_chunks > 0
|
||||
&& resident >= min_chunks
|
||||
&& now - began > Duration::from_secs(5));
|
||||
if min_chunks > 0 && (!moved || resident < min_chunks) {
|
||||
(last, changed, previous, meshing) = (None, now, None, now);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
}
|
||||
if last != Some(counts) {
|
||||
(last, changed) = (Some(counts), now);
|
||||
}
|
||||
@@ -370,6 +388,12 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
|
||||
Err(format!("not loaded after {} s", LOAD_LIMIT.as_secs()))
|
||||
}
|
||||
|
||||
/// 80% of the view circle (pi x RD^2): the game keeps 637 columns at RD 12 and 3725 at RD 32
|
||||
/// (`tools/perf_bench.py`'s `view_chunks`).
|
||||
fn view_chunks(distance: u64) -> u64 {
|
||||
(0.8 * std::f64::consts::PI * (distance * distance) as f64) as u64
|
||||
}
|
||||
|
||||
/// Vanilla's yaw facing from `start` to `end`.
|
||||
fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 {
|
||||
(-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees()
|
||||
@@ -420,6 +444,13 @@ mod tests {
|
||||
assert!(scenes("nether").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_chunks_matches_perf_bench() {
|
||||
assert_eq!(view_chunks(12), 361);
|
||||
assert_eq!(view_chunks(32), 2573);
|
||||
assert_eq!(view_chunks(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
// South (+Z) is 0, west (-X) 90, east (+X) -90.
|
||||
|
||||
@@ -28,6 +28,9 @@ pub struct BiomeInfo {
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
|
||||
/// mushroom fields).
|
||||
pub increased_fire_burnout: bool,
|
||||
pub name: String,
|
||||
pub temperature: f32,
|
||||
pub downfall: f32,
|
||||
@@ -128,6 +131,9 @@ impl Biomes {
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
|
||||
@@ -648,6 +648,61 @@ impl Conduit {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ConduitCheck {
|
||||
/// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`.
|
||||
pub toggled: Option<bool>,
|
||||
/// The frame blocks counted, while the conduit is active: `applyEffects`
|
||||
/// and `updateAndAttackTarget` follow (the attack needs 42 or more).
|
||||
pub frames: Option<usize>,
|
||||
}
|
||||
|
||||
/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ConduitServer {
|
||||
shape: Conduit,
|
||||
/// `destroyTarget`: the mob id of the enemy being struck.
|
||||
pub target: Option<u32>,
|
||||
next_ambient_sound: i64,
|
||||
}
|
||||
|
||||
impl ConduitServer {
|
||||
/// The `gameTime % 40` part of `serverTick`: the shape is checked again.
|
||||
pub fn check(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
game_time: i64,
|
||||
is_water: impl Fn([i32; 3]) -> bool,
|
||||
is_frame: impl Fn([i32; 3]) -> bool,
|
||||
) -> Option<ConduitCheck> {
|
||||
if game_time % 40 != 0 {
|
||||
return None;
|
||||
}
|
||||
let active = self.shape.update_shape(pos, &is_water, &is_frame);
|
||||
let toggled = (active != self.shape.is_active).then_some(active);
|
||||
self.shape.is_active = active;
|
||||
Some(ConduitCheck {
|
||||
toggled,
|
||||
frames: active.then_some(self.shape.effect_blocks.len()),
|
||||
})
|
||||
}
|
||||
/// The sounds of an active conduit at the end of `serverTick`: whether
|
||||
/// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every
|
||||
/// 60 to 99 ticks) play.
|
||||
pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) {
|
||||
if !self.shape.is_active {
|
||||
return (false, false);
|
||||
}
|
||||
let short = game_time > self.next_ambient_sound;
|
||||
if short {
|
||||
self.next_ambient_sound =
|
||||
game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0));
|
||||
}
|
||||
(game_time % 80 == 0, short)
|
||||
}
|
||||
}
|
||||
|
||||
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Skull {
|
||||
@@ -832,6 +887,8 @@ pub struct BlockEntityAnims {
|
||||
pub gateways: FxHashMap<[i32; 3], EndGateway>,
|
||||
/// The integrated server's copies of the gateways (`portalTick`).
|
||||
pub gateway_servers: FxHashMap<[i32; 3], EndGateway>,
|
||||
/// The integrated server's copies of the conduits (`serverTick`).
|
||||
pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>,
|
||||
/// The client level's random (conduit particles).
|
||||
pub random: LegacyRandom,
|
||||
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
|
||||
@@ -854,6 +911,7 @@ impl Default for BlockEntityAnims {
|
||||
pots: FxHashMap::default(),
|
||||
gateways: FxHashMap::default(),
|
||||
gateway_servers: FxHashMap::default(),
|
||||
conduit_servers: FxHashMap::default(),
|
||||
random: LegacyRandom::new(0x6c6576656c),
|
||||
kinds: Vec::new(),
|
||||
present: Vec::new(),
|
||||
@@ -909,6 +967,7 @@ impl BlockEntityAnims {
|
||||
self.pots.retain(|p, _| keep(*p));
|
||||
self.gateways.retain(|p, _| keep(*p));
|
||||
self.gateway_servers.retain(|p, _| keep(*p));
|
||||
self.conduit_servers.retain(|p, _| keep(*p));
|
||||
}
|
||||
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
|
||||
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
|
||||
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
"minecraft:trial_spawner" => {
|
||||
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
|
||||
// and defaulted by vanilla; they are kept as loaded.
|
||||
// and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
|
||||
// added: an absent one loads as Optional.empty and is not stored, and
|
||||
// an empty entity would keep the spawner from ever picking a mob.
|
||||
if let Tag::Compound(c) = tag {
|
||||
for (k, v) in c {
|
||||
let key = k.to_string_lossy();
|
||||
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !f.out.contains_key(&"spawn_data".into()) {
|
||||
f.put(
|
||||
"spawn_data",
|
||||
Tag::compound([("entity", Tag::Compound(Compound::new()))]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:vault" => {
|
||||
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
|
||||
@@ -827,6 +823,21 @@ mod tests {
|
||||
canonical(&snbt(s)).expect("known type")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trial_spawner_without_spawn_data_saves_none() {
|
||||
let fresh = canon(
|
||||
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
|
||||
);
|
||||
assert_eq!(fresh.get("spawn_data"), None);
|
||||
let picked = canon(
|
||||
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
picked.get("spawn_data"),
|
||||
Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_type_has_defaults() {
|
||||
for id in TYPES.iter().chain(NEWEST.iter()) {
|
||||
|
||||
@@ -558,12 +558,11 @@ fn frac(x: f32) -> f32 {
|
||||
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
|
||||
/// wrapped at the last space that fits or mid-word when none does.
|
||||
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut width = 0.0;
|
||||
let mut last_space = None;
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
for (i, c) in line.char_indices() {
|
||||
if c == '\n' {
|
||||
return chars[..i].iter().collect();
|
||||
return line[..i].to_owned();
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
@@ -572,9 +571,11 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
|
||||
if width > max_width {
|
||||
let end = match last_space {
|
||||
Some(s) if s > 0 => s,
|
||||
_ => i.max(1),
|
||||
_ if i > 0 => i,
|
||||
// The first char alone is too wide: take that one char.
|
||||
_ => c.len_utf8(),
|
||||
};
|
||||
return chars[..end].iter().collect();
|
||||
return line[..end].to_owned();
|
||||
}
|
||||
}
|
||||
line.to_owned()
|
||||
@@ -2098,4 +2099,13 @@ mod tests {
|
||||
assert_eq!(out[1].pos, [2.0, 0.0, 0.0]);
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,38 @@ impl Contents {
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// The stacks inside, in display order (the first is the next out).
|
||||
pub fn items(&self) -> &[ItemStack] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// `getNumberOfItemsToShow`: a full grid shows 11 and a `+N`, so the last
|
||||
/// row keeps its fourth cell for the count.
|
||||
pub fn shown_count(&self) -> usize {
|
||||
let count = self.items.len();
|
||||
let available: usize = if count > 12 { 11 } else { 12 };
|
||||
let on_last_row = count % 4;
|
||||
let empty = if on_last_row == 0 { 0 } else { 4 - on_last_row };
|
||||
count.min(available.saturating_sub(empty))
|
||||
}
|
||||
|
||||
/// The items the tooltip shows, and how many sit beyond them.
|
||||
pub fn shown(&self) -> (&[ItemStack], u32) {
|
||||
let n = self.shown_count();
|
||||
let hidden = self.items[n..].iter().map(|s| s.count).sum();
|
||||
(&self.items[..n], hidden)
|
||||
}
|
||||
|
||||
/// The `Fraction` weight as the float the progress bar reads.
|
||||
pub fn weight_fraction(&self) -> f32 {
|
||||
self.weight.n as f32 / self.weight.d as f32
|
||||
}
|
||||
|
||||
/// `Fraction.ONE`: a full bundle draws the full bar and says so.
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.weight.n >= self.weight.d
|
||||
}
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
bundle.data_mut().bundle_contents = self.items;
|
||||
@@ -190,3 +222,174 @@ mod tests {
|
||||
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the tooltip
|
||||
|
||||
/// `ClientBundleTooltip`'s geometry, relative to the tooltip box's top-left:
|
||||
/// the 96 pixel column of a 4 wide slot grid and the progress bar under it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TooltipLayout {
|
||||
/// The component's own width (96) and height, the text's excluded.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// Each slot's top-left and the stack in it, in reading order.
|
||||
pub slots: Vec<([f32; 2], ItemStack)>,
|
||||
/// The `+N` of the items past the grid: its top-left and the count.
|
||||
pub overflow: Option<([f32; 2], u32)>,
|
||||
/// The progress bar's top-left, its fill's width out of 94, and whether
|
||||
/// the weight fills it (the full sprite).
|
||||
pub bar: [f32; 2],
|
||||
pub fill: f32,
|
||||
pub full: bool,
|
||||
/// `item.minecraft.bundle.empty` / `.full`, centred over the bar.
|
||||
pub label: Option<&'static str>,
|
||||
/// An empty bundle's description, wrapped at `width` from here.
|
||||
pub empty_description: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// The tooltip of a bundle's contents (`ClientBundleTooltip`), or None when
|
||||
/// the stack is not a bundle. `x`/`y` are the box's top-left, `box_width` the
|
||||
/// tooltip's width, which the component is centred in.
|
||||
pub fn tooltip_layout(
|
||||
items: &Items,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
box_width: f32,
|
||||
) -> Option<TooltipLayout> {
|
||||
let contents = Contents::of(items, stack)?;
|
||||
let offset = ((box_width - 96.0) / 2.0).floor();
|
||||
if contents.items().is_empty() {
|
||||
// The description takes the top; the bar follows it.
|
||||
let description_height = 9.0 * 2.0;
|
||||
return Some(TooltipLayout {
|
||||
width: 96.0,
|
||||
height: description_height + 13.0 + 8.0,
|
||||
slots: Vec::new(),
|
||||
overflow: None,
|
||||
bar: [x + offset, y + description_height + 4.0],
|
||||
fill: 0.0,
|
||||
full: false,
|
||||
label: Some("item.minecraft.bundle.empty"),
|
||||
empty_description: Some([x + offset, y]),
|
||||
});
|
||||
}
|
||||
let (shown, hidden) = contents.shown();
|
||||
// `gridSizeY` counts `slotCount()` (at most twelve), not the slots the
|
||||
// overflow count leaves room for.
|
||||
let rows = contents.items().len().min(12).div_ceil(4).max(1);
|
||||
let grid_height = rows as f32 * 24.0;
|
||||
let x_start = x + offset + 96.0;
|
||||
let y_start = y + grid_height;
|
||||
let mut slots = Vec::with_capacity(shown.len());
|
||||
let mut overflow = None;
|
||||
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
|
||||
let mut slot = 0;
|
||||
for row in 1..=rows {
|
||||
for column in 1..=4 {
|
||||
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
|
||||
if hidden > 0 && row == 1 && column == 1 {
|
||||
overflow = Some((at, hidden));
|
||||
} else if let Some(stack) = shown.get(slot) {
|
||||
slots.push((at, stack.clone()));
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let weight = contents.weight_fraction();
|
||||
Some(TooltipLayout {
|
||||
width: 96.0,
|
||||
height: grid_height + 13.0 + 8.0,
|
||||
slots,
|
||||
overflow,
|
||||
bar: [x + offset, y + grid_height + 4.0],
|
||||
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
|
||||
fill: (weight * 94.0).trunc().clamp(0.0, 94.0),
|
||||
full: contents.is_full(),
|
||||
label: if contents.is_full() {
|
||||
Some("item.minecraft.bundle.full")
|
||||
} else {
|
||||
None
|
||||
},
|
||||
empty_description: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tooltip_tests {
|
||||
use super::*;
|
||||
|
||||
/// The prepared resources, when they are on this machine: the tooltip
|
||||
/// asks the item registry whether a stack is a bundle.
|
||||
fn items() -> Option<Items> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return None;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).ok()?;
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
|
||||
Items::load(&root, assets.registry, &facts).ok()
|
||||
}
|
||||
|
||||
fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
|
||||
let mut bundle = ItemStack::new("minecraft:bundle", 1);
|
||||
bundle.data_mut().bundle_contents = counts
|
||||
.iter()
|
||||
.map(|(item, n)| ItemStack::new(item, *n))
|
||||
.collect();
|
||||
bundle
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_shows_twelve_stacks_and_counts_the_rest() {
|
||||
let Some(items) = items() else { return };
|
||||
let twelve: Vec<(&str, u32)> = (0..12).map(|_| ("minecraft:stone", 1)).collect();
|
||||
let layout = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 96.0).unwrap();
|
||||
assert_eq!(layout.slots.len(), 12);
|
||||
assert_eq!(layout.overflow, None);
|
||||
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
|
||||
// Rows fill bottom-up and columns right-to-left: the first item is in
|
||||
// the bottom-right cell, the twelfth in the top-left.
|
||||
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
|
||||
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
|
||||
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
|
||||
// A wider tooltip centres the 96 pixel column in it.
|
||||
let centred = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 120.0).unwrap();
|
||||
assert_eq!(centred.slots[0].0[0], 12.0 + 96.0 - 24.0);
|
||||
// Thirteen stacks: `getNumberOfItemsToShow` leaves the last row room
|
||||
// for the count, so 8 slots show and `+6` (four stones and two dirt)
|
||||
// takes the first cell.
|
||||
let mut counts = twelve;
|
||||
counts.push(("minecraft:dirt", 2));
|
||||
let layout = tooltip_layout(&items, &bundle_with(&counts), 0.0, 0.0, 96.0).unwrap();
|
||||
assert_eq!(layout.slots.len(), 8);
|
||||
assert_eq!(layout.overflow, Some(([96.0 - 24.0, 48.0], 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bar_fills_with_the_weight_and_an_empty_bundle_explains_itself() {
|
||||
let Some(items) = items() else { return };
|
||||
let layout = tooltip_layout(&items, &bundle_with(&[]), 4.0, 8.0, 96.0).unwrap();
|
||||
assert!(layout.slots.is_empty());
|
||||
assert_eq!(layout.fill, 0.0);
|
||||
assert_eq!(layout.label, Some("item.minecraft.bundle.empty"));
|
||||
assert_eq!(layout.empty_description, Some([4.0, 8.0]));
|
||||
assert_eq!(layout.bar, [4.0, 8.0 + 18.0 + 4.0]);
|
||||
// A bundle holding one 64 stack is full: the whole bar, the full label.
|
||||
let layout = tooltip_layout(
|
||||
&items,
|
||||
&bundle_with(&[("minecraft:stone", 64)]),
|
||||
0.0,
|
||||
0.0,
|
||||
96.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(layout.full);
|
||||
assert_eq!(layout.fill, 94.0);
|
||||
assert_eq!(layout.label, Some("item.minecraft.bundle.full"));
|
||||
// Something that is not a bundle has no component.
|
||||
let dirt = ItemStack::new("minecraft:dirt", 1);
|
||||
assert!(tooltip_layout(&items, &dirt, 0.0, 0.0, 96.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -436,3 +436,34 @@ fn the_chat_option_filters_the_lines_both_ways() {
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
|
||||
assert_eq!(shown(&chat), ["client", "server", "player"]);
|
||||
}
|
||||
|
||||
/// The in-frame LAN host's chat: a guest's line is a player message, so the
|
||||
/// Chat option's player-message permission hides it at Commands Only and
|
||||
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
|
||||
/// at Commands Only.
|
||||
#[test]
|
||||
fn a_guests_line_follows_the_player_message_permission() {
|
||||
let font = font();
|
||||
let shown = |visibility, source| {
|
||||
let mut chat = Chat::default();
|
||||
chat.set_abilities(&font, ChatAbilities::new(visibility));
|
||||
chat.add_message(&font, Text::literal("line"), None, source, 0, false);
|
||||
chat.messages.len()
|
||||
};
|
||||
for (visibility, player, system) in [
|
||||
(ChatVisibility::Full, 1, 1),
|
||||
(ChatVisibility::System, 0, 1),
|
||||
(ChatVisibility::Hidden, 0, 0),
|
||||
] {
|
||||
assert_eq!(
|
||||
shown(visibility, ChatSource::Player),
|
||||
player,
|
||||
"{visibility:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
shown(visibility, ChatSource::Server),
|
||||
system,
|
||||
"{visibility:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,9 @@ pub struct Chunk {
|
||||
/// Structure starts reaching into this chunk (`structures.References`):
|
||||
/// structure id and the start's chunk.
|
||||
pub structure_refs: Vec<(Arc<str>, (i32, i32))>,
|
||||
/// The starts generation made in this chunk (`structures.starts`), saved
|
||||
/// with the state their placement has reached by then.
|
||||
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
|
||||
}
|
||||
impl Chunk {
|
||||
pub fn empty(x: i32, z: i32) -> Self {
|
||||
@@ -178,6 +181,7 @@ impl Chunk {
|
||||
block_entities: Vec::new(),
|
||||
block_names: Vec::new(),
|
||||
structure_refs: Vec::new(),
|
||||
structure_starts: Vec::new(),
|
||||
postprocess: Vec::new(),
|
||||
saved: None,
|
||||
entities: Vec::new(),
|
||||
|
||||
@@ -346,6 +346,11 @@ mod tests {
|
||||
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
|
||||
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
|
||||
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
|
||||
// TextFieldHelper.getClipboardContents runs before insertText's filter.
|
||||
assert_eq!(
|
||||
filter_text(&helper_clipboard_contents("§aHello"), false),
|
||||
"Hello"
|
||||
);
|
||||
}
|
||||
|
||||
struct Field {
|
||||
|
||||
@@ -275,4 +275,30 @@ mod tests {
|
||||
Release::Click(Input::QuickMove)
|
||||
);
|
||||
}
|
||||
|
||||
/// A press whose release never arrives (the pointer left the window, or
|
||||
/// the screen closed first) leaves `isQuickCrafting` set; the next screen
|
||||
/// starts with its own state, as `AbstractContainerScreen` does, so the
|
||||
/// reset the game runs on open/close makes clicks work again.
|
||||
#[test]
|
||||
fn a_lost_release_does_not_wedge_the_next_screen() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::<usize>::Slot(3);
|
||||
// Pressed with a stack carried: a quick craft drag starts, and its
|
||||
// release is lost.
|
||||
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
|
||||
assert!(clicks.quick_crafting().is_some());
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
None,
|
||||
"the stuck drag swallows the next press"
|
||||
);
|
||||
// A new screen: fresh state, so the press is a normal click again.
|
||||
clicks = Clicks::default();
|
||||
assert!(clicks.quick_crafting().is_none());
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,17 @@ pub struct Furnace {
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
|
||||
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
|
||||
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
|
||||
/// ("Duplicate entry for key").
|
||||
pub fn recipe_used_key(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
@@ -83,8 +94,68 @@ pub enum BlockEntity {
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
|
||||
let Some(slot) = self.items.iter().position(Option::is_none) else {
|
||||
return false;
|
||||
};
|
||||
self.total[slot] = cooking_time;
|
||||
self.progress[slot] = 0;
|
||||
self.items[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..held.clone()
|
||||
});
|
||||
held.count = held.count.saturating_sub(1);
|
||||
true
|
||||
}
|
||||
/// `cookTick` (lit): each item's progress rises by one; the slots whose
|
||||
/// progress reached their cooking time give up their item (the caller
|
||||
/// drops its result).
|
||||
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
|
||||
let mut done = Vec::new();
|
||||
for i in 0..4 {
|
||||
if self.items[i].is_none() {
|
||||
continue;
|
||||
}
|
||||
self.progress[i] += 1;
|
||||
if self.progress[i] >= self.total[i]
|
||||
&& let Some(item) = self.items[i].take()
|
||||
{
|
||||
done.push((i, item));
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
|
||||
/// Whether anything changed (`setChanged`).
|
||||
pub fn cooldown_tick(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
for i in 0..4 {
|
||||
if self.progress[i] > 0 {
|
||||
changed = true;
|
||||
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
@@ -295,6 +366,9 @@ impl BlockEntity {
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
if name == "minecraft:beacon" {
|
||||
return Some(Self::Beacon(Box::new(Beacon {
|
||||
last_check_y: i32::MIN / 2,
|
||||
@@ -304,6 +378,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:brewing_stand" {
|
||||
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
|
||||
}
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -341,6 +418,8 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -357,11 +436,13 @@ impl BlockEntity {
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Skull(_) => Vec::new(),
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -375,14 +456,17 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &[],
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &[],
|
||||
| Self::CommandBlock(_)
|
||||
// Not a Container (only Clearable): hoppers don't reach in.
|
||||
| Self::Campfire(_) => &[],
|
||||
}
|
||||
}
|
||||
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
|
||||
@@ -396,14 +480,16 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &mut [],
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &mut [],
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Campfire(_) => &mut [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +724,10 @@ impl Furnace {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
@@ -692,6 +781,41 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
let mut beef = ItemStack::new("minecraft:beef", 2);
|
||||
// placeFood: the first free slot, one item, its recipe's time.
|
||||
assert!(c.place_food(&mut beef, 600));
|
||||
assert_eq!(beef.count, 1);
|
||||
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
|
||||
assert_eq!((c.progress[0], c.total[0]), (0, 600));
|
||||
let mut fish = ItemStack::new("minecraft:cod", 1);
|
||||
assert!(c.place_food(&mut fish, 3));
|
||||
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
|
||||
// cookTick: the cod is done on its third tick, the beef keeps cooking.
|
||||
assert!(c.cook_tick().is_empty());
|
||||
assert!(c.cook_tick().is_empty());
|
||||
let done = c.cook_tick();
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
|
||||
assert!(c.items[1].is_none());
|
||||
assert_eq!(c.progress[0], 3);
|
||||
// cooldownTick: two a tick, never below zero.
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 1);
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 0);
|
||||
// Full: four slots, no fifth.
|
||||
for _ in 0..3 {
|
||||
let mut more = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(c.place_food(&mut more, 600));
|
||||
}
|
||||
let mut fifth = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(!c.place_food(&mut fifth, 600));
|
||||
assert_eq!(fifth.count, 1);
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -1612,7 +1612,9 @@ impl Recipes {
|
||||
}
|
||||
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
|
||||
/// pack does not have (a save from another pack can name it).
|
||||
/// `id` with or without the `minecraft:` namespace (a furnace keeps the full id).
|
||||
pub fn cooking_experience(&self, id: &str) -> f32 {
|
||||
let id = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
self.cooking
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
|
||||
//! contents as built by the game's own display generators
|
||||
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
|
||||
use crate::{Result, items::ItemStack};
|
||||
use crate::{Result, items::ItemStack, ui::EditBox};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Tab {
|
||||
@@ -35,13 +40,22 @@ pub struct Creative {
|
||||
pub scroll: f32,
|
||||
/// The scrollbar is held (scrolling): dragging moves it.
|
||||
pub scrolling: bool,
|
||||
pub search: String,
|
||||
/// When the search box took the focus (`EditBox.setFocused`): its
|
||||
/// cursor blinks from then.
|
||||
pub search_focused: std::time::Instant,
|
||||
/// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50.
|
||||
pub search: EditBox,
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
}
|
||||
/// The grid's stacks for one tab, search text and language.
|
||||
struct Shown {
|
||||
tab: usize,
|
||||
search: String,
|
||||
/// A display name, so a language change refills the search results.
|
||||
language: String,
|
||||
items: Arc<[ItemStack]>,
|
||||
}
|
||||
/// Display names from the pinned en_us language file.
|
||||
#[derive(Default)]
|
||||
@@ -284,10 +298,16 @@ impl Creative {
|
||||
selected: 0,
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
search: String::new(),
|
||||
search_focused: std::time::Instant::now(),
|
||||
search: {
|
||||
let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0);
|
||||
search.bordered = false;
|
||||
search.max_length = 50;
|
||||
search.text_color = u32::MAX;
|
||||
search
|
||||
},
|
||||
search_items,
|
||||
search_index,
|
||||
shown: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
/// Where a stack comes in the creative tabs (the search tab lists them
|
||||
@@ -300,18 +320,35 @@ impl Creative {
|
||||
.position(|s| s.same_item_same_components(stack))
|
||||
.or_else(|| items.iter().position(|s| s.item == stack.item))
|
||||
}
|
||||
/// The tabs without their stacks: what the screen draws of them each frame.
|
||||
pub fn tab_headers(&self) -> Vec<Tab> {
|
||||
self.tabs
|
||||
.iter()
|
||||
.map(|t| Tab {
|
||||
id: t.id.clone(),
|
||||
title: t.title.clone(),
|
||||
icon: t.icon.clone(),
|
||||
items: Vec::new(),
|
||||
top: t.top,
|
||||
column: t.column,
|
||||
search: t.search,
|
||||
inventory: t.inventory,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
}
|
||||
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
|
||||
pub fn select(&mut self, tab: usize) {
|
||||
if tab != self.selected {
|
||||
self.search.clear();
|
||||
self.search.set_value("");
|
||||
}
|
||||
self.selected = tab;
|
||||
self.scroll = 0.0;
|
||||
if self.tab().search {
|
||||
self.search_focused = std::time::Instant::now();
|
||||
self.search.focused = true;
|
||||
self.search.reset_blink();
|
||||
}
|
||||
}
|
||||
/// The search tab's stacks.
|
||||
@@ -380,17 +417,44 @@ impl Creative {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Items shown by the selected tab (the search tab filters by `search`).
|
||||
pub fn items(&self, names: &Names) -> Vec<ItemStack> {
|
||||
let tab = self.tab();
|
||||
if tab.search {
|
||||
return self
|
||||
.search_results(&self.search, names)
|
||||
/// Items shown by the selected tab (the search tab filters by `search`),
|
||||
/// worked out again only when the tab, the search or the language changed.
|
||||
pub fn items(&self, names: &Names) -> Arc<[ItemStack]> {
|
||||
let tab = self.selected.min(self.tabs.len() - 1);
|
||||
let search = if self.tab().search {
|
||||
self.search.value.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let language = names.display_get("itemGroup.search").unwrap_or_default();
|
||||
let mut shown = self.shown.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(s) = shown.as_ref()
|
||||
&& s.tab == tab
|
||||
&& s.search == search
|
||||
&& s.language == language
|
||||
{
|
||||
return s.items.clone();
|
||||
}
|
||||
let items: Arc<[ItemStack]> = if self.tab().search {
|
||||
self.search_results(search, names)
|
||||
.into_iter()
|
||||
.map(|i| self.search_items[i].clone())
|
||||
.collect();
|
||||
}
|
||||
tab.items.clone()
|
||||
.collect()
|
||||
} else {
|
||||
self.tab().items.as_slice().into()
|
||||
};
|
||||
*shown = Some(Shown {
|
||||
tab,
|
||||
search: search.to_owned(),
|
||||
language,
|
||||
items: items.clone(),
|
||||
});
|
||||
items
|
||||
}
|
||||
/// Drop the shown items, so the next `items` works them out again: a
|
||||
/// reload can rename items without changing the Search label.
|
||||
pub fn forget_items(&self) {
|
||||
*self.shown.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
pub fn rows(&self, names: &Names) -> usize {
|
||||
self.items(names).len().div_ceil(9)
|
||||
@@ -754,13 +818,13 @@ mod tests {
|
||||
(1, false, true),
|
||||
(2, true, false),
|
||||
] {
|
||||
c.search = "anvil".into();
|
||||
c.search.set_value("anvil");
|
||||
c.scroll = 0.5;
|
||||
c.select(selected);
|
||||
assert_eq!(c.selected, selected);
|
||||
assert_eq!(c.tab().inventory, inventory);
|
||||
assert_eq!(c.tab().search, search);
|
||||
assert!(c.search.is_empty());
|
||||
assert!(c.search.value.is_empty());
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -780,12 +844,12 @@ mod tests {
|
||||
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
|
||||
let mut c = c;
|
||||
c.select(1);
|
||||
c.search = "anvil".into();
|
||||
c.search.set_value("anvil");
|
||||
c.scroll = 0.5;
|
||||
c.select(1);
|
||||
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
|
||||
assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0));
|
||||
c.select(0);
|
||||
assert_eq!(c.search, "");
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -899,4 +963,25 @@ mod tests {
|
||||
c.scroll = 1.0;
|
||||
assert_eq!(c.scroller_offset(), 95.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_edits_at_its_cursor() {
|
||||
use winit::keyboard::KeyCode;
|
||||
let mut c = creative(0);
|
||||
c.select(1);
|
||||
c.search.insert_text("hello world");
|
||||
for _ in 0..5 {
|
||||
assert!(c.search.key(KeyCode::ArrowLeft, false, false));
|
||||
}
|
||||
c.search.insert_text("X");
|
||||
assert_eq!(c.search.value, "hello Xworld");
|
||||
c.search.key(KeyCode::Home, false, true);
|
||||
c.search.insert_text("Y");
|
||||
assert_eq!(c.search.value, "Yworld");
|
||||
// Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor.
|
||||
c.search.key(KeyCode::ArrowLeft, true, false);
|
||||
c.search.key(KeyCode::ArrowRight, false, false);
|
||||
c.search.key(KeyCode::Backspace, false, false);
|
||||
assert_eq!(c.search.value, "world");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Cushion placement and support checks (`Cushion.canBePlacedAt`/`wouldSurviveAt`).
|
||||
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
|
||||
|
||||
pub trait World {
|
||||
fn outline_bounds(&self, pos: [i32; 3]) -> Option<Aabb>;
|
||||
fn collision_shapes(&self, bounds: Aabb) -> Vec<VoxelShape>;
|
||||
fn suffocating(&self, pos: [i32; 3]) -> bool;
|
||||
}
|
||||
|
||||
pub fn blocks_in(a: Aabb) -> impl Iterator<Item = [i32; 3]> {
|
||||
let (lo, hi) = (a.min_position(), a.max_position());
|
||||
let (x0, y0, z0) = (
|
||||
lo.x.floor() as i32,
|
||||
lo.y.floor() as i32,
|
||||
lo.z.floor() as i32,
|
||||
);
|
||||
let (x1, y1, z1) = (
|
||||
hi.x.floor() as i32,
|
||||
hi.y.floor() as i32,
|
||||
hi.z.floor() as i32,
|
||||
);
|
||||
(y0..=y1).flat_map(move |y| (z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| [x, y, z])))
|
||||
}
|
||||
|
||||
pub fn survives(world: &impl World, bounds: Aabb) -> bool {
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let anchor = Aabb::new(
|
||||
Vec3::new(lo.x, lo.y - 0.015625, lo.z),
|
||||
Vec3::new(hi.x.next_down(), lo.y, hi.z.next_down()),
|
||||
);
|
||||
// The extra eighth block expands the scan, not the intersection test.
|
||||
let supported = blocks_in(anchor.expand_towards(Vec3::new(0.0, -0.125, 0.0))).any(|p| {
|
||||
world
|
||||
.outline_bounds(p)
|
||||
.is_some_and(|shape| shape.intersects(anchor))
|
||||
});
|
||||
supported && !blocks_in(bounds.next_deflated()).all(|p| world.suffocating(p))
|
||||
}
|
||||
|
||||
pub fn can_place(world: &impl World, bounds: Aabb) -> bool {
|
||||
if !survives(world, bounds) {
|
||||
return false;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let slab = Aabb::new(lo, Vec3::new(hi.x, lo.y + 0.015625, hi.z)).next_deflated();
|
||||
let Ok(mut remaining) = VoxelShape::from_box(slab) else {
|
||||
return false;
|
||||
};
|
||||
for shape in world.collision_shapes(slab) {
|
||||
let Ok(next) = remaining.join(&shape, ShapeOp::ONLY_FIRST) else {
|
||||
return false;
|
||||
};
|
||||
remaining = next;
|
||||
if remaining.is_empty() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -221,18 +221,17 @@ pub fn scale(
|
||||
pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec<GuiDraw> {
|
||||
let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])];
|
||||
for (lines, align_left) in [(columns[0], true), (columns[1], false)] {
|
||||
let placed: Vec<(&String, i32, i32)> = lines
|
||||
let placed: Vec<(&String, i32, i32, i32)> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.is_empty())
|
||||
.map(|(i, l)| {
|
||||
let w = font.width(l) as i32;
|
||||
let left = if align_left { 2 } else { width - 2 - w };
|
||||
(l, left, 2 + LINE_HEIGHT * i as i32)
|
||||
(l, left, 2 + LINE_HEIGHT * i as i32, w)
|
||||
})
|
||||
.collect();
|
||||
for (line, left, top) in &placed {
|
||||
let w = font.width(line) as i32;
|
||||
for (_, left, top, w) in &placed {
|
||||
out.push(fill(
|
||||
left - 1,
|
||||
top - 1,
|
||||
@@ -241,7 +240,7 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
|
||||
BACKGROUND,
|
||||
));
|
||||
}
|
||||
for (line, left, top) in placed {
|
||||
for (line, left, top, _) in placed {
|
||||
out.push(GuiDraw::Text {
|
||||
text: Text::literal(line.clone()),
|
||||
x: left,
|
||||
|
||||
@@ -95,6 +95,15 @@ impl ItemEntity {
|
||||
self.health -= fire_damage as i32;
|
||||
}
|
||||
}
|
||||
// CactusBlock.entityInside: `entity.hurt(cactus, 1.0)`. An item has no
|
||||
// hurt cooldown, so it loses 1 health every tick it touches a cactus
|
||||
// (5 health: gone in 5 ticks). Fire resistance does not help;
|
||||
// `ItemStack.canBeHurtBy` only skips it for a damage_resistant stack.
|
||||
if crate::motion::environment::cactus_contact(world, registry, self.bounds())
|
||||
&& !items.resists_cactus(&self.stack)
|
||||
{
|
||||
self.health -= 1;
|
||||
}
|
||||
ItemEnvironmentTick {
|
||||
alive: self.health > 0,
|
||||
lava_hurt,
|
||||
@@ -385,8 +394,29 @@ pub struct ExperienceOrb {
|
||||
pub value: u32,
|
||||
pub age: u32,
|
||||
pub on_ground: bool,
|
||||
/// `ExperienceOrb.count`: orbs merged into this one; each pickup gives
|
||||
/// `value` once and takes one off.
|
||||
pub count: u32,
|
||||
/// `Entity.tickCount`, which paces `ExperienceOrb.scanForMerges`.
|
||||
pub ticks: u32,
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let h = 0.25;
|
||||
Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 0.5,
|
||||
self.position.z + h,
|
||||
),
|
||||
)
|
||||
}
|
||||
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
|
||||
/// the same value and ids a multiple of 40 apart.
|
||||
pub fn can_merge(&self, id: u32, value: u32) -> bool {
|
||||
self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0
|
||||
}
|
||||
/// ExperienceOrb.getExperienceValue: the largest orb size not above `value`.
|
||||
pub fn split(value: u32) -> u32 {
|
||||
[2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1]
|
||||
@@ -416,7 +446,9 @@ impl ExperienceOrb {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
player: Option<[f64; 3]>,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
let block = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
@@ -432,6 +464,14 @@ impl ExperienceOrb {
|
||||
} else {
|
||||
self.velocity.y -= 0.03;
|
||||
}
|
||||
if facts.get(block).fluid == Fluid::Lava {
|
||||
// ExperienceOrb.tick: lava throws the orb back out.
|
||||
self.velocity = Vec3::new(
|
||||
f64::from((next_float() - next_float()) * 0.2),
|
||||
f64::from(0.2f32),
|
||||
f64::from((next_float() - next_float()) * 0.2),
|
||||
);
|
||||
}
|
||||
if let Some(p) = player {
|
||||
let d = [
|
||||
(p[0] - self.position.x) / 8.0,
|
||||
@@ -449,15 +489,7 @@ impl ExperienceOrb {
|
||||
);
|
||||
}
|
||||
}
|
||||
let h = 0.25;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 0.5,
|
||||
self.position.z + h,
|
||||
),
|
||||
);
|
||||
let bounds = self.bounds();
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
@@ -494,6 +526,31 @@ impl ExperienceOrb {
|
||||
self.age < 6000
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
|
||||
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
|
||||
/// adding their counts and keeping the smaller age.
|
||||
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
|
||||
let mut removed = vec![false; orbs.len()];
|
||||
for i in 0..orbs.len() {
|
||||
if removed[i] || orbs[i].ticks % 20 != 1 {
|
||||
continue;
|
||||
}
|
||||
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
|
||||
for j in 0..orbs.len() {
|
||||
if j != i
|
||||
&& !removed[j]
|
||||
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
|
||||
&& orbs[j].bounds().intersects(area)
|
||||
{
|
||||
orbs[i].count += orbs[j].count;
|
||||
orbs[i].age = orbs[i].age.min(orbs[j].age);
|
||||
removed[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut removed = removed.into_iter();
|
||||
orbs.retain(|_| !removed.next().unwrap_or(false));
|
||||
}
|
||||
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Experience {
|
||||
@@ -591,6 +648,57 @@ impl Painting {
|
||||
)
|
||||
}
|
||||
}
|
||||
/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when
|
||||
/// the name is unknown.
|
||||
pub fn dye_index(name: &str) -> u8 {
|
||||
crate::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|n| *n == name)
|
||||
.unwrap_or(0) as u8
|
||||
}
|
||||
/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow
|
||||
/// that seats a player. It sits on the top face of a block, at the clicked
|
||||
/// height, and is coloured by its `DyeColor`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cushion {
|
||||
pub id: u32,
|
||||
/// `Entity.position`: the bottom centre of the cushion.
|
||||
pub position: Vec3,
|
||||
/// `BlockAttachedEntity.pos` (the saved `block_pos`).
|
||||
pub pos: [i32; 3],
|
||||
pub yaw: f32,
|
||||
/// `DyeColor` ordinal: white 0, orange 1, ..., black 15.
|
||||
pub color: u8,
|
||||
/// BlockAttachedEntity.ticksSinceLastCheck.
|
||||
pub check_interval: u32,
|
||||
}
|
||||
impl Cushion {
|
||||
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
|
||||
pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
|
||||
let p = self.position;
|
||||
(
|
||||
[p.x - 0.5, p.y, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 0.25, p.z + 0.5],
|
||||
)
|
||||
}
|
||||
/// The `DyeColor` name of the cushion.
|
||||
pub fn color_name(&self) -> &'static str {
|
||||
crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))]
|
||||
}
|
||||
/// `Cushion.getCushionItemStackWithData`: the matching coloured item.
|
||||
pub fn item(&self) -> String {
|
||||
format!("minecraft:{}_cushion", self.color_name())
|
||||
}
|
||||
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
|
||||
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
|
||||
pub fn seat(&self) -> Vec3 {
|
||||
Vec3::new(self.position.x, self.position.y - 0.35, self.position.z)
|
||||
}
|
||||
/// `Entity.getDismountLocationForPassenger`: on top of the cushion.
|
||||
pub fn dismount(&self) -> Vec3 {
|
||||
Vec3::new(self.position.x, self.position.y + 0.25, self.position.z)
|
||||
}
|
||||
}
|
||||
/// An item frame or glow item frame (`ItemFrame`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemFrame {
|
||||
|
||||
@@ -166,12 +166,23 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d >= end { return 1.0; }
|
||||
return (d - start) / (end - start);
|
||||
}
|
||||
// CRUMBLING_UV_BEGIN
|
||||
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
|
||||
let size = vec2<f32>(dimensions);
|
||||
// Undo stage_light's 16-bit quantization before adding local texels. Otherwise
|
||||
// different stages can fall on opposite sides of a nearest-sampling boundary.
|
||||
let origin = round(packed_origin * size);
|
||||
let extent = max(round(sprite_size * size), vec2(1.0));
|
||||
let local = min(floor(fract(uv) * extent), extent - vec2(1.0));
|
||||
return (origin + local + vec2(0.5)) / size;
|
||||
}
|
||||
// CRUMBLING_UV_END
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
if in.overlay == 11u || in.overlay == 12u {
|
||||
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
|
||||
uv = in.wrap + fract(in.uv) * globals.crumbling_sprite.zw;
|
||||
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
|
||||
}
|
||||
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
|
||||
if in.overlay == 9u || in.overlay == 10u {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! survive a Horizon save.
|
||||
use crate::{
|
||||
arrows::Pickup,
|
||||
entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
mobs::{Mob, MobType, Who},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
save::{
|
||||
@@ -265,6 +265,19 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
Tag::Byte(i8::from(m.extra.flag2)),
|
||||
);
|
||||
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
|
||||
if id == "minecraft:piglin" {
|
||||
// InventoryCarrier.writeInventoryToTag.
|
||||
map.insert(
|
||||
"Inventory".into(),
|
||||
Tag::List(
|
||||
m.extra
|
||||
.inventory
|
||||
.iter()
|
||||
.map(crate::save::item_to_nbt)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
@@ -357,6 +370,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"minecraft:bat" => {
|
||||
map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag)));
|
||||
}
|
||||
"minecraft:turtle" => {
|
||||
map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag)));
|
||||
if let Some(home) = m.extra.home {
|
||||
map.insert("home_pos".into(), Tag::IntArray(home.to_vec()));
|
||||
}
|
||||
}
|
||||
"minecraft:creaking" => {
|
||||
// home_pos: the heart it is bound to.
|
||||
if let Some(home) = m.extra.home {
|
||||
@@ -482,7 +501,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"minecraft:zombie" => {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
for (key, value) in [
|
||||
("CanBreakDoors", Tag::Byte(0)),
|
||||
("DrownedConversionTime", Tag::Int(-1)),
|
||||
("InWaterTime", Tag::Int(-1)),
|
||||
] {
|
||||
@@ -507,6 +525,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Zombie.addAdditionalSaveData: CanBreakDoors.
|
||||
if crate::mobs::doors::is_zombie(m.species()) {
|
||||
map.insert(
|
||||
"CanBreakDoors".into(),
|
||||
Tag::Byte(i8::from(m.extra.can_break_doors)),
|
||||
);
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -706,6 +731,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
) {
|
||||
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
|
||||
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
|
||||
if m.species() == crate::mobs::Species::Piglin
|
||||
&& let Some(Tag::List(items)) = tag.get("Inventory")
|
||||
{
|
||||
// InventoryCarrier.readInventoryFromTag (eight slots).
|
||||
m.extra.inventory = items
|
||||
.iter()
|
||||
.filter_map(crate::save::item_from_nbt)
|
||||
.take(8)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -847,6 +882,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
{
|
||||
crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Turtle {
|
||||
// Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`.
|
||||
m.extra.flag = flag(tag, "has_egg");
|
||||
m.extra.home = Some(match tag.get("home_pos") {
|
||||
Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]],
|
||||
_ => [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
});
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wither {
|
||||
m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32;
|
||||
}
|
||||
@@ -925,6 +972,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
|
||||
crate::mobs::finalize::set_baby(&mut m, true);
|
||||
}
|
||||
// Zombie.readAdditionalSaveData: setCanBreakDoors.
|
||||
if crate::mobs::doors::is_zombie(m.species()) {
|
||||
crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors"));
|
||||
}
|
||||
crate::mobs::raider::read_nbt(&mut m, tag);
|
||||
crate::mobs::riders::read_nbt(&mut m, tag);
|
||||
// A mob saved mid-death finishes dying; one saved dead is dropped.
|
||||
@@ -1043,18 +1094,18 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
("Health", Tag::Short(5)),
|
||||
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
|
||||
("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)),
|
||||
("Count", Tag::Int(1)),
|
||||
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// One Horizon orb per counted orb (vanilla merges orbs of equal value).
|
||||
/// One orb with its merged `Count`.
|
||||
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
let Some(position) = position(tag) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize;
|
||||
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
|
||||
let orb = ExperienceOrb {
|
||||
id: 0,
|
||||
position,
|
||||
@@ -1063,8 +1114,10 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
value: number(tag.get("Value")).unwrap_or(1).max(1) as u32,
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
count,
|
||||
ticks: 0,
|
||||
};
|
||||
vec![orb; count]
|
||||
vec![orb]
|
||||
}
|
||||
|
||||
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
|
||||
@@ -1313,6 +1366,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
|
||||
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
|
||||
Some((pos, facing, text(tag.get("variant"))?))
|
||||
}
|
||||
/// Cushion.addAdditionalSaveData: the block it sits on and its colour.
|
||||
pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: "minecraft:cushion",
|
||||
uuid,
|
||||
position: c.position,
|
||||
motion: Vec3::ZERO,
|
||||
rotation: [c.yaw, 0.0],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec()));
|
||||
put(&mut tag, "color", string(c.color_name()));
|
||||
tag
|
||||
}
|
||||
/// The position, block position, yaw and colour of a saved cushion.
|
||||
pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option<Cushion> {
|
||||
let position = position(tag)?;
|
||||
let pos = match tag.get("block_pos") {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
|
||||
_ => [
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
],
|
||||
};
|
||||
let yaw = match tag.get("Rotation") {
|
||||
Some(Tag::List(l)) => l
|
||||
.first()
|
||||
.and_then(|v| crate::save::float(Some(v)))
|
||||
.unwrap_or(0.0) as f32,
|
||||
_ => 0.0,
|
||||
};
|
||||
let color =
|
||||
crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into()));
|
||||
Some(Cushion {
|
||||
id,
|
||||
position,
|
||||
pos,
|
||||
yaw,
|
||||
color,
|
||||
check_interval: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cushion_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() {
|
||||
let cushion = Cushion {
|
||||
id: 7,
|
||||
position: Vec3::new(-1.5, 64.5, 3.5),
|
||||
pos: [-2, 64, 3],
|
||||
yaw: 270.0,
|
||||
color: 14,
|
||||
check_interval: 55,
|
||||
};
|
||||
let raw = Tag::compound([("CustomName", string("Seat"))]);
|
||||
let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw));
|
||||
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat"));
|
||||
let loaded = cushion_from_nbt(&tag, 8).unwrap();
|
||||
assert_eq!(loaded.id, 8);
|
||||
assert_eq!(loaded.position, cushion.position);
|
||||
assert_eq!(loaded.pos, cushion.pos);
|
||||
assert_eq!(loaded.yaw, cushion.yaw);
|
||||
assert_eq!(loaded.color_name(), "red");
|
||||
assert_eq!(loaded.check_interval, 0);
|
||||
assert_eq!(loaded.bounds(), cushion.bounds());
|
||||
}
|
||||
}
|
||||
/// ItemFrame.addAdditionalSaveData.
|
||||
pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let (lo, hi) = f.bounds();
|
||||
@@ -1840,7 +1966,7 @@ mod tests {
|
||||
assert_eq!(number(tag.get("Health")), Some(5));
|
||||
}
|
||||
#[test]
|
||||
fn orbs_expand_their_count() {
|
||||
fn orbs_keep_their_count() {
|
||||
let o = ExperienceOrb {
|
||||
id: 0,
|
||||
position: Vec3::new(1.0, 2.0, 3.0),
|
||||
@@ -1849,12 +1975,14 @@ mod tests {
|
||||
value: 7,
|
||||
age: 3,
|
||||
on_ground: false,
|
||||
count: 3,
|
||||
ticks: 0,
|
||||
};
|
||||
let mut tag = orb_to_nbt(&o, [0; 4]);
|
||||
put(&mut tag, "Count", Tag::Int(3));
|
||||
let tag = orb_to_nbt(&o, [0; 4]);
|
||||
let back = orbs_from_nbt(&tag);
|
||||
assert_eq!(back.len(), 3);
|
||||
assert_eq!(back.len(), 1);
|
||||
assert_eq!(back[0].value, 7);
|
||||
assert_eq!(back[0].count, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1919,12 +2047,39 @@ fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
|
||||
}
|
||||
}
|
||||
const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"];
|
||||
/// `Salmon.Variant` names, by id.
|
||||
const SALMON_SIZES: [&str; 3] = ["small", "medium", "large"];
|
||||
/// `TropicalFish.Pattern` names in enum order: the six small patterns, then
|
||||
/// the six large ones (the order of `extra.variant`).
|
||||
const TROPICAL_PATTERNS: [&str; 12] = [
|
||||
"kob",
|
||||
"sunstreak",
|
||||
"snooper",
|
||||
"dasher",
|
||||
"brinely",
|
||||
"spotty",
|
||||
"flopper",
|
||||
"stripey",
|
||||
"glitter",
|
||||
"blockfish",
|
||||
"betty",
|
||||
"clayfish",
|
||||
];
|
||||
/// `Bucketable.saveDefaultDataToBucketTag`, with the age an axolotl and a
|
||||
/// tadpole add (`saveToBucketTag`).
|
||||
pub fn bucket_data(m: &Mob) -> Tag {
|
||||
let mut data = Tag::compound([
|
||||
("Age", Tag::Int(m.extra.age)),
|
||||
("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))),
|
||||
("Health", Tag::Float(m.health.to_bits())),
|
||||
]);
|
||||
let mut data = Tag::compound([("Health", Tag::Float(m.health.to_bits()))]);
|
||||
if matches!(
|
||||
m.species(),
|
||||
crate::mobs::Species::Axolotl | crate::mobs::Species::Tadpole
|
||||
) {
|
||||
put(&mut data, "Age", Tag::Int(m.extra.age));
|
||||
put(
|
||||
&mut data,
|
||||
"AgeLocked",
|
||||
Tag::Byte(i8::from(m.extra.age_locked)),
|
||||
);
|
||||
}
|
||||
for (key, on) in [
|
||||
("NoAI", m.no_ai),
|
||||
("Silent", m.silent),
|
||||
@@ -1954,6 +2109,29 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
.unwrap_or(&"lucy");
|
||||
data.set_other("minecraft:axolotl/variant", Some(string(variant)));
|
||||
}
|
||||
// Salmon.saveToBucketTag: the size.
|
||||
if m.species() == crate::mobs::Species::Salmon {
|
||||
let size = SALMON_SIZES
|
||||
.get(usize::from(m.extra.variant))
|
||||
.unwrap_or(&"medium");
|
||||
data.set_other("minecraft:salmon/size", Some(string(size)));
|
||||
}
|
||||
// TropicalFish.saveToBucketTag: the pattern and both colours.
|
||||
if m.species() == crate::mobs::Species::TropicalFish {
|
||||
let dye = |i: u8| crate::mobs::render::DYE_NAMES[usize::from(i) & 15];
|
||||
let pattern = TROPICAL_PATTERNS
|
||||
.get(usize::from(m.extra.variant))
|
||||
.unwrap_or(&"kob");
|
||||
data.set_other("minecraft:tropical_fish/pattern", Some(string(pattern)));
|
||||
data.set_other(
|
||||
"minecraft:tropical_fish/base_color",
|
||||
Some(string(dye(m.extra.variant2))),
|
||||
);
|
||||
data.set_other(
|
||||
"minecraft:tropical_fish/pattern_color",
|
||||
Some(string(dye(m.extra.variant3))),
|
||||
);
|
||||
}
|
||||
data
|
||||
}
|
||||
pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
@@ -2004,6 +2182,39 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
{
|
||||
m.extra.variant = id as u8;
|
||||
}
|
||||
// Salmon.applyImplicitComponents: the size.
|
||||
if m.species() == crate::mobs::Species::Salmon
|
||||
&& let Some(name) = text(components.other("minecraft:salmon/size"))
|
||||
&& let Some(id) = SALMON_SIZES
|
||||
.iter()
|
||||
.position(|&v| v == name.trim_start_matches("minecraft:"))
|
||||
{
|
||||
m.extra.variant = id as u8;
|
||||
}
|
||||
// TropicalFish.applyImplicitComponent: the pattern and both colours.
|
||||
if m.species() == crate::mobs::Species::TropicalFish {
|
||||
let name = |key| text(components.other(key));
|
||||
let dye = |key| {
|
||||
name(key).and_then(|n| {
|
||||
crate::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|&v| v == n.trim_start_matches("minecraft:"))
|
||||
})
|
||||
};
|
||||
if let Some(id) = name("minecraft:tropical_fish/pattern").and_then(|n| {
|
||||
TROPICAL_PATTERNS
|
||||
.iter()
|
||||
.position(|&v| v == n.trim_start_matches("minecraft:"))
|
||||
}) {
|
||||
m.extra.variant = id as u8;
|
||||
}
|
||||
if let Some(id) = dye("minecraft:tropical_fish/base_color") {
|
||||
m.extra.variant2 = id as u8;
|
||||
}
|
||||
if let Some(id) = dye("minecraft:tropical_fish/pattern_color") {
|
||||
m.extra.variant3 = id as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1201,6 +1201,31 @@ mod tests {
|
||||
assert!(!font.take_patches().is_empty());
|
||||
}
|
||||
|
||||
/// A renderer rebuilt over the same font (the loading screen's handoff)
|
||||
/// makes its atlas texture from the textures alone, so the glyphs it had
|
||||
/// rasterized into the previous texture must be placed into the new one.
|
||||
#[test]
|
||||
fn a_rebuilt_atlas_gets_its_glyphs_again() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
let default = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"rebuild",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/minecraft/textures/font/a.png", &sheet),
|
||||
],
|
||||
);
|
||||
let font = load(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let first = font.glyph('a');
|
||||
assert_eq!(font.take_patches().len(), 1);
|
||||
font.reset_cache();
|
||||
// Nothing is left over: drawing the character again places its pixels.
|
||||
assert!(font.take_patches().is_empty());
|
||||
assert_eq!(font.glyph('a').advance, first.advance);
|
||||
assert_eq!(font.take_patches().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_filters_follow_the_font_options() {
|
||||
let sheet = png(8, 8, |x, _| x < 4);
|
||||
|
||||
+653
-149
File diff suppressed because it is too large.
Load diff
@@ -453,6 +453,11 @@ impl Game<'_> {
|
||||
}
|
||||
// ArmorStand.swapItem.
|
||||
match held {
|
||||
// A player with infinite materials puts a copy of one on an empty
|
||||
// slot and keeps the stack.
|
||||
Some(h) if self.creative && current.is_none() => {
|
||||
self.armor_stands[i].equipment[slot] = Some(ItemStack { count: 1, ..h });
|
||||
}
|
||||
Some(h) if h.count > 1 => {
|
||||
if current.is_some() {
|
||||
return true;
|
||||
|
||||
@@ -515,8 +515,7 @@ impl Game<'_> {
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = stack_tip {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
//! [`Game::block_anim`] and [`Game::chest_lid`].
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
|
||||
block_entity_anim::{
|
||||
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
|
||||
},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
containers::BlockEntity,
|
||||
effects::EffectInstance,
|
||||
mobs::{Damage, DamageKind},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save::number,
|
||||
skins,
|
||||
@@ -16,6 +20,7 @@ use horizon_client::{
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -218,9 +223,12 @@ impl Game<'_> {
|
||||
/// The client tickers of the loaded animated block entities, once per
|
||||
/// client tick (`ClientLevel.tickBlockEntities`).
|
||||
pub(super) fn tick_block_entity_anims(&mut self) {
|
||||
// A server has no window, but its gateways and conduits still run
|
||||
// their server tickers; a client of one only animates what it is sent.
|
||||
if self.remote.is_none() {
|
||||
self.tick_conduit_servers();
|
||||
}
|
||||
let Some(r) = &self.renderer else {
|
||||
// A server has no window, but its gateways still run their
|
||||
// server ticker.
|
||||
if self.remote.is_none() {
|
||||
self.tick_gateway_servers();
|
||||
}
|
||||
@@ -337,6 +345,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Ticker::Conduit => {
|
||||
let target = self.conduit_target(pos);
|
||||
let world = &self.world;
|
||||
let facts = self.res.facts();
|
||||
let registry = self.pack.assets.registry;
|
||||
@@ -356,7 +365,7 @@ impl Game<'_> {
|
||||
time,
|
||||
is_water,
|
||||
is_frame,
|
||||
None,
|
||||
target,
|
||||
self.particles.random(),
|
||||
);
|
||||
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
|
||||
@@ -383,6 +392,248 @@ impl Game<'_> {
|
||||
}
|
||||
self.be_anims.present = positions;
|
||||
}
|
||||
/// `ConduitBlockEntity.serverTick` of every conduit in the chunks that
|
||||
/// tick blocks, with or without a renderer. The conduits are looked for
|
||||
/// when the shape is checked, every 40 ticks (they have no block entity
|
||||
/// of their own here).
|
||||
fn tick_conduit_servers(&mut self) {
|
||||
if self.world.game_time % 40 == 0 {
|
||||
self.find_conduits();
|
||||
}
|
||||
let mut positions: Vec<[i32; 3]> = self.be_anims.conduit_servers.keys().copied().collect();
|
||||
positions.sort_unstable();
|
||||
for pos in positions {
|
||||
self.tick_conduit_server(pos);
|
||||
}
|
||||
}
|
||||
/// Sets `be_anims.conduit_servers` to the conduits in the
|
||||
/// chunks that tick blocks, keeping the state of those already known.
|
||||
fn find_conduits(&mut self) {
|
||||
let levels = self.tickets.levels();
|
||||
let kinds = &self.be_anims.kinds;
|
||||
let is_conduit = |id: u32| matches!(kinds.get(id as usize), Some(Some(Ticker::Conduit)));
|
||||
let mut found = Vec::new();
|
||||
for (&(cx, cz), chunk) in &self.world.chunks {
|
||||
if !levels.block_ticking_at(cx * 16, cz * 16) {
|
||||
continue;
|
||||
}
|
||||
for (si, section) in chunk.sections.iter().enumerate() {
|
||||
if !section.blocks.maybe_has(is_conduit) {
|
||||
continue;
|
||||
}
|
||||
for i in 0..4096 {
|
||||
if is_conduit(section.get(i)) {
|
||||
found.push([
|
||||
cx * 16 + (i & 15) as i32,
|
||||
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
|
||||
cz * 16 + ((i >> 4) & 15) as i32,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut old = std::mem::take(&mut self.be_anims.conduit_servers);
|
||||
for pos in found {
|
||||
let known = old.remove(&pos).unwrap_or_default();
|
||||
self.be_anims.conduit_servers.insert(pos, known);
|
||||
}
|
||||
}
|
||||
/// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk
|
||||
/// that ticks blocks: the shape check every 40 ticks with its sounds, the
|
||||
/// power for players in range, the hurt for an enemy, then the ambient
|
||||
/// sounds.
|
||||
fn tick_conduit_server(&mut self, pos: [i32; 3]) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
if self.be_anims.ticker(state) != Some(Ticker::Conduit) {
|
||||
// Broken since the chunks were searched.
|
||||
self.be_anims.conduit_servers.remove(&pos);
|
||||
return;
|
||||
}
|
||||
if !self.tickets.levels().block_ticking_at(pos[0], pos[2])
|
||||
|| !self.border.contains(f64::from(pos[0]), f64::from(pos[2]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let time = self.world.game_time;
|
||||
let mut conduit = self
|
||||
.be_anims
|
||||
.conduit_servers
|
||||
.remove(&pos)
|
||||
.unwrap_or_default();
|
||||
let check = {
|
||||
let world = &self.world;
|
||||
let facts = self.res.facts();
|
||||
let registry = self.pack.assets.registry;
|
||||
let frames = self
|
||||
.res
|
||||
.items()
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block");
|
||||
// Level.isWaterAt: the fluid at the position is water.
|
||||
let is_water = |p: [i32; 3]| {
|
||||
facts.get(world.block(p[0], p[1], p[2])).fluid
|
||||
== horizon_client::blocks::Fluid::Water
|
||||
};
|
||||
let is_frame = |p: [i32; 3]| {
|
||||
registry
|
||||
.state(world.block(p[0], p[1], p[2]))
|
||||
.is_some_and(|s| frames.is_some_and(|f| f.contains(&s.name)))
|
||||
};
|
||||
conduit.check(pos, time, is_water, is_frame)
|
||||
};
|
||||
let centre = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if let Some(check) = check {
|
||||
if let Some(active) = check.toggled {
|
||||
let sound = if active {
|
||||
"minecraft:block.conduit.activate"
|
||||
} else {
|
||||
"minecraft:block.conduit.deactivate"
|
||||
};
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
}
|
||||
if let Some(frames) = check.frames {
|
||||
self.conduit_apply_effects(pos, frames);
|
||||
self.conduit_attack(&mut conduit, pos, frames >= 42);
|
||||
}
|
||||
}
|
||||
let (ambient, short) = conduit.ambient(time, &mut self.world.random);
|
||||
if ambient {
|
||||
self.audio
|
||||
.play("minecraft:block.conduit.ambient", Some(centre), 1.0, 1.0);
|
||||
}
|
||||
if short {
|
||||
self.audio.play(
|
||||
"minecraft:block.conduit.ambient_short",
|
||||
Some(centre),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
self.be_anims.conduit_servers.insert(pos, conduit);
|
||||
}
|
||||
/// `ConduitBlockEntity.applyEffects`: conduit power for every player
|
||||
/// in water or rain within `frames / 7 * 16` blocks.
|
||||
fn conduit_apply_effects(&mut self, pos: [i32; 3], frames: usize) {
|
||||
let range = (frames / 7 * 16) as i64;
|
||||
self.for_each_player(|g| {
|
||||
let Some(p) = g.player.as_ref().filter(|p| !p.health.dead_or_dying()) else {
|
||||
return;
|
||||
};
|
||||
if g.spectator() {
|
||||
return;
|
||||
}
|
||||
let at = p.position;
|
||||
let in_water = p.fluids.was_touching_water;
|
||||
let top = at.y + f64::from(p.dimensions.height);
|
||||
// BlockPos.closerThan: the squared distance under range squared.
|
||||
let d = [at.x, at.y, at.z]
|
||||
.map(|v| v.floor() as i64)
|
||||
.iter()
|
||||
.zip(pos)
|
||||
.map(|(a, b)| (a - i64::from(b)).pow(2))
|
||||
.sum::<i64>();
|
||||
if d >= range * range || !(in_water || g.in_rain(at, top)) {
|
||||
return;
|
||||
}
|
||||
let power = EffectInstance {
|
||||
ambient: true,
|
||||
..EffectInstance::new("conduit_power", 260, 0)
|
||||
};
|
||||
g.potions.effects.add(power);
|
||||
});
|
||||
}
|
||||
/// `Entity.isInRain`: rain at the block position or at the top of the box.
|
||||
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
|
||||
let p = BlockPos::new(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let up = BlockPos::new(p.x, top.floor() as i32, p.z);
|
||||
self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up))
|
||||
}
|
||||
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
|
||||
/// (42 frame blocks) its target stays until it dies or leaves the 8
|
||||
/// blocks, else a random enemy in water or rain is chosen; the target
|
||||
/// takes 4 magic damage.
|
||||
fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) {
|
||||
conduit.target = match conduit.target {
|
||||
_ if !hunting => None,
|
||||
None => self.conduit_new_target(pos),
|
||||
Some(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && !m.removed)
|
||||
.filter(|m| {
|
||||
let at = [m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i64);
|
||||
m.alive()
|
||||
&& at
|
||||
.iter()
|
||||
.zip(pos)
|
||||
.map(|(a, b)| (a - i64::from(b)).pow(2))
|
||||
.sum::<i64>()
|
||||
< 64
|
||||
})
|
||||
.map(|m| m.id),
|
||||
};
|
||||
let Some(m) = conduit
|
||||
.target
|
||||
.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
m.damage(4.0, Damage::of(DamageKind::Magic));
|
||||
self.audio
|
||||
.play("minecraft:block.conduit.attack.target", Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// `ConduitBlockEntity.selectNewTarget`: one of the enemies in water or
|
||||
/// rain whose box meets the conduit's block inflated by 8.
|
||||
fn conduit_new_target(&mut self, pos: [i32; 3]) -> Option<u32> {
|
||||
let [x, y, z] = pos.map(f64::from);
|
||||
let area = Aabb::new(
|
||||
Vec3::new(x - 8.0, y - 8.0, z - 8.0),
|
||||
Vec3::new(x + 9.0, y + 9.0, z + 9.0),
|
||||
);
|
||||
let near: Vec<(u32, bool, Vec3, f64)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.hostile() && !m.removed && m.bounds().intersects(area))
|
||||
.map(|m| {
|
||||
let top = m.position.y + f64::from(m.height());
|
||||
(m.id, m.fluids.in_water, m.position, top)
|
||||
})
|
||||
.collect();
|
||||
let candidates: Vec<u32> = near
|
||||
.into_iter()
|
||||
.filter(|&(_, in_water, at, top)| in_water || self.in_rain(at, top))
|
||||
.map(|(id, ..)| id)
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(candidates.len() as i32)
|
||||
.unwrap_or(0);
|
||||
Some(candidates[pick as usize])
|
||||
}
|
||||
/// The conduit's `destroyTarget` as the client draws it.
|
||||
fn conduit_target(&self, pos: [i32; 3]) -> Option<ConduitTarget> {
|
||||
let id = self.be_anims.conduit_servers.get(&pos)?.target?;
|
||||
let m = self.mobs.iter().find(|m| m.id == id && !m.removed)?;
|
||||
Some(ConduitTarget {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
eye_y: m.eye_y(),
|
||||
width: m.width(),
|
||||
height: m.height(),
|
||||
})
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
|
||||
/// gateways a server keeps a copy of, in block-ticking chunks
|
||||
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
block_entities::{Pos, Ticking},
|
||||
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -56,6 +57,8 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
|
||||
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +95,11 @@ impl Game<'_> {
|
||||
| "minecraft:vault"
|
||||
| "minecraft:beehive"
|
||||
| "minecraft:bee_nest"
|
||||
| "minecraft:brewing_stand" => Ticking::Yes,
|
||||
| "minecraft:brewing_stand"
|
||||
| "minecraft:daylight_detector"
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
"minecraft:jukebox" => {
|
||||
let record = registry
|
||||
.state(state)
|
||||
@@ -113,6 +120,16 @@ impl Game<'_> {
|
||||
let levels = self.tickets.levels();
|
||||
move |x, z| levels.block_ticking_at(x, z)
|
||||
}
|
||||
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
|
||||
fn tick_daylight_detector(&mut self, pos: Pos) {
|
||||
// It updates every 20 game ticks (the behaviour checks this too).
|
||||
if self.world.game_time % 20 != 0 {
|
||||
return;
|
||||
}
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
self.with_level(|b, level, ticks| b.tick_daylight_detector(level, ticks, at));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and
|
||||
/// its block's `lit` following it.
|
||||
fn tick_furnace(&mut self, pos: Pos) -> Result<()> {
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(super) struct Boat {
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -587,6 +587,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
if self.tick_cushion_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
|
||||
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
|
||||
return Ok(());
|
||||
|
||||
@@ -17,7 +17,6 @@ use horizon_client::{
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -27,8 +26,24 @@ const BLACK: u32 = 0xFF00_0000;
|
||||
const PAGE_CHARS: usize = 1024;
|
||||
const MAX_PAGES: usize = 100;
|
||||
const LINES: usize = 14;
|
||||
/// `BookSignScreen`'s title box.
|
||||
const BOOK_TITLE: horizon_client::clipboard::FieldId = ("book.title", 0);
|
||||
|
||||
fn book_title_box() -> ui::EditBox {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
|
||||
edit.bordered = false;
|
||||
edit.max_length = 15;
|
||||
edit.text_color = BLACK;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
fn book_page_box() -> ui::EditBox {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
|
||||
edit.bordered = false;
|
||||
edit.multiline = true;
|
||||
edit.max_length = PAGE_CHARS;
|
||||
edit.text_color = BLACK;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
|
||||
pub(super) enum BookScreen {
|
||||
Edit {
|
||||
@@ -38,7 +53,10 @@ pub(super) enum BookScreen {
|
||||
page: usize,
|
||||
signing: bool,
|
||||
title: String,
|
||||
opened: Instant,
|
||||
/// BookSignScreen's title box (`EditBox` of 15).
|
||||
title_edit: ui::EditBox,
|
||||
/// BookEditScreen's page (`MultiLineEditBox`).
|
||||
page_edit: ui::EditBox,
|
||||
},
|
||||
View {
|
||||
pages: Vec<String>,
|
||||
@@ -84,13 +102,16 @@ impl Game<'_> {
|
||||
if pages.is_empty() {
|
||||
pages.push(String::new());
|
||||
}
|
||||
let mut page_edit = book_page_box();
|
||||
page_edit.set_value(&pages[0]);
|
||||
BookScreen::Edit {
|
||||
slot: self.held_slot(),
|
||||
pages,
|
||||
page: 0,
|
||||
signing: false,
|
||||
title: String::new(),
|
||||
opened: Instant::now(),
|
||||
title_edit: book_title_box(),
|
||||
page_edit,
|
||||
}
|
||||
}
|
||||
"minecraft:written_book" => BookScreen::View {
|
||||
@@ -239,34 +260,24 @@ impl Game<'_> {
|
||||
Some(KeyCode::PageDown) => self.turn_page(true),
|
||||
_ => {}
|
||||
},
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => match key {
|
||||
Some(BookScreen::Edit { signing: true, .. }) => match key {
|
||||
Some(KeyCode::Escape) => {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Backspace) => {
|
||||
if !self.text_selection.take(BOOK_TITLE, title) {
|
||||
title.pop();
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
if !title.trim().is_empty() {
|
||||
let blank = self.book.as_ref().is_some_and(
|
||||
|b| matches!(b, BookScreen::Edit { title, .. } if title.trim().is_empty()),
|
||||
);
|
||||
if !blank {
|
||||
self.save_book(true);
|
||||
}
|
||||
}
|
||||
Some(key) if self.book_title_key(key) => {}
|
||||
_ => {
|
||||
if let Some(typed) = text {
|
||||
self.text_selection.take(BOOK_TITLE, title);
|
||||
for c in typed.chars().filter(|c| !c.is_control()) {
|
||||
if title.chars().count() < 15 {
|
||||
title.push(c);
|
||||
}
|
||||
}
|
||||
self.book_title_insert(typed);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -274,24 +285,13 @@ impl Game<'_> {
|
||||
Some(KeyCode::Escape) => self.save_book(false),
|
||||
Some(KeyCode::PageUp) => self.turn_page(false),
|
||||
Some(KeyCode::PageDown) => self.turn_page(true),
|
||||
Some(KeyCode::Backspace) => {
|
||||
if !self.take_page_selection() {
|
||||
self.edit_page(|p| {
|
||||
p.pop();
|
||||
})
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
self.take_page_selection();
|
||||
self.type_into_page("\n")
|
||||
}
|
||||
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
|
||||
Some(KeyCode::ArrowDown) => self.book_move_line(1),
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
|
||||
Some(key) if self.book_page_key(key) => {}
|
||||
_ => {
|
||||
if let Some(typed) = text {
|
||||
let typed: String = typed.chars().filter(|c| !c.is_control()).collect();
|
||||
if !typed.is_empty() {
|
||||
self.take_page_selection();
|
||||
}
|
||||
self.type_into_page(&typed);
|
||||
self.page_insert(typed);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -302,99 +302,258 @@ impl Game<'_> {
|
||||
pub(super) fn book_type(&mut self, text: &str) {
|
||||
let text = text.replace("\\n", "\n");
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => {
|
||||
for c in text.chars().filter(|c| !c.is_control()) {
|
||||
if title.chars().count() < 15 {
|
||||
title.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { .. }) => self.type_into_page(&text),
|
||||
Some(BookScreen::Edit { signing: true, .. }) => self.book_title_insert(&text),
|
||||
Some(BookScreen::Edit { .. }) => self.page_insert(&text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Before typing on a page: Select All's selection is cleared (true).
|
||||
fn take_page_selection(&mut self) -> bool {
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => self
|
||||
.text_selection
|
||||
.take(("book.page", *page), &mut pages[*page]),
|
||||
_ => false,
|
||||
/// `BookSignScreen.keyPressed` on the title box.
|
||||
fn book_title_key(&mut self, key: KeyCode) -> bool {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !title_edit.key(key, ctrl, shift) {
|
||||
return false;
|
||||
}
|
||||
title.clone_from(&title_edit.value);
|
||||
true
|
||||
}
|
||||
/// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else
|
||||
/// the page (`MultilineTextField.insertText`: line breaks kept, cut to
|
||||
/// the 1024 characters left, rejected when it overflows the 14 lines).
|
||||
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
let font = &self.gui.atlas.font;
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => {
|
||||
Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
BOOK_TITLE,
|
||||
title,
|
||||
shortcut,
|
||||
15,
|
||||
);
|
||||
}
|
||||
Some(BookScreen::Edit { pages, page, .. }) => {
|
||||
// truncateInsertionText counts the whole value, selection included.
|
||||
let room = PAGE_CHARS.saturating_sub(pages[*page].chars().count());
|
||||
self.text_selection.apply(
|
||||
("book.page", *page),
|
||||
&mut pages[*page],
|
||||
shortcut,
|
||||
&mut self.clipboard,
|
||||
|value, pasted| {
|
||||
let text: String = horizon_client::clipboard::filter_text(pasted, true)
|
||||
.chars()
|
||||
.take(room)
|
||||
.collect();
|
||||
let next = format!("{value}{text}");
|
||||
font.split(&Text::literal(&next), 114.0).len() <= LINES && {
|
||||
*value = next;
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn edit_page(&mut self, f: impl FnOnce(&mut String)) {
|
||||
if let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_mut() {
|
||||
f(&mut pages[*page]);
|
||||
}
|
||||
}
|
||||
/// MultiLineEditBox: characters that keep the page within 14 lines and 1024 characters.
|
||||
fn type_into_page(&mut self, typed: &str) {
|
||||
let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_ref() else {
|
||||
fn book_title_insert(&mut self, text: &str) {
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut text = pages[*page].clone();
|
||||
for c in typed.chars() {
|
||||
let mut next = text.clone();
|
||||
next.push(c);
|
||||
if next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES {
|
||||
text = next;
|
||||
title_edit.insert_text(text);
|
||||
title.clone_from(&title_edit.value);
|
||||
}
|
||||
/// `BookEditScreen.keyPressed` on the page, apart from the line moves.
|
||||
fn book_page_key(&mut self, key: KeyCode) -> bool {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => return false,
|
||||
};
|
||||
let handled = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.key(key, ctrl, shift)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if handled {
|
||||
self.page_keep(before);
|
||||
}
|
||||
handled
|
||||
}
|
||||
/// One character at a time, so a page stops at the last character that fits.
|
||||
fn page_insert(&mut self, text: &str) {
|
||||
for c in text.chars() {
|
||||
self.page_change(|edit| edit.insert_text(&c.to_string()));
|
||||
}
|
||||
}
|
||||
/// Ctrl+A/C/V/X: the title box while signing, else the page.
|
||||
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
let signing = matches!(self.book, Some(BookScreen::Edit { signing: true, .. }));
|
||||
if signing {
|
||||
if let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
{
|
||||
title_edit.shortcut(shortcut, &mut self.clipboard);
|
||||
title.clone_from(&title_edit.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.shortcut(shortcut, &mut self.clipboard);
|
||||
}
|
||||
self.page_keep(before);
|
||||
}
|
||||
/// Edit the page box, then keep it only within 14 lines and 1024 characters.
|
||||
fn page_change(&mut self, f: impl FnOnce(&mut ui::EditBox)) -> bool {
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => return false,
|
||||
};
|
||||
let ran = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
f(page_edit);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
ran && self.page_keep(before)
|
||||
}
|
||||
/// Write the page, or put the previous text back when it no longer fits.
|
||||
fn page_keep(&mut self, before: Option<(String, usize, usize)>) -> bool {
|
||||
let next = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => page_edit.value.clone(),
|
||||
_ => return false,
|
||||
};
|
||||
let ok = next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES;
|
||||
let Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if ok {
|
||||
pages[*page].clone_from(&page_edit.value);
|
||||
} else if let Some((value, cursor, highlight)) = before {
|
||||
page_edit.value = value;
|
||||
page_edit.cursor = cursor;
|
||||
page_edit.highlight = highlight;
|
||||
}
|
||||
ok
|
||||
}
|
||||
/// ArrowUp and ArrowDown move a visual line, keeping the column.
|
||||
fn book_move_line(&mut self, dir: isize) {
|
||||
let shift = self.has_shift_down();
|
||||
let (text, cursor) = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) => (pages[*page].clone(), page_edit.cursor),
|
||||
_ => return,
|
||||
};
|
||||
let rows = self.page_rows(&text);
|
||||
let (x, line) = self.caret_on(&text, &rows, cursor);
|
||||
let next = (line as isize + dir).clamp(0, rows.len() as isize - 1) as usize;
|
||||
let (start, end) = rows[next];
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let slice: String = chars[start..end].iter().collect();
|
||||
let pos = start + self.gui.atlas.font.fit(&slice, x);
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
|
||||
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
|
||||
let font = &self.gui.atlas.font;
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
let mut rows = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < chars.len() {
|
||||
let mut end = chars.len();
|
||||
let mut next = chars.len();
|
||||
let mut last_space = None;
|
||||
for i in start..chars.len() {
|
||||
if chars[i] == '\n' {
|
||||
end = i;
|
||||
next = i + 1;
|
||||
break;
|
||||
}
|
||||
if chars[i] == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
let slice: String = chars[start..=i].iter().collect();
|
||||
if font.width(&slice) > 114.0 {
|
||||
match last_space {
|
||||
Some(s) if s > start => {
|
||||
end = s;
|
||||
next = s + 1;
|
||||
}
|
||||
_ => {
|
||||
end = i.max(start + 1);
|
||||
next = end;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
rows.push((start, end));
|
||||
start = next;
|
||||
if start == chars.len() && chars.last() == Some(&'\n') {
|
||||
rows.push((chars.len(), chars.len()));
|
||||
}
|
||||
}
|
||||
self.edit_page(|p| *p = text);
|
||||
if rows.is_empty() {
|
||||
rows.push((0, 0));
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// GUI x of the caret and the row it sits on.
|
||||
fn caret_on(&self, page: &str, rows: &[(usize, usize)], cursor: usize) -> (f32, usize) {
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
for (i, &(start, end)) in rows.iter().enumerate() {
|
||||
if cursor <= end || i + 1 == rows.len() {
|
||||
let take = cursor.saturating_sub(start).min(end.saturating_sub(start));
|
||||
let prefix: String = chars.iter().skip(start).take(take).collect();
|
||||
return (self.gui.atlas.font.width(&prefix), i);
|
||||
}
|
||||
}
|
||||
(0.0, 0)
|
||||
}
|
||||
fn page_index_at(&self, page: &str, local_x: f32, local_y: f32) -> usize {
|
||||
let rows = self.page_rows(page);
|
||||
let line = (local_y / 9.0).floor().max(0.0) as usize;
|
||||
let line = line.min(rows.len().saturating_sub(1));
|
||||
let (start, end) = rows[line];
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
let slice: String = chars[start..end].iter().collect();
|
||||
start + self.gui.atlas.font.fit(&slice, local_x.max(0.0))
|
||||
}
|
||||
/// A click on the centred title (`EditBox.onClick`).
|
||||
fn book_title_click(&mut self, field_x: f32, mx: f32) {
|
||||
let shift = self.has_shift_down();
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let tx = field_x + ((114.0 - self.gui.atlas.font.width(title)) / 2.0).floor();
|
||||
let rel = (mx - tx).max(0.0);
|
||||
let pos = self.gui.atlas.font.fit(title, rel);
|
||||
title_edit.set_cursor(pos, shift);
|
||||
}
|
||||
/// A click on the open page.
|
||||
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
|
||||
let shift = self.has_shift_down();
|
||||
let text = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
|
||||
_ => return,
|
||||
};
|
||||
let pos = self.page_index_at(&text, mx - origin_x, my - origin_y);
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// The page buttons (`pageForward` adds a page while editing).
|
||||
fn turn_page(&mut self, forward: bool) {
|
||||
let mut lectern_page = None;
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => {
|
||||
Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) => {
|
||||
let before = *page;
|
||||
if forward {
|
||||
if *page + 1 >= pages.len() && pages.len() < MAX_PAGES {
|
||||
pages.push(String::new());
|
||||
@@ -405,6 +564,10 @@ impl Game<'_> {
|
||||
} else if *page > 0 {
|
||||
*page -= 1;
|
||||
}
|
||||
if *page != before {
|
||||
let text = pages[*page].clone();
|
||||
page_edit.set_value(&text);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
lectern: Some(key), ..
|
||||
@@ -764,6 +927,8 @@ impl Game<'_> {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
} else if inside(left + 36.0, 50.0, 114.0, 9.0) {
|
||||
self.book_title_click(left + 36.0, mx);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { page, .. }) => {
|
||||
@@ -780,6 +945,8 @@ impl Game<'_> {
|
||||
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
self.save_book(false);
|
||||
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
|
||||
self.book_page_click(left + 36.0, 32.0, mx, my);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
@@ -865,7 +1032,7 @@ impl Game<'_> {
|
||||
BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
opened,
|
||||
title_edit,
|
||||
..
|
||||
} => {
|
||||
let edit = tr("book.editTitle", "Enter Book Title:");
|
||||
@@ -881,19 +1048,35 @@ impl Game<'_> {
|
||||
// The title box: an unbordered, centred EditBox of 114 with
|
||||
// black text and no shadow; the value alone is centred, the
|
||||
// cursor follows it.
|
||||
let value = &title_edit.value;
|
||||
let x = left + 36.0;
|
||||
let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor();
|
||||
horizon_client::ui::append_field(
|
||||
batch,
|
||||
atlas,
|
||||
title,
|
||||
(tx, 50.0, x + 114.0 - tx),
|
||||
(BLACK, false),
|
||||
15,
|
||||
self.text_selection.covers(BOOK_TITLE, title),
|
||||
true,
|
||||
opened.elapsed().as_millis(),
|
||||
);
|
||||
let tx = x + ((114.0 - HudBatch::text_width(atlas, value)) / 2.0).floor();
|
||||
batch.text(atlas, value, tx, 50.0, BLACK, false);
|
||||
if title_edit.cursor != title_edit.highlight {
|
||||
let (a, b) = (
|
||||
title_edit.cursor.min(title_edit.highlight),
|
||||
title_edit.cursor.max(title_edit.highlight),
|
||||
);
|
||||
let pre: String = value.chars().take(a).collect();
|
||||
let sel: String = value.chars().skip(a).take(b - a).collect();
|
||||
let sx = tx + HudBatch::text_width(atlas, &pre);
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(sx, 49.0, HudBatch::text_width(atlas, &sel), 11.0),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if title_edit.cursor_visible() {
|
||||
let pre: String = value.chars().take(title_edit.cursor).collect();
|
||||
batch.fill(
|
||||
atlas,
|
||||
tx + HudBatch::text_width(atlas, &pre),
|
||||
49.0,
|
||||
1.0,
|
||||
9.0,
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
|
||||
let tw = HudBatch::text_width(atlas, &by);
|
||||
batch.text(
|
||||
@@ -941,12 +1124,12 @@ impl Game<'_> {
|
||||
BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
opened,
|
||||
page_edit,
|
||||
..
|
||||
} => {
|
||||
indicator(batch, *page, pages.len());
|
||||
let blink = (opened.elapsed().as_millis() / 300) % 2 == 0;
|
||||
let lines = self.book_lines(&pages[*page]);
|
||||
let text = &pages[*page];
|
||||
let lines = self.book_lines(text);
|
||||
for (i, line) in lines.iter().take(LINES).enumerate() {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
@@ -957,27 +1140,39 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
}
|
||||
if blink {
|
||||
// The cursor after the last character.
|
||||
let last = lines
|
||||
.last()
|
||||
.map(|l| atlas.font.width_text(l))
|
||||
.unwrap_or(0.0);
|
||||
let row = lines.len().saturating_sub(1) as f32
|
||||
+ if pages[*page].ends_with('\n') {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let x = if pages[*page].ends_with('\n') {
|
||||
0.0
|
||||
} else {
|
||||
last
|
||||
};
|
||||
let rows = self.page_rows(text);
|
||||
let (sel_a, sel_b) = (
|
||||
page_edit.cursor.min(page_edit.highlight),
|
||||
page_edit.cursor.max(page_edit.highlight),
|
||||
);
|
||||
if sel_a != sel_b {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, &(start, end)) in rows.iter().enumerate().take(LINES) {
|
||||
let from = sel_a.max(start);
|
||||
let to = sel_b.min(end);
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
let pre: String = chars[start..from].iter().collect();
|
||||
let sel: String = chars[from..to].iter().collect();
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(
|
||||
left + 36.0 + atlas.font.width(&pre),
|
||||
top + 29.0 + i as f32 * 9.0,
|
||||
atlas.font.width(&sel),
|
||||
9.0,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if page_edit.cursor_visible() {
|
||||
let (x, row) = self.caret_on(text, &rows, page_edit.cursor);
|
||||
batch.fill(
|
||||
atlas,
|
||||
left + 36.0 + x,
|
||||
top + 29.0 + row * 9.0,
|
||||
top + 29.0 + row as f32 * 9.0,
|
||||
1.0,
|
||||
9.0,
|
||||
BLACK,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`)
|
||||
//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The
|
||||
//! items drawn on the fire are `campfire_geometry`'s; a server's clients get
|
||||
//! them through the block entity's update tag (`lan::chunks::entity_tag`).
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
|
||||
items::ItemStack, mesher::Vertex, mobs::Who,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`).
|
||||
const KIND: &str = "minecraft:campfire_cooking";
|
||||
|
||||
fn is_campfire(name: &str) -> bool {
|
||||
name == "minecraft:campfire" || name == "minecraft:soul_campfire"
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's
|
||||
/// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food
|
||||
/// goes into the first free slot (`placeFood`), and the use is consumed
|
||||
/// whether or not there was room. A client of a server leaves the placing
|
||||
/// to the server. Whether the food went in; None when this isn't a
|
||||
/// campfire use, so the item's own use runs.
|
||||
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !is_campfire(&self.res.facts().get(state).name) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A client of a server predicts the placing; the server decides.
|
||||
if self.remote.is_some() {
|
||||
return Ok(Some(true));
|
||||
}
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let placed = match self.world.block_entities.get_mut(&key) {
|
||||
Some(BlockEntity::Campfire(c)) => {
|
||||
let mut one = held.clone();
|
||||
c.place_food(&mut one, time)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if placed {
|
||||
// ItemStack.consumeAndReturn: creative keeps the item.
|
||||
self.consume_held();
|
||||
self.stats.custom("interact_with_campfire", 1);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(state),
|
||||
);
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
Ok(Some(placed))
|
||||
}
|
||||
|
||||
/// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an
|
||||
/// unlit one. A finished item's result (the recipe's, or the item itself
|
||||
/// when no recipe takes it any more) drops at the block's corner
|
||||
/// (`Containers.dropItemStack` at its integer position), then the clients
|
||||
/// see the slot empty and a `block_change` goes out.
|
||||
pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let lit = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("lit"))
|
||||
.is_some_and(|v| v == "true");
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
// BlockEntity.setChanged: a timer that moved is saved with the
|
||||
// chunk (no update to the clients, who don't see the timers).
|
||||
let changed = if lit {
|
||||
c.items.iter().any(Option::is_some)
|
||||
} else {
|
||||
c.cooldown_tick()
|
||||
};
|
||||
if changed {
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
}
|
||||
if !lit {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let done = c.cook_tick();
|
||||
for (_, item) in done {
|
||||
let result = self
|
||||
.res
|
||||
.recipes()
|
||||
.cook(KIND, &item)
|
||||
.map_or_else(|| item.clone(), |r| r.result.clone());
|
||||
self.campfire_drop(pos, result);
|
||||
self.mark_block_entity_updated(pos);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
[pos.0, pos.1, pos.2],
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
|
||||
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
|
||||
/// (90° about X), at a corner and three eighths of its size, drawn as a
|
||||
/// FIXED item seeded by the block's position plus the slot.
|
||||
pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Campfire(c) = entity else {
|
||||
continue;
|
||||
};
|
||||
if c.items.iter().all(Option::is_none) {
|
||||
continue;
|
||||
}
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
let facing = match self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing"))
|
||||
.map(String::as_str)
|
||||
{
|
||||
Some("west") => 1,
|
||||
Some("north") => 2,
|
||||
Some("east") => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let light = vertex_light(self.world.light(key.0, key.1, key.2));
|
||||
let packed = BlockPos::new(key.0, key.1, key.2).pack();
|
||||
for (i, stack) in c.items.iter().enumerate() {
|
||||
let Some(stack) = stack else {
|
||||
continue;
|
||||
};
|
||||
let seed = (packed as i32).wrapping_add(i as i32);
|
||||
let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Direction.toYRot: 90° per 2D step from south.
|
||||
let yaw = -(((i + facing) % 4) as f32 * 90.0);
|
||||
let pose = glam::Mat4::from_translation(
|
||||
(at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5),
|
||||
) * glam::Mat4::from_rotation_y(yaw.to_radians())
|
||||
* glam::Mat4::from_rotation_x(90f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.375));
|
||||
self.submit_item_world(&item, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
|
||||
/// integer position.
|
||||
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
for piece in
|
||||
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
|
||||
{
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
count: piece.count,
|
||||
..stack.clone()
|
||||
},
|
||||
piece.pos,
|
||||
piece.motion,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `CampfireBlock.useItemOn`: an item in `#minecraft:douses_campfires` (the
|
||||
//! shovels) put on a lit campfire puts it out.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
|
||||
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
|
||||
pub(super) fn use_campfire_douse(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> Result<bool> {
|
||||
let Some(tool) = held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
|
||||
self.level_event_at(1009, pos, 0);
|
||||
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
|
||||
// then the block change game event from the player.
|
||||
if self.lan_acting.is_none() {
|
||||
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
|
||||
for _ in 0..20 {
|
||||
make_campfire_smoke(&mut self.particles, pos, signal, true);
|
||||
}
|
||||
}
|
||||
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), None);
|
||||
state.properties.insert("lit".into(), "false".into());
|
||||
if let Ok(unlit) = registry.resolve(&state) {
|
||||
self.level_set(pos, unlit)?;
|
||||
}
|
||||
let used = tool.clone();
|
||||
self.damage_held(1);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &used);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -464,7 +464,28 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// The Chat option's Hidden level takes the server's messages too.
|
||||
if !self.ui.chat.shows(ChatSource::Server) {
|
||||
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
|
||||
}
|
||||
/// A player's chat line on the window's own chat: a LAN guest's message
|
||||
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
|
||||
/// checks `canReceivePlayerMessages` for these, so Commands Only and
|
||||
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
|
||||
/// message and takes the system-message permission instead.
|
||||
pub(super) fn player_message(&mut self, text: Text) {
|
||||
if let Some(capture) = self.chat_capture.as_mut() {
|
||||
capture.push(text);
|
||||
return;
|
||||
}
|
||||
// A server's client receives the line as a packet (PlayerChat or
|
||||
// DisguisedChat) and filters it there.
|
||||
if self.remote.is_none() {
|
||||
self.local_chat_line(text, None, ChatSource::Player);
|
||||
}
|
||||
}
|
||||
/// A line on the window's own chat when the Chat option lets `source`
|
||||
/// through; running mods hear it.
|
||||
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
|
||||
if !self.ui.chat.shows(source) {
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -472,8 +493,8 @@ impl Game<'_> {
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
Some(SYSTEM_INDICATOR),
|
||||
ChatSource::Server,
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
@@ -602,7 +623,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
self.run_command(command);
|
||||
// A client mod's command first, then a server mod's, as on a server.
|
||||
if !self.mod_command(command) && !self.local_server_mod_command(command) {
|
||||
self.run_command(command);
|
||||
}
|
||||
} else {
|
||||
let line = Text::translate(
|
||||
"chat.type.text",
|
||||
@@ -622,6 +646,7 @@ impl Game<'_> {
|
||||
self.mods_chat(&line);
|
||||
}
|
||||
self.lan_host_chat(message);
|
||||
self.mod_player_chat(super::server_mods::Who::Local, message);
|
||||
}
|
||||
}
|
||||
pub(super) fn chat_scroll(&mut self, amount: f32) {
|
||||
@@ -757,6 +782,7 @@ impl Game<'_> {
|
||||
max_snow_accumulation_height: r.int("max_snow_accumulation_height"),
|
||||
spawn_mobs: r.bool("spawn_mobs"),
|
||||
spawn_monsters: r.bool("spawn_monsters"),
|
||||
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
|
||||
}
|
||||
}
|
||||
/// Apply rules mirrored in other state (natural regeneration, difficulty).
|
||||
|
||||
@@ -63,8 +63,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Typing or a backspace in a field of the title screens: in the server
|
||||
/// screens' append-only fields Select All's selection is replaced first.
|
||||
/// Typing or a backspace in a title-screen field. A field that still only
|
||||
/// appends replaces a Select All selection before the edit.
|
||||
pub(super) fn title_edit(&mut self, typed: Option<&str>, backspace: bool) {
|
||||
let Some(title) = self.title.as_mut() else {
|
||||
return;
|
||||
@@ -86,42 +86,31 @@ impl Game<'_> {
|
||||
}
|
||||
/// Shortcuts in the creative inventory's search box (an `EditBox` of 50).
|
||||
pub(super) fn creative_search_shortcut(&mut self, shortcut: EditShortcut) {
|
||||
if Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
CREATIVE_SEARCH,
|
||||
&mut self.creative_screen.search,
|
||||
shortcut,
|
||||
CREATIVE_SEARCH_CHARS,
|
||||
) {
|
||||
if self
|
||||
.creative_screen
|
||||
.search
|
||||
.shortcut(shortcut, &mut self.clipboard)
|
||||
{
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
/// A backspace in the creative search box: with Ctrl, the whole word
|
||||
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
|
||||
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
|
||||
/// A selection is what it deletes, as in `EditBox.keyPressed`.
|
||||
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
if !self.text_selection.take(CREATIVE_SEARCH, search) {
|
||||
if ctrl {
|
||||
horizon_client::ui::delete_words(search, -1);
|
||||
} else {
|
||||
search.pop();
|
||||
}
|
||||
}
|
||||
self.creative_screen
|
||||
.search
|
||||
.key(KeyCode::Backspace, ctrl, false);
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
/// Typing in the creative search box.
|
||||
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
self.text_selection.take(CREATIVE_SEARCH, search);
|
||||
if let Some(text) = typed {
|
||||
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
|
||||
self.creative_screen.search.insert_text(text);
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen`'s search box: `setMaxLength(50)`.
|
||||
pub(super) const CREATIVE_SEARCH_CHARS: usize = 50;
|
||||
pub(super) const CREATIVE_SEARCH: horizon_client::clipboard::FieldId = ("creative.search", 0);
|
||||
@@ -31,6 +31,10 @@ pub(super) struct CombatTracker {
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// Charged creepers whose blast just went off and that have not dropped
|
||||
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
|
||||
/// but the mobs they killed still ask for their loot.
|
||||
pub(super) charged_exploders: Vec<u32>,
|
||||
/// The player's fall distance before this tick's movement (as of the
|
||||
/// end of the last one).
|
||||
fall_before: f64,
|
||||
@@ -52,11 +56,16 @@ impl Game<'_> {
|
||||
/// Remember who deals the next `damage_type` damage to the player.
|
||||
pub(super) fn note_attacker(&mut self, damage_type: &str, mob: u32) {
|
||||
if let Some(name) = self.mob_display_name(mob) {
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (mob, name)));
|
||||
self.note_named_attacker(damage_type, mob, name);
|
||||
}
|
||||
}
|
||||
/// `note_attacker` for an entity that is not one of `self.mobs` (the
|
||||
/// ender dragon).
|
||||
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name)));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
|
||||
@@ -1697,6 +1697,8 @@ impl Game<'_> {
|
||||
value: 0,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
});
|
||||
name = Text::translate("entity.minecraft.experience_orb", &[]);
|
||||
}
|
||||
@@ -1885,11 +1887,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, new)) = horizon_client::save::decode_block_entity(&tag) else {
|
||||
let Some((_, mut new)) = horizon_client::save::decode_block_entity(&tag) else {
|
||||
return false;
|
||||
};
|
||||
let rolled = self.take_loot_table(key, &tag, &mut new);
|
||||
let after = horizon_client::save::encode_block_entity(key, &new, &name);
|
||||
if after == before {
|
||||
if after == before && !rolled {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
@@ -1897,6 +1900,79 @@ impl Game<'_> {
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
true
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
/// container (`setblock`, `data`): an empty container with a `LootTable`
|
||||
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
|
||||
/// the roll is kept with its chunk, so that the container saves, and
|
||||
/// `data get` shows, its table and seed until its items change. A tag
|
||||
/// without one drops what the container had. True when it had a table.
|
||||
pub(super) fn take_loot_table(
|
||||
&mut self,
|
||||
key: (i32, i32, i32),
|
||||
tag: &horizon_world::nbt::Tag,
|
||||
entity: &mut horizon_client::containers::BlockEntity,
|
||||
) -> bool {
|
||||
use horizon_client::{containers::BlockEntity, save, structure_blocks};
|
||||
let table = save::text(tag.get("LootTable"));
|
||||
let rolled = match (table, entity) {
|
||||
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
|
||||
let loot = structure_blocks::loot_data(&self.options.resources);
|
||||
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
let biome = self.biome_name_at(at).unwrap_or_default();
|
||||
Some(structure_blocks::roll_container(
|
||||
slots,
|
||||
loot.as_deref(),
|
||||
table,
|
||||
structure_blocks::loot_seed(tag),
|
||||
key,
|
||||
&biome,
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
|
||||
return false;
|
||||
};
|
||||
let held = |c: &horizon_client::chunk::Chunk| {
|
||||
c.saved
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
|
||||
};
|
||||
if rolled.is_none() && !held(chunk) {
|
||||
return false;
|
||||
}
|
||||
let chunk = std::sync::Arc::make_mut(chunk);
|
||||
let mut extras = chunk
|
||||
.saved
|
||||
.take()
|
||||
.map(std::sync::Arc::unwrap_or_clone)
|
||||
.unwrap_or_default();
|
||||
extras.rolled_loot.retain(|r| r.pos != key);
|
||||
let taken = rolled.is_some();
|
||||
extras.rolled_loot.extend(rolled);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
taken
|
||||
}
|
||||
/// The loot table and seed a container still holding its roll saves
|
||||
/// (`RolledLoot::restore`), in place of its items.
|
||||
pub(super) fn restore_loot_table(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
tag: &mut horizon_world::nbt::Tag,
|
||||
) {
|
||||
let horizon_world::nbt::Tag::Compound(map) = tag else {
|
||||
return;
|
||||
};
|
||||
let rolled = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(key.0 >> 4, key.2 >> 4))
|
||||
.and_then(|c| c.saved.as_ref())
|
||||
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key));
|
||||
if let Some(r) = rolled {
|
||||
r.restore(map);
|
||||
}
|
||||
}
|
||||
fn cmd_fill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let a = self.block_pos_arg(ctx, "from")?;
|
||||
let b = self.block_pos_arg(ctx, "to")?;
|
||||
@@ -2593,38 +2669,7 @@ impl Game<'_> {
|
||||
/// `PoiTypes`: the point of interest type of a block state.
|
||||
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
|
||||
let s = self.pack.assets.registry.state(state)?;
|
||||
let name = s.name.strip_prefix("minecraft:")?;
|
||||
Some(match name {
|
||||
"blast_furnace" => "minecraft:armorer",
|
||||
"smoker" => "minecraft:butcher",
|
||||
"cartography_table" => "minecraft:cartographer",
|
||||
"brewing_stand" => "minecraft:cleric",
|
||||
"composter" => "minecraft:farmer",
|
||||
"barrel" => "minecraft:fisherman",
|
||||
"fletching_table" => "minecraft:fletcher",
|
||||
"cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => {
|
||||
"minecraft:leatherworker"
|
||||
}
|
||||
"lectern" => "minecraft:librarian",
|
||||
"stonecutter" => "minecraft:mason",
|
||||
"loom" => "minecraft:shepherd",
|
||||
"smithing_table" => "minecraft:toolsmith",
|
||||
"grindstone" => "minecraft:weaponsmith",
|
||||
"bell" => "minecraft:meeting",
|
||||
"beehive" => "minecraft:beehive",
|
||||
"bee_nest" => "minecraft:bee_nest",
|
||||
"nether_portal" => "minecraft:nether_portal",
|
||||
"lodestone" => "minecraft:lodestone",
|
||||
"test_instance_block" => "minecraft:test_instance",
|
||||
n if n.ends_with("_bed") => {
|
||||
if s.properties.get("part").map(String::as_str) != Some("head") {
|
||||
return None;
|
||||
}
|
||||
"minecraft:home"
|
||||
}
|
||||
n if n.ends_with("lightning_rod") => "minecraft:lightning_rod",
|
||||
_ => return None,
|
||||
})
|
||||
horizon_client::save::poi_type(s).map(|(kind, _)| kind)
|
||||
}
|
||||
/// `LocateCommand.locatePoi`: `PoiManager.findClosestWithType` within
|
||||
/// 256 blocks of the source, over the loaded chunks.
|
||||
|
||||
@@ -286,6 +286,7 @@ impl Game<'_> {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"keepPacked".into());
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
|
||||
@@ -296,7 +297,8 @@ impl Game<'_> {
|
||||
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
|
||||
DataRef::Block(p) => {
|
||||
let key = (p[0], p[1], p[2]);
|
||||
if let Some((_, new)) = save::decode_block_entity(&tag) {
|
||||
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
|
||||
self.take_loot_table(key, &tag, &mut new);
|
||||
self.world.block_entities.insert(key, new);
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
// Redraw what shows the block entity (signs, banners).
|
||||
|
||||
@@ -49,26 +49,12 @@ impl Game<'_> {
|
||||
let mut tracker = Tracker::default();
|
||||
for &t in &targets {
|
||||
let h = holder(self.target_name(t));
|
||||
// ServerRecipeBook.addRecipes/removeRecipes count recipe book
|
||||
// displays (a transmute recipe has one per material count).
|
||||
let mut n = 0;
|
||||
// ServerPlayer.awardRecipes / resetRecipes: the displays changed.
|
||||
let index = &self.res.stations().recipe_index;
|
||||
n += if give {
|
||||
let (count, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
count as i32
|
||||
} else {
|
||||
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
};
|
||||
// ServerPlayer.awardRecipes / resetRecipes on the target's own
|
||||
// ServerRecipeBook: a guest's is swapped in for the call, and the
|
||||
// packets go to its client.
|
||||
let n = self
|
||||
.for_player(t, |g| g.change_recipes(give, &recipes))
|
||||
.unwrap_or(0);
|
||||
tracker.track(&h, n);
|
||||
}
|
||||
let verb = if give { "give" } else { "take" };
|
||||
@@ -92,6 +78,27 @@ impl Game<'_> {
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// ServerRecipeBook.addRecipes/removeRecipes for the player in place: how
|
||||
/// many recipe book displays changed (a transmute recipe has one per
|
||||
/// material count).
|
||||
fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 {
|
||||
let index = &self.res.stations().recipe_index;
|
||||
if give {
|
||||
let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
return count as i32;
|
||||
}
|
||||
let removed = self.level.recipe_book.remove_recipes(index, recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
// ---- /damage ----------------------------------------------------------------
|
||||
|
||||
fn damage_kind(ty: &str) -> DamageKind {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::{Game, commands::compute::ComputeContext};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::villager,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
@@ -86,6 +87,82 @@ impl Game<'_> {
|
||||
self.composter_add(pos, &layers);
|
||||
(stack.count > 0).then_some(stack)
|
||||
}
|
||||
/// `WorkAtComposter.compostItems` for the villager at `mob`: a full
|
||||
/// composter is emptied, then up to 20 wheat or beetroot seeds go in
|
||||
/// (keeping 10 of each kind) until the composter reaches level 7. Level
|
||||
/// event 1500 plays once, at the end.
|
||||
pub(super) fn villager_compost_items(&mut self, mob: usize, pos: [i32; 3]) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let Some(mut before) = self.composter_level(pos) else {
|
||||
return;
|
||||
};
|
||||
if before == 8 {
|
||||
self.with_level(|b, level, ticks| b.composter_extract(level, ticks, at));
|
||||
let _ = self.process_level_events();
|
||||
before = 0;
|
||||
}
|
||||
let mut remaining = 20;
|
||||
let mut amounts: Vec<(std::sync::Arc<str>, i32)> = Vec::new();
|
||||
let slots = villager::ext(&self.mobs[mob]).map_or(0, |e| e.inventory.len());
|
||||
'slots: for slot in (0..slots).rev() {
|
||||
if remaining <= 0 {
|
||||
break;
|
||||
}
|
||||
let stack = villager::ext_mut(&mut self.mobs[mob]).inventory[slot].clone();
|
||||
if !matches!(
|
||||
&*stack.item,
|
||||
"minecraft:wheat_seeds" | "minecraft:beetroot_seeds"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let known = amounts.iter().position(|(item, _)| *item == stack.item);
|
||||
let total = known.map_or(0, |k| amounts[k].1) + stack.count as i32;
|
||||
match known {
|
||||
Some(k) => amounts[k].1 = total,
|
||||
None => amounts.push((stack.item.clone(), total)),
|
||||
}
|
||||
let uses = (total - 10).min(remaining).min(stack.count as i32);
|
||||
if uses <= 0 {
|
||||
continue;
|
||||
}
|
||||
remaining -= uses;
|
||||
for _ in 0..uses {
|
||||
self.villager_insert_item(mob, slot, &stack, pos);
|
||||
if self.composter_level(pos) == Some(7) {
|
||||
break 'slots;
|
||||
}
|
||||
}
|
||||
}
|
||||
let after = self.composter_level(pos).unwrap_or(before);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.composter_fill_effects(level, ticks, at, after != before)
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `ComposterBlock.insertItem` with a villager's stack: below level 7 a
|
||||
/// compostable item is used up and may add a layer.
|
||||
fn villager_insert_item(&mut self, mob: usize, slot: usize, stack: &ItemStack, pos: [i32; 3]) {
|
||||
if self.composter_level(pos).is_none_or(|level| level >= 7) {
|
||||
return;
|
||||
}
|
||||
let Some(layers) = self.compostable(stack) else {
|
||||
return;
|
||||
};
|
||||
let context = ComputeContext {
|
||||
block: Some(pos),
|
||||
target: None,
|
||||
};
|
||||
let layers = self.provider_int(&layers, &context, 0).unwrap_or(0);
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.composter_layer(level, ticks, at, layers));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
let _ = self.process_level_events();
|
||||
let inventory = &mut villager::ext_mut(&mut self.mobs[mob]).inventory;
|
||||
inventory[slot].count -= 1;
|
||||
if inventory[slot].count == 0 {
|
||||
inventory.remove(slot);
|
||||
}
|
||||
}
|
||||
/// `ComposterBlock.handleFill` (level event 1500) on the client: the
|
||||
/// fill sound and ten composter particles above the compost.
|
||||
pub(super) fn composter_fill_event(&mut self, pos: BlockPos, success: bool) {
|
||||
|
||||
@@ -50,9 +50,9 @@ pub(super) struct Creatures {
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
}
|
||||
struct ProjWorld<'a, 'w> {
|
||||
w: &'a MobWorld<'w>,
|
||||
targets: Vec<(Who, Aabb)>,
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
}
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
@@ -193,6 +193,7 @@ impl Game<'_> {
|
||||
velocity: p.velocity,
|
||||
yaw: self.yaw,
|
||||
hero: self.potions.effects.get("hero_of_the_village").is_some(),
|
||||
effects: self.potions.effects.list.iter().map(|e| e.id).collect(),
|
||||
uuid: self.level.player_uuid.unwrap_or_default(),
|
||||
allies: std::sync::Arc::new(self.player_allies()),
|
||||
remote: false,
|
||||
@@ -373,6 +374,7 @@ impl Game<'_> {
|
||||
// Drawn seated (Entity.isPassenger); set apart because the
|
||||
// json! above is at the macro recursion limit.
|
||||
state["passenger"] = m.extra.passenger.into();
|
||||
state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0));
|
||||
state
|
||||
})
|
||||
.collect();
|
||||
@@ -391,6 +393,9 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
// Level.canHaveWeather: only the overworld's rain and thunder levels
|
||||
// ever rise, so nothing rains or thunders in the Nether or the End.
|
||||
let weather = self.dim == Dim::Overworld;
|
||||
let player = self.player_view();
|
||||
MobWorld {
|
||||
world: &self.world,
|
||||
@@ -407,8 +412,8 @@ impl Game<'_> {
|
||||
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
|
||||
game_time: self.world.game_time,
|
||||
day_time: self.world.day_time,
|
||||
raining: rain > 0.2,
|
||||
thundering: thunder > 0.9,
|
||||
raining: weather && rain > 0.2,
|
||||
thundering: weather && thunder > 0.9,
|
||||
nether: self.dim == Dim::Nether,
|
||||
block_tags: &self.res.items().block_tags,
|
||||
biomes: self.res.biomes(),
|
||||
@@ -493,6 +498,8 @@ impl Game<'_> {
|
||||
self.died.insert(m.id);
|
||||
self.mob_died(&m);
|
||||
}
|
||||
// Their victims have been looted: no charged creeper is waiting any more.
|
||||
self.combat.charged_exploders.clear();
|
||||
let uuid = self.level.player_uuid;
|
||||
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
let mut events = Vec::new();
|
||||
@@ -775,7 +782,17 @@ impl Game<'_> {
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| drops::loot_entity(o, None)),
|
||||
.map(|o| drops::loot_entity(o, None))
|
||||
// A charged creeper's blast kills after the creeper left the list.
|
||||
.or_else(|| {
|
||||
self.combat
|
||||
.charged_exploders
|
||||
.contains(&id)
|
||||
.then(|| LootEntity {
|
||||
powered: true,
|
||||
..LootEntity::new("minecraft:creeper")
|
||||
})
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
let direct = match d.kind {
|
||||
@@ -811,12 +828,12 @@ impl Game<'_> {
|
||||
world_seed: self.level.seed,
|
||||
};
|
||||
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
|
||||
if skulls
|
||||
&& let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker)
|
||||
&& let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id)
|
||||
{
|
||||
if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) {
|
||||
// Creeper.droppedSkulls: one head per charged explosion.
|
||||
creeper.extra.charged = false;
|
||||
self.combat.charged_exploders.retain(|c| *c != id);
|
||||
if let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) {
|
||||
creeper.extra.charged = false;
|
||||
}
|
||||
}
|
||||
let raider = horizon_client::mobs::raider::is_raider(m.species());
|
||||
for d in items {
|
||||
@@ -834,9 +851,51 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
|
||||
/// sequence with the mob as `this` (a chicken's egg depends on its
|
||||
/// variant); the loot data of the game's resources decides, and without it
|
||||
/// the items' table engine (no entity) rolls it.
|
||||
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
|
||||
use horizon_client::mobs::drops;
|
||||
use horizon_world::loot::EntityLootParams;
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
let this = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.map(|m| drops::loot_entity(m, None));
|
||||
let stacks = match (loot, this) {
|
||||
(Some(loot), Some(this)) if loot.has_table(table) => {
|
||||
let params = EntityLootParams {
|
||||
this,
|
||||
..EntityLootParams::default()
|
||||
};
|
||||
let seed = self.level.seed;
|
||||
match loot.random_sequence(table) {
|
||||
Some(id) => {
|
||||
let random = self.rand_sequences.get(id, seed);
|
||||
loot.roll_entity(table, random, ¶ms)
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(from_loot)
|
||||
.collect()
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
_ => self
|
||||
.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random),
|
||||
};
|
||||
for d in stacks {
|
||||
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
match e {
|
||||
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
|
||||
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
@@ -898,16 +957,24 @@ impl Game<'_> {
|
||||
owner,
|
||||
} => self.add_fangs(at, yaw, delay, owner),
|
||||
MobEvent::Shoot(..) => {}
|
||||
MobEvent::ExplosionSource { mob, charged } => {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::Explode(at, radius) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let source_mob = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) < 1.0
|
||||
})
|
||||
.map(|m| m.id)
|
||||
.next();
|
||||
let source_mob = self.combat.explosion_source.or_else(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2)
|
||||
< 1.0
|
||||
})
|
||||
.map(|m| m.id)
|
||||
.next()
|
||||
});
|
||||
if let Some(id) = source_mob {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
@@ -938,6 +1005,9 @@ impl Game<'_> {
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
} else {
|
||||
// Left for the end of the tick.
|
||||
t.outbox.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1040,6 +1110,40 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
self.process_level_events()?;
|
||||
}
|
||||
MobEvent::BreakDoor { pos } => {
|
||||
// BreakDoorGoal.tick: removeBlock, then level events 1021
|
||||
// and 2001 with the state that is there now.
|
||||
let air = self.world.air;
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3));
|
||||
self.process_level_events()?;
|
||||
self.level_event_at(1021, pos, 0);
|
||||
let left = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.level_event_at(2001, pos, left.0 as i32);
|
||||
}
|
||||
MobEvent::DestroyProgress { mob, pos, progress } => {
|
||||
self.destroy_block_progress(mob as i32, pos, progress);
|
||||
// ServerLevel.destroyBlockProgress: every player nearby sees it.
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.broadcast_near(
|
||||
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
|
||||
64.0,
|
||||
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
|
||||
horizon_protocol::packets::play::BlockDestruction {
|
||||
id: mob as i32,
|
||||
pos,
|
||||
progress: if (0..10).contains(&progress) {
|
||||
progress as u8
|
||||
} else {
|
||||
255
|
||||
},
|
||||
},
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
|
||||
MobEvent::SetBlockName { pos, name, effect } => {
|
||||
if effect {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1062,7 +1166,10 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::BreakBlock { pos, drop } => {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.destroy_particles(pos, old);
|
||||
// Block.spawnDestroyParticles: fire breaks leave nothing.
|
||||
if !self.res.facts().get(old).has_class("BaseFireBlock") {
|
||||
self.destroy_particles(pos, old);
|
||||
}
|
||||
if drop {
|
||||
let drops = self.res.items().block_drops_in(
|
||||
self.pack.assets.registry,
|
||||
@@ -1084,6 +1191,9 @@ impl Game<'_> {
|
||||
self.ensure_item_particle_sprites();
|
||||
}
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
if ty == horizon_client::particles::ParticleType::SonicBoom {
|
||||
self.lan_sonic_boom(&options, at);
|
||||
}
|
||||
// Count 0: one particle exactly there, at rest.
|
||||
if count == 0 {
|
||||
self.particles.add(&options, at, [0.0; 3]);
|
||||
@@ -1117,7 +1227,7 @@ impl Game<'_> {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount),
|
||||
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
|
||||
MobEvent::AxolotlSupportPlayer => {
|
||||
let previous = self
|
||||
.potions
|
||||
@@ -1179,6 +1289,10 @@ impl Game<'_> {
|
||||
self.mob_vibration_particle(from, to, ticks)
|
||||
}
|
||||
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
|
||||
MobEvent::BlockBreakEffect { pos, block } => {
|
||||
let state = self.world.block(block[0], block[1], block[2]);
|
||||
self.destroy_particles(pos, state);
|
||||
}
|
||||
MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius),
|
||||
MobEvent::CreakingHurt {
|
||||
home,
|
||||
@@ -1189,12 +1303,14 @@ impl Game<'_> {
|
||||
self.creaking_tear_down(center, spread)
|
||||
}
|
||||
MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob),
|
||||
MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target),
|
||||
MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other),
|
||||
MobEvent::VillagerBirth {
|
||||
parent,
|
||||
partner,
|
||||
child,
|
||||
} => self.villager_birth(parent, partner, child),
|
||||
MobEvent::WorkAtComposter { mob, pos } => self.villager_work_at_composter(mob, pos),
|
||||
MobEvent::GolemCheck { mob, needed } => self.villager_golem_check(mob, needed),
|
||||
MobEvent::Firework { at, color, flight } => {
|
||||
self.villager_firework(at, color, flight)
|
||||
@@ -1215,19 +1331,51 @@ impl Game<'_> {
|
||||
m.ai.aggressive |= aggressive;
|
||||
}
|
||||
}
|
||||
MobEvent::GiftLoot { table, at } => {
|
||||
let drops =
|
||||
self.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
for d in drops {
|
||||
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
|
||||
MobEvent::GiftLoot { mob, table, at } => {
|
||||
// Turtle.ageBoundaryReached: only with mob loot on.
|
||||
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
self.drop_gift_loot(mob, table, at);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle
|
||||
/// per egg, a day from grown, with the egg as its home (the sound, the
|
||||
/// crumbs and the block's removal came from the level).
|
||||
pub(super) fn hatch_turtles(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) {
|
||||
let eggs = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("eggs"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(1);
|
||||
for i in 0..eggs {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.3,
|
||||
];
|
||||
if let Some(id) = self.spawn_mob("minecraft:turtle", at, true)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.extra.age = -24000;
|
||||
m.extra.home = Some(pos);
|
||||
m.yaw = 0.0;
|
||||
m.body_yaw = 0.0;
|
||||
m.head_yaw = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
@@ -1261,6 +1409,13 @@ impl Game<'_> {
|
||||
m.extra.egg_time = life;
|
||||
}
|
||||
}
|
||||
/// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the
|
||||
/// `mob_drops` game rule is on.
|
||||
fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) {
|
||||
if self.ui.rules.bool("mob_drops") {
|
||||
self.spawn_orbs(at, amount);
|
||||
}
|
||||
}
|
||||
/// Animal.spawnChildFromBreeding
|
||||
fn breed(
|
||||
&mut self,
|
||||
@@ -1283,6 +1438,8 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| parent.clone());
|
||||
let mut child = parent.clone();
|
||||
child.baby = true;
|
||||
// TurtleBreedGoal.breed triggers it without a child.
|
||||
let turtle = p.species() == Species::Turtle;
|
||||
if matches!(
|
||||
(
|
||||
p.species(),
|
||||
@@ -1295,7 +1452,7 @@ impl Game<'_> {
|
||||
) {
|
||||
child.kind = "minecraft:mule".into();
|
||||
}
|
||||
self.trigger_bred(&parent, &partner_view, Some(&child));
|
||||
self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
|
||||
}
|
||||
if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) {
|
||||
q.extra.age = 6000;
|
||||
@@ -1307,6 +1464,19 @@ impl Game<'_> {
|
||||
self.lay_sniffer_egg(&p);
|
||||
return;
|
||||
}
|
||||
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
|
||||
// experience is left, and only with mob loot on.
|
||||
if p.species() == Species::Turtle {
|
||||
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
|
||||
// split up as `ExperienceOrb.award` does).
|
||||
if self.ui.rules.bool("mob_drops")
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
|
||||
{
|
||||
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.add_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
// A horse and a donkey have a mule.
|
||||
@@ -1354,7 +1524,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.world.random = r;
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
if p.species() == Species::Cat {
|
||||
@@ -1375,7 +1545,7 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
if p.species() == Species::Wolf {
|
||||
@@ -1397,7 +1567,7 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
|
||||
@@ -1413,7 +1583,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
}
|
||||
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
|
||||
/// mining fatigue III for five minutes unless a stronger or longer one
|
||||
@@ -1509,15 +1679,19 @@ impl Game<'_> {
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.note_attacker(damage_type, a);
|
||||
}
|
||||
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
|
||||
// owner) caused the hit, not a player.
|
||||
let living_cause = attacker.or(owner).is_some();
|
||||
let mut outcome = horizon_client::health::BlockOutcome::default();
|
||||
if let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_blocking(
|
||||
&& let Ok(o) = player.hurt_caused_by(
|
||||
&self.motion.health,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
living_cause,
|
||||
)
|
||||
&& {
|
||||
outcome = o;
|
||||
@@ -1535,8 +1709,10 @@ impl Game<'_> {
|
||||
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
|
||||
}
|
||||
}
|
||||
self.hurt_armor(amount);
|
||||
self.thorns(from);
|
||||
// The thorns hurt the entity that caused the hit, if there is one.
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.thorns(a);
|
||||
}
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, attacker, projectile);
|
||||
@@ -1987,6 +2163,23 @@ impl Game<'_> {
|
||||
self.creatures.projectiles.push(arrow);
|
||||
}
|
||||
}
|
||||
/// `TargetBlock.onProjectileHit`: a target block takes its power from
|
||||
/// where the projectile landed and resets after 20 ticks for an arrow, 8
|
||||
/// for any other projectile. A player's shot counts as a target hit.
|
||||
fn target_block_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) {
|
||||
let block = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let arrow = p.arrow.is_some();
|
||||
let location = [at.x, at.y, at.z];
|
||||
let hit = self.with_level(|b, level, ticks| {
|
||||
b.target_projectile_hit(level, ticks, block, location, face, arrow)
|
||||
});
|
||||
if hit.is_some() {
|
||||
let _ = self.process_level_events();
|
||||
if p.owner == Some(Who::Player) {
|
||||
self.stats.custom("target_hit", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
self.combat.projectile_owner = match p.owner {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
@@ -1996,6 +2189,13 @@ impl Game<'_> {
|
||||
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
|
||||
Hit::None => return Ok(()),
|
||||
};
|
||||
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
|
||||
// projectile (a wind charge out of the world's top has no face).
|
||||
if let Hit::Block { pos, face, .. } = hit
|
||||
&& face != [0; 3]
|
||||
{
|
||||
self.target_block_hit(p, pos, at, face);
|
||||
}
|
||||
if let Hit::Entity {
|
||||
who: Who::Mob(id),
|
||||
at,
|
||||
@@ -2358,12 +2558,14 @@ impl Game<'_> {
|
||||
// ShulkerBullet.onHitEntity: 4 damage, then levitation for 10 s.
|
||||
(ProjectileKind::ShulkerBullet { .. }, Hit::Entity { who, .. }) => match who {
|
||||
Who::Player => {
|
||||
if !self.creative {
|
||||
self.mob_hits_player(
|
||||
// Only a hit that hurt (`wasHurt`) brings the levitation.
|
||||
if !self.creative
|
||||
&& self.mob_hits_player(
|
||||
4.0,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:mob_projectile",
|
||||
);
|
||||
)
|
||||
{
|
||||
self.give_player_effect("minecraft:levitation", 200, 0);
|
||||
}
|
||||
}
|
||||
@@ -2372,6 +2574,8 @@ impl Game<'_> {
|
||||
&& m.damage(4.0, source)
|
||||
{
|
||||
m.extra.levitation = 200;
|
||||
// Shulker.hurtServer: a shulker hit by a bullet.
|
||||
horizon_client::mobs::shulker::hit_by_bullet(m);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2453,21 +2657,22 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|h| h.item.to_string())
|
||||
.unwrap_or_default();
|
||||
// Bucketable.bucketMobPickup: a water bucket scoops up a fish, a
|
||||
// tadpole or an axolotl, with the data its bucket carries.
|
||||
if item == "minecraft:water_bucket"
|
||||
&& self.mobs[index].species() == Species::Axolotl
|
||||
&& self.mobs[index].alive()
|
||||
&& let Some((bucket, sound)) = bucketable(self.mobs[index].species())
|
||||
{
|
||||
let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1);
|
||||
let mut filled = ItemStack::new(bucket, 1);
|
||||
filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components(
|
||||
&self.mobs[index],
|
||||
)));
|
||||
self.audio.play(
|
||||
"minecraft:item.bucket.fill_axolotl",
|
||||
Some(self.mobs[index].center()),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.audio
|
||||
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
|
||||
self.trigger_item("minecraft:filled_bucket", &filled);
|
||||
// Leashable.dropLeash, then discard.
|
||||
let id = self.mobs[index].id;
|
||||
self.break_leash(id, true, false);
|
||||
self.mobs[index].removed = true;
|
||||
if self.creative {
|
||||
if let Some(rest) = self.give(filled) {
|
||||
@@ -2521,6 +2726,19 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// Creeper.mobInteract: any item in #creeper_igniters, damaged when it
|
||||
// is damageable and used up otherwise.
|
||||
let creeper_igniter = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:creeper_igniters")
|
||||
.is_some_and(|t| t.contains(&*item));
|
||||
let damageable = self
|
||||
.res
|
||||
.items()
|
||||
.get(&item)
|
||||
.is_some_and(|i| i.max_damage > 0);
|
||||
let m = &mut self.mobs[index];
|
||||
if !m.alive() {
|
||||
return Ok(false);
|
||||
@@ -2536,6 +2754,7 @@ impl Game<'_> {
|
||||
let mut handled = true;
|
||||
let mut drops: Vec<(String, u32)> = Vec::new();
|
||||
let is_food = m.kind.food.contains(&item);
|
||||
let baby_turtle = species == Species::Turtle && m.is_baby();
|
||||
match species {
|
||||
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
|
||||
give = Some("minecraft:milk_bucket");
|
||||
@@ -2761,10 +2980,21 @@ impl Game<'_> {
|
||||
consume = true;
|
||||
sound = Some("entity.iron_golem.repair");
|
||||
}
|
||||
Species::Creeper if item == "minecraft:flint_and_steel" => {
|
||||
Species::Creeper if creeper_igniter => {
|
||||
m.extra.flag = true;
|
||||
sound = Some("item.flintandsteel.use");
|
||||
self.damage_held_tool();
|
||||
// FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE,
|
||||
// at the creeper's feet with a pitch from its own random.
|
||||
let event = if item == "minecraft:fire_charge" {
|
||||
"item.firecharge.use"
|
||||
} else {
|
||||
"item.flintandsteel.use"
|
||||
};
|
||||
eat = Some((event, 1.0, m.random.next_float() * 0.4 + 0.8));
|
||||
if damageable {
|
||||
self.damage_held_tool();
|
||||
} else {
|
||||
consume = true;
|
||||
}
|
||||
}
|
||||
// Parrot.mobInteract: parrot food may tame a wild one (one time in
|
||||
// ten); a parrot never takes it as Animal food (isFood is false).
|
||||
@@ -2814,6 +3044,14 @@ impl Game<'_> {
|
||||
if !handled {
|
||||
return Ok(false);
|
||||
}
|
||||
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
|
||||
if baby_turtle && !self.mobs[index].is_baby() {
|
||||
self.handle_mob_events(vec![MobEvent::GiftLoot {
|
||||
mob: self.mobs[index].id,
|
||||
table: "minecraft:gameplay/turtle_grow",
|
||||
at: feet,
|
||||
}])?;
|
||||
}
|
||||
self.swing_arm();
|
||||
if let Some(s) = sound {
|
||||
let pitch =
|
||||
@@ -3247,6 +3485,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
|
||||
/// bucket picks up.
|
||||
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
|
||||
let fish = "minecraft:item.bucket.fill_fish";
|
||||
Some(match species {
|
||||
Species::Cod => ("minecraft:cod_bucket", fish),
|
||||
Species::Salmon => ("minecraft:salmon_bucket", fish),
|
||||
Species::Pufferfish => ("minecraft:pufferfish_bucket", fish),
|
||||
Species::TropicalFish => ("minecraft:tropical_fish_bucket", fish),
|
||||
Species::Tadpole => (
|
||||
"minecraft:tadpole_bucket",
|
||||
"minecraft:item.bucket.fill_tadpole",
|
||||
),
|
||||
Species::Axolotl => (
|
||||
"minecraft:axolotl_bucket",
|
||||
"minecraft:item.bucket.fill_axolotl",
|
||||
),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Mob.getBaseExperienceReward
|
||||
/// The damage type id of a Horizon damage kind.
|
||||
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
|
||||
@@ -3291,6 +3549,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
|
||||
map: false,
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A generated loot stack as a Horizon item stack.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! finds the ammo for bows and crossbows.
|
||||
use super::{Game, shield::OFFHAND_SLOT, view_vector};
|
||||
use horizon_client::{
|
||||
enchant::EffectContext,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
@@ -152,6 +153,20 @@ impl Game<'_> {
|
||||
};
|
||||
let charged = std::mem::take(&mut bow.data_mut().charged);
|
||||
bow.tidy();
|
||||
let weapon = bow.clone();
|
||||
// AbstractArrow's constructor: `getPiercingCount` of the weapon.
|
||||
let pierce = self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.modify_value(
|
||||
Some(&weapon),
|
||||
"minecraft:projectile_piercing",
|
||||
&EffectContext::default(),
|
||||
&mut self.world.random,
|
||||
0.0,
|
||||
)
|
||||
.clamp(0.0, 127.0) as u8;
|
||||
let count = charged.len();
|
||||
// getShootingPower: a rocket among them slows every shot.
|
||||
let power = if charged.iter().any(|s| &*s.item == FIREWORK_ROCKET) {
|
||||
@@ -204,6 +219,7 @@ impl Game<'_> {
|
||||
if let Some(a) = arrow.arrow.as_mut() {
|
||||
a.set_item(ammo);
|
||||
a.set_owner(Some(Who::Player));
|
||||
a.pierce = pierce;
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! Cushions: `CushionItem.useOn` placement on the top face of a block,
|
||||
//! `Cushion.canBePlacedAt`/`survives`, `Cushion.interact` (sit),
|
||||
//! `BlockAttachedEntity.hurtServer` (break, dropping the matching colour),
|
||||
//! `BlockAttachedEntity.tick` and `CushionRenderer`.
|
||||
//!
|
||||
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
|
||||
//! only an entity whose `Pos` is the click point's height. Placement uses
|
||||
//! outline bounds, collision shapes and suffocation predicates.
|
||||
use super::{Game, Hit, view_vector};
|
||||
use horizon_client::entities::{Cushion, dye_index};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `Direction.fromYRot(yaw).toYRot()`: the nearest 90 degree step.
|
||||
pub(crate) fn snap_yaw(yaw: f32) -> f32 {
|
||||
(((f64::from(yaw) / 90.0 + 0.5).floor() as i64) & 3) as f32 * 90.0
|
||||
}
|
||||
|
||||
fn aabb(bounds: ([f64; 3], [f64; 3])) -> Aabb {
|
||||
Aabb::new(
|
||||
Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]),
|
||||
Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]),
|
||||
)
|
||||
}
|
||||
|
||||
fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
a.intersects(b)
|
||||
}
|
||||
use horizon_client::cushion::blocks_in;
|
||||
|
||||
struct PlacementWorld<'a, 'r>(&'a Game<'r>);
|
||||
impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
|
||||
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
|
||||
let boxes = self.0.outline_boxes(p);
|
||||
let mut shape = *boxes.first()?;
|
||||
for b in &boxes[1..] {
|
||||
for k in 0..3 {
|
||||
shape[k] = shape[k].min(b[k]);
|
||||
shape[k + 3] = shape[k + 3].max(b[k + 3]);
|
||||
}
|
||||
}
|
||||
Some(Aabb::new(
|
||||
Vec3::new(shape[0], shape[1], shape[2]),
|
||||
Vec3::new(shape[3], shape[4], shape[5]),
|
||||
))
|
||||
}
|
||||
|
||||
fn collision_shapes(&self, bounds: Aabb) -> Vec<horizon_core::geometry::VoxelShape> {
|
||||
let binding = self.0.res.collision();
|
||||
let mut world = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.0.world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
// Include neighbour blocks whose collision shape can reach outside their cell.
|
||||
blocks_in(bounds.inflate(Vec3::new(1.0, 1.0, 1.0)))
|
||||
.filter_map(|p| {
|
||||
let state = self.0.world.block(p[0], p[1], p[2]);
|
||||
binding
|
||||
.collision_shape(&mut world, state, p)
|
||||
.ok()
|
||||
.map(|s| s.moved(Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn suffocating(&self, p: [i32; 3]) -> bool {
|
||||
let state = self.0.world.block(p[0], p[1], p[2]);
|
||||
let facts = self.0.res.facts().get(state);
|
||||
let name = facts.name.as_ref();
|
||||
// Blocks' explicit predicates, then the default causes_suffocation/full-shape rule.
|
||||
match name {
|
||||
"minecraft:dirt_path"
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:mud"
|
||||
| "minecraft:soul_sand" => true,
|
||||
"minecraft:piston" | "minecraft:sticky_piston" => self
|
||||
.0
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("extended").is_some_and(|p| p == "false")),
|
||||
"minecraft:mangrove_roots" | "minecraft:moving_piston" => false,
|
||||
_ if name.ends_with("_leaves")
|
||||
|| name.ends_with("glass")
|
||||
|| name.ends_with("copper_grate") =>
|
||||
{
|
||||
false
|
||||
}
|
||||
// ShulkerBoxBlock: suffocating while closed; a box seen open
|
||||
// here is rare enough to count as closed.
|
||||
_ if name.ends_with("shulker_box") => true,
|
||||
_ => {
|
||||
facts.full_collision
|
||||
&& self
|
||||
.0
|
||||
.res
|
||||
.items()
|
||||
.block_in_tag("minecraft:causes_suffocation", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The cushion under the crosshair, its distance and the hit point.
|
||||
pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, c) in self.cushions.iter().enumerate() {
|
||||
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
|
||||
continue;
|
||||
};
|
||||
if best.is_none_or(|b| d < b.1) {
|
||||
best = Some((i, d, std::array::from_fn(|k| eye[k] + dir[k] * d)));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
pub(super) fn cushion_survives(&self, c: &Cushion) -> bool {
|
||||
horizon_client::cushion::survives(&PlacementWorld(self), aabb(c.bounds()))
|
||||
}
|
||||
|
||||
/// `Cushion.destroyIfInFire`: a fire-tagged block inside the deflated box.
|
||||
fn cushion_in_fire(&self, c: &Cushion) -> bool {
|
||||
let bounds = aabb(c.bounds()).next_deflated();
|
||||
blocks_in(bounds).any(|p| {
|
||||
let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2]));
|
||||
self.res.items().block_in_tag("minecraft:fire", &facts.name)
|
||||
})
|
||||
}
|
||||
|
||||
/// `CushionItem.useOn`: on the top face only, spawning at the click's
|
||||
/// height above the clicked block (or the replaceable block itself).
|
||||
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(default_color) = info.cushion_color.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let color_override = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:cushion/color"))
|
||||
.and_then(|tag| match tag {
|
||||
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
|
||||
// CushionItem.recalculateContextForSpecialCollisionShapes: open containers
|
||||
// (cauldrons, hoppers and composters) use the collision ray's actual height.
|
||||
let mut adjusted = Hit {
|
||||
pos: hit.pos,
|
||||
face: hit.face,
|
||||
point: hit.point,
|
||||
};
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
if self.res.items().block_in_tag(
|
||||
"minecraft:cushion_uses_collision_shape",
|
||||
&self.res.facts().get(state).name,
|
||||
) && let Some(eye) = self.eye_position()
|
||||
{
|
||||
let start = Vec3::new(eye[0], eye[1], eye[2]);
|
||||
let point = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
|
||||
let end = point.add_vector(point.subtract(start).normalize().scale(0.001));
|
||||
let binding = self.res.collision();
|
||||
let mut world = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
if let Ok(shape) = binding.collision_shape(&mut world, state, hit.pos)
|
||||
&& let Some(result) = shape.clip(
|
||||
start,
|
||||
end,
|
||||
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
)
|
||||
{
|
||||
adjusted.face = result.direction.normal();
|
||||
adjusted.point = [result.location.x, result.location.y, result.location.z];
|
||||
}
|
||||
}
|
||||
let hit = &adjusted;
|
||||
if hit.face != [0, 1, 0] {
|
||||
return false;
|
||||
}
|
||||
// BlockPlaceContext: a replaceable clicked block is replaced.
|
||||
let clicked = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
let pos = if clicked.replaceable {
|
||||
hit.pos
|
||||
} else {
|
||||
std::array::from_fn(|i| hit.pos[i] + hit.face[i])
|
||||
};
|
||||
// `Vec3.atCenterOfWithY(clickedPos, clickLocation.y)`.
|
||||
let y = hit.point[1];
|
||||
let position = Vec3::new(f64::from(pos[0]) + 0.5, y, f64::from(pos[2]) + 0.5);
|
||||
let bounds = aabb((
|
||||
[position.x - 0.5, position.y, position.z - 0.5],
|
||||
[position.x + 0.5, position.y + 0.25, position.z + 0.5],
|
||||
));
|
||||
if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) {
|
||||
return false;
|
||||
}
|
||||
// `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`.
|
||||
if self
|
||||
.cushions
|
||||
.iter()
|
||||
.any(|c| overlaps(aabb(c.bounds()), bounds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
// `BlockAttachedEntity.setPos`: `BlockPos.containing(position)`.
|
||||
let attached = [
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
];
|
||||
self.cushions.push(Cushion {
|
||||
id,
|
||||
position,
|
||||
pos: attached,
|
||||
yaw: snap_yaw(self.yaw),
|
||||
color,
|
||||
check_interval: 0,
|
||||
});
|
||||
self.world
|
||||
.modified
|
||||
.insert((attached[0] >> 4, attached[2] >> 4));
|
||||
// `destroyIfInFire`: placed into fire, it breaks at once.
|
||||
let placed = self.cushions.len() - 1;
|
||||
if self.cushion_in_fire(&self.cushions[placed]) {
|
||||
self.break_cushion(placed, false);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.place",
|
||||
Some([position.x, position.y, position.z]),
|
||||
0.75,
|
||||
0.8,
|
||||
);
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event(
|
||||
"minecraft:entity_place",
|
||||
[position.x, position.y, position.z],
|
||||
ctx,
|
||||
);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.interact`: sit down unless sneaking or already occupied.
|
||||
pub(super) fn interact_cushion(&mut self) -> bool {
|
||||
let Some((i, d, _)) = self.cushion_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// isSecondaryUseActive.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return false;
|
||||
}
|
||||
let id = self.cushions[i].id;
|
||||
let occupied = self.riding == Some(id)
|
||||
|| self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(id)));
|
||||
// `Cushion.interact`: an occupied cushion (`isVehicle`) passes.
|
||||
if occupied {
|
||||
return false;
|
||||
}
|
||||
// `Player.startRiding` gives up the vehicle it already has; the
|
||||
// client never sends that click, so pass instead.
|
||||
if self.riding.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(id);
|
||||
let p = self.cushions[i].position;
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.sit",
|
||||
Some([p.x, p.y, p.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.hurtServer` from a player: killed, then the item dropped.
|
||||
pub(super) fn attack_cushion(&mut self) -> bool {
|
||||
let Some((i, d, _)) = self.cushion_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// `skipAttackInteraction` when `mayInteract` is false.
|
||||
if !self.may_build() {
|
||||
return true;
|
||||
}
|
||||
self.break_cushion(i, true);
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.dropItem`: the break sound and particles, then the coloured
|
||||
/// item unless drops are off or the breaker has infinite materials.
|
||||
pub(super) fn break_cushion(&mut self, index: usize, by_player: bool) {
|
||||
let c = self.cushions.remove(index);
|
||||
let p = c.position;
|
||||
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
|
||||
if self.riding == Some(c.id) {
|
||||
self.riding = None;
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.break",
|
||||
Some([p.x, p.y, p.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.cushion_break_particles(&c);
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
|
||||
return;
|
||||
}
|
||||
let stack = ItemStack {
|
||||
item: std::sync::Arc::from(c.item()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10);
|
||||
}
|
||||
|
||||
/// `Cushion.showBreakingParticles`: ten of the matching wool block.
|
||||
fn cushion_break_particles(&mut self, c: &Cushion) {
|
||||
let name = format!("minecraft:{}_wool", c.color_name());
|
||||
let Some(state) = self.pack.assets.registry.default_state(&name) else {
|
||||
return;
|
||||
};
|
||||
let at = [
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
];
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
return;
|
||||
};
|
||||
let options = horizon_client::particles::ParticleOptions::block(
|
||||
horizon_client::particles::ParticleType::Block,
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
let p = c.position;
|
||||
self.particles.send_particles(
|
||||
&options,
|
||||
[p.x, p.y + 0.666_666_7, p.z],
|
||||
[0.25, 0.0625, 0.25],
|
||||
0.05,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
|
||||
pub(super) fn tick_cushions(&mut self) {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut i = 0;
|
||||
while i < self.cushions.len() {
|
||||
let p = self.cushions[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let previous = self.cushions[i].check_interval;
|
||||
self.cushions[i].check_interval += 1;
|
||||
if previous < 100 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
self.cushions[i].check_interval = 0;
|
||||
let c = self.cushions[i].clone();
|
||||
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
|
||||
self.break_cushion(i, false);
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
|
||||
let Some(c) = self
|
||||
.cushions
|
||||
.iter()
|
||||
.find(|c| Some(c.id) == self.riding)
|
||||
.cloned()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if keys.shift {
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
// `Cushion.removePassenger`: the get-up sound for a rider that
|
||||
// left rather than one whose cushion was killed.
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.get_up",
|
||||
Some([c.position.x, c.position.y, c.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
let seat = c.seat();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `CushionRenderer`: the coloured model, rotated to the nearest
|
||||
/// direction, with its bottom on the cushion's position.
|
||||
pub(super) fn cushion_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(model) = self.cushion_model.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
for c in &self.cushions {
|
||||
let entity_start = out.len();
|
||||
let a = aabb(c.bounds());
|
||||
let (lo, hi) = (a.min_position(), a.max_position());
|
||||
let (lo, hi) = (
|
||||
glam::DVec3::new(lo.x, lo.y, lo.z),
|
||||
glam::DVec3::new(hi.x, hi.y, hi.z),
|
||||
);
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
let Some(uv) = sprites
|
||||
.get(&format!("entity/cushion/{}_cushion", c.color_name()))
|
||||
.map(|r| [r.u0, r.v0, r.u1, r.v1])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
|
||||
let base = glam::Mat4::from_translation(p.as_vec3())
|
||||
* glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians())
|
||||
* glam::Mat4::from_rotation_x(std::f32::consts::PI)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0));
|
||||
// `EntityRenderer.getPackedLightCoords` probes at the eye height.
|
||||
let l = self.world.light(
|
||||
c.position.x.floor() as i32,
|
||||
(c.position.y + 0.2125).floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&Poses::new(model),
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -725,6 +725,11 @@ impl Game<'_> {
|
||||
match self.world.block_entities.get_mut(&p) {
|
||||
Some(BlockEntity::Jukebox(j)) => j.record = stack,
|
||||
Some(BlockEntity::Lectern(l)) => l.book = stack,
|
||||
Some(BlockEntity::Campfire(c)) => {
|
||||
if let Some(cell) = c.items.get_mut(slot) {
|
||||
*cell = stack;
|
||||
}
|
||||
}
|
||||
Some(e) => {
|
||||
if let Some(cell) = e.slots_mut().get_mut(slot) {
|
||||
*cell = stack;
|
||||
@@ -1126,14 +1131,18 @@ impl Game<'_> {
|
||||
let (p, v) = (b.position, b.velocity);
|
||||
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
// A merged orb prints once per count, as the oracle does: whether orbs
|
||||
// merge depends on entity ids, which differ from vanilla's.
|
||||
for o in &self.orbs {
|
||||
let (p, v) = (o.position, o.velocity);
|
||||
out.push(line(
|
||||
"minecraft:experience_orb",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
"",
|
||||
));
|
||||
for _ in 0..o.count.max(1) {
|
||||
out.push(line(
|
||||
"minecraft:experience_orb",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
"",
|
||||
));
|
||||
}
|
||||
}
|
||||
for c in &self.carts {
|
||||
let (p, v) = (c.position, c.velocity);
|
||||
|
||||
@@ -12,11 +12,13 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
|
||||
@@ -1558,6 +1560,7 @@ impl Game<'_> {
|
||||
y -= 1.0;
|
||||
}
|
||||
self.breath_clouds.push(Breath {
|
||||
id: 0,
|
||||
at: [x, y.floor() + 1.0, z],
|
||||
radius: 5.0,
|
||||
age: 0,
|
||||
@@ -1684,32 +1687,17 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
|
||||
}
|
||||
}
|
||||
// checkCrystals: heal 1 every 10 ticks from the nearest crystal within 32.
|
||||
// checkCrystals: heal 1 every 10 ticks from the nearest crystal, found
|
||||
// among those touching the dragon's box grown by 32.
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
if d.nearest_crystal.is_some() && d.ticks % 10 == 0 && d.health < MAX_HEALTH {
|
||||
d.health += 1.0;
|
||||
// LivingEntity.setHealth clamps to the maximum.
|
||||
d.health = (d.health + 1.0).min(MAX_HEALTH);
|
||||
}
|
||||
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let p = d.position;
|
||||
d.nearest_crystal = self
|
||||
.crystals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
(c.position.x - p.x).abs() <= 32.0
|
||||
&& (c.position.y - p.y).abs() <= 32.0
|
||||
&& (c.position.z - p.z).abs() <= 32.0
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = (a.1.position.x - p.x).powi(2)
|
||||
+ (a.1.position.y - p.y).powi(2)
|
||||
+ (a.1.position.z - p.z).powi(2);
|
||||
let db = (b.1.position.x - p.x).powi(2)
|
||||
+ (b.1.position.y - p.y).powi(2)
|
||||
+ (b.1.position.z - p.z).powi(2);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
.map(|(i, _)| i);
|
||||
let nearest = self.nearest_crystal_to(p);
|
||||
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
d.yaw = wrap(d.yaw);
|
||||
@@ -1870,7 +1858,7 @@ impl Game<'_> {
|
||||
pl.velocity.z += zd / dd * 4.0;
|
||||
}
|
||||
if !sitting {
|
||||
g.hurt_player_by("minecraft:mob_attack", 5.0);
|
||||
g.dragon_hurts_player("minecraft:mob_attack", 5.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1879,7 +1867,7 @@ impl Game<'_> {
|
||||
for h in heads {
|
||||
if h.inflate(Vec3::new(1.0, 1.0, 1.0)).intersects(p.bounds) {
|
||||
self.as_fight_player(p.who, |g| {
|
||||
g.hurt_player_by("minecraft:mob_attack", 10.0);
|
||||
g.dragon_hurts_player("minecraft:mob_attack", 10.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2037,6 +2025,7 @@ impl Game<'_> {
|
||||
// assignDirectionalMovement: the direction times the acceleration power 0.1.
|
||||
let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1);
|
||||
self.dragon_fireballs.push(DragonFireball {
|
||||
id: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
@@ -2083,6 +2072,7 @@ impl Game<'_> {
|
||||
});
|
||||
if hit_block || hit_player || b.age > 600 {
|
||||
self.breath_clouds.push(Breath {
|
||||
id: 0,
|
||||
at: [next.x, next.y, next.z],
|
||||
radius: 3.0,
|
||||
age: 0,
|
||||
@@ -2161,10 +2151,20 @@ impl Game<'_> {
|
||||
self.breath_clouds.append(&mut clouds);
|
||||
for (who, amount) in harmed {
|
||||
self.as_fight_player(who, |g| {
|
||||
g.hurt_player_by("minecraft:indirect_magic", amount);
|
||||
g.dragon_hurts_player("minecraft:indirect_magic", amount);
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Damage from the dragon, which is its causing entity
|
||||
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
|
||||
/// death message names it.
|
||||
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
|
||||
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
|
||||
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
|
||||
self.note_named_attacker(damage_type, id, name);
|
||||
}
|
||||
self.hurt_player_by(damage_type, amount);
|
||||
}
|
||||
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
|
||||
/// (or a server's packets) put on this client's overlay.
|
||||
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
|
||||
@@ -2202,15 +2202,58 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) struct DragonFireball {
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
velocity: Vec3,
|
||||
/// Its entity id once a server's players track it (0 until then).
|
||||
pub(super) id: u32,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
pub(super) velocity: Vec3,
|
||||
age: i32,
|
||||
}
|
||||
impl DragonFireball {
|
||||
/// One a server sent (`handleAddEntity`).
|
||||
pub(super) fn remote(id: u32, at: Vec3, velocity: Vec3) -> Self {
|
||||
Self {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
age: 0,
|
||||
}
|
||||
}
|
||||
/// `AbstractHurtingProjectile.tick` on a server's client: `applyInertia`
|
||||
/// first (power 0.1, inertia 0.95 or 0.8 in water), then the move; only
|
||||
/// the server decides what it hits.
|
||||
pub(super) fn remote_step(&mut self, water: bool) {
|
||||
self.previous = self.position;
|
||||
self.age += 1;
|
||||
let v = self.velocity;
|
||||
// Vec3.normalize: a vector shorter than 1e-5 stays zero.
|
||||
let l = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
|
||||
let n = if l < 1.0e-5 {
|
||||
Vec3::new(0.0, 0.0, 0.0)
|
||||
} else {
|
||||
Vec3::new(v.x / l, v.y / l, v.z / l)
|
||||
};
|
||||
let inertia = f64::from(if water { 0.8f32 } else { 0.95f32 });
|
||||
let v = Vec3::new(
|
||||
(v.x + n.x * 0.1) * inertia,
|
||||
(v.y + n.y * 0.1) * inertia,
|
||||
(v.z + n.z * 0.1) * inertia,
|
||||
);
|
||||
self.velocity = v;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + v.x,
|
||||
self.position.y + v.y,
|
||||
self.position.z + v.z,
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) struct Breath {
|
||||
at: [f64; 3],
|
||||
radius: f32,
|
||||
age: i32,
|
||||
/// Its entity id once a server's players track it (0 until then).
|
||||
pub(super) id: u32,
|
||||
pub(super) at: [f64; 3],
|
||||
pub(super) radius: f32,
|
||||
pub(super) age: i32,
|
||||
duration: i32,
|
||||
from_dragon: bool,
|
||||
}
|
||||
@@ -3132,6 +3175,212 @@ impl Game<'_> {
|
||||
let _ = self.activate_exit_portal();
|
||||
self.fight.dirty = true;
|
||||
}
|
||||
/// `EnderDragon.checkCrystals`' search: the nearest crystal whose box
|
||||
/// meets the dragon's box grown by 32.
|
||||
fn nearest_crystal_to(&self, p: Vec3) -> Option<usize> {
|
||||
// The dragon's 16 by 8 box.
|
||||
let reach = Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
)
|
||||
.inflate(Vec3::new(32.0, 32.0, 32.0));
|
||||
self.crystals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
// An end crystal's 2 by 2 box.
|
||||
reach.intersects(Aabb::new(
|
||||
Vec3::new(c.position.x - 1.0, c.position.y, c.position.z - 1.0),
|
||||
Vec3::new(c.position.x + 1.0, c.position.y + 2.0, c.position.z + 1.0),
|
||||
))
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = (a.1.position.x - p.x).powi(2)
|
||||
+ (a.1.position.y - p.y).powi(2)
|
||||
+ (a.1.position.z - p.z).powi(2);
|
||||
let db = (b.1.position.x - p.x).powi(2)
|
||||
+ (b.1.position.y - p.y).powi(2)
|
||||
+ (b.1.position.z - p.z).powi(2);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
/// The dragon on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
/// and the client side of `EnderDragon.aiStep`): the server flies it and
|
||||
/// sends its moves, health and phase; the client flaps its wings, keeps
|
||||
/// its flight history and places its parts, so it can be drawn and hit.
|
||||
impl Game<'_> {
|
||||
/// `handleAddEntity` for `minecraft:ender_dragon`. Its constructor
|
||||
/// sets the full health and the hovering phase until the server's
|
||||
/// synched data says otherwise.
|
||||
pub(super) fn spawn_remote_dragon(&mut self, id: i32, position: [f64; 3], yaw: f32) {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let mut d = Dragon::new(at, yaw, i64::from(id));
|
||||
d.id = id as u32;
|
||||
d.phase = Phase::from_id(10);
|
||||
self.dragon = Some(d);
|
||||
self.place_dragon_parts();
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
for part in &mut d.parts {
|
||||
part.1 = part.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The remote dragon, if `id` is it.
|
||||
fn remote_dragon_mut(&mut self, id: i32) -> Option<&mut Dragon> {
|
||||
self.dragon.as_mut().filter(|d| d.id as i32 == id)
|
||||
}
|
||||
/// `handleSetEntityData`: LivingEntity.DATA_HEALTH_ID and
|
||||
/// EnderDragon.DATA_PHASE (`onSyncedDataUpdated` sets the phase).
|
||||
pub(super) fn remote_dragon_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let Some(d) = self.remote_dragon_mut(id) else {
|
||||
return;
|
||||
};
|
||||
for (index, _, value) in values {
|
||||
match (index, value) {
|
||||
(9, Value::Float(health)) => d.health = *health,
|
||||
(16, Value::Int(phase)) => d.set_phase(Phase::from_id(*phase)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `handleSetEntityMotion`: the velocity its wings flap by.
|
||||
pub(super) fn remote_dragon_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
if let Some(d) = self.remote_dragon_mut(id) {
|
||||
d.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
}
|
||||
}
|
||||
/// `handleHurtAnimation` and `handleDamageEvent`: the red flash.
|
||||
pub(super) fn remote_dragon_hurt(&mut self, id: i32) {
|
||||
if let Some(d) = self.remote_dragon_mut(id) {
|
||||
d.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
/// `handleRemoveEntities`, or a crystal removed: the healing beam
|
||||
/// keeps pointing at the same crystal.
|
||||
pub(super) fn remove_remote_dragon(&mut self, id: i32) {
|
||||
if self.remote_dragon_mut(id).is_some() {
|
||||
self.dragon = None;
|
||||
return;
|
||||
}
|
||||
let Some(index) = self.crystals.iter().position(|c| c.id as i32 == id) else {
|
||||
return;
|
||||
};
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
match d.nearest_crystal {
|
||||
Some(n) if n == index => d.nearest_crystal = None,
|
||||
Some(n) if n > index => d.nearest_crystal = Some(n - 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One client tick of the dragon. `lerp`: the step toward its last
|
||||
/// sent position and yaw (LivingEntity's interpolation), and the
|
||||
/// steps left including this one.
|
||||
pub(super) fn tick_remote_dragon(&mut self, lerp: Option<([f64; 3], Option<f32>, u32)>) {
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return;
|
||||
};
|
||||
d.previous = d.position;
|
||||
d.previous_yaw = d.yaw;
|
||||
d.old_flap = d.flap;
|
||||
d.ticks += 1;
|
||||
d.hurt_time = (d.hurt_time - 1).max(0);
|
||||
for part in &mut d.parts {
|
||||
part.1 = part.0;
|
||||
}
|
||||
if let Some((target, yaw, steps)) = lerp {
|
||||
let n = f64::from(steps.max(1));
|
||||
d.position = Vec3::new(
|
||||
d.position.x + (target[0] - d.position.x) / n,
|
||||
d.position.y + (target[1] - d.position.y) / n,
|
||||
d.position.z + (target[2] - d.position.z) / n,
|
||||
);
|
||||
if let Some(yaw) = yaw {
|
||||
d.yaw += wrap(yaw - d.yaw) / steps.max(1) as f32;
|
||||
}
|
||||
}
|
||||
if !d.alive() {
|
||||
// EnderDragon.tickDeath on a client: it rises, and bursts
|
||||
// from tick 180 to 200; the server removes it.
|
||||
d.death_time += 1;
|
||||
let (t, p) = (d.death_time, d.position);
|
||||
if (180..=200).contains(&t) {
|
||||
let r = &mut d.random;
|
||||
let o = [
|
||||
f64::from((r.next_float() - 0.5) * 8.0),
|
||||
f64::from((r.next_float() - 0.5) * 4.0),
|
||||
f64::from((r.next_float() - 0.5) * 8.0),
|
||||
];
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::ExplosionEmitter),
|
||||
[p.x + o[0], p.y + 2.0 + o[1], p.z + o[2]],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
d.position.y += 0.1;
|
||||
for part in &mut d.parts {
|
||||
part.0.y += 0.1;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// processFlappingMovement and onFlap: its wing beats, and their sound.
|
||||
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
|
||||
let v = d.velocity;
|
||||
let mut flap_speed = 0.2 / ((v.x.hypot(v.z) as f32) * 10.0 + 1.0);
|
||||
flap_speed *= 2f32.powf(v.y as f32);
|
||||
d.flap += if d.phase.sitting() { 0.1 } else { flap_speed };
|
||||
let flap_now = (d.flap * std::f32::consts::TAU).cos();
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
if flap_was <= -0.3 && flap_now >= -0.3 {
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.flap", Some(at), 5.0, pitch);
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
if !d.phase.sitting() {
|
||||
d.growl -= 1;
|
||||
if d.growl < 0 {
|
||||
d.growl = 200 + d.random.next_int_bound(200).unwrap_or(0);
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
|
||||
}
|
||||
}
|
||||
// checkCrystals' search runs on a client too (the healing beam).
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let p = d.position;
|
||||
let nearest = self.nearest_crystal_to(p);
|
||||
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
d.yaw = wrap(d.yaw);
|
||||
d.record();
|
||||
self.place_dragon_parts();
|
||||
}
|
||||
/// The dragon's parts as a server's client attacks them: the server's
|
||||
/// ids for `EnderDragonPart`s follow the dragon's.
|
||||
pub(super) fn remote_dragon_part_hit(
|
||||
&self,
|
||||
eye: [f64; 3],
|
||||
dir: [f64; 3],
|
||||
reach: f64,
|
||||
) -> Option<(i32, f64, [f64; 3])> {
|
||||
let d = self.dragon.as_ref().filter(|d| d.alive() && d.id != 0)?;
|
||||
(0..8)
|
||||
.filter_map(|i| {
|
||||
let b = d.part_box(i);
|
||||
let p = d.parts[i].0;
|
||||
super::ray_box(eye, dir, reach, b)
|
||||
.map(|t| ((d.id + 1 + i as u32) as i32, t, [p.x, p.y, p.z]))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -255,23 +255,26 @@ impl Game<'_> {
|
||||
let _ = self.wind_burst(feet, radius, knockback, false, false);
|
||||
}
|
||||
}
|
||||
/// Thorns on worn armor when a mob at `from` hits the player.
|
||||
pub(super) fn thorns(&mut self, from: [f64; 3]) {
|
||||
let attacker = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| m.alive())
|
||||
.min_by(|a, b| {
|
||||
let d = |m: &horizon_client::mobs::Mob| {
|
||||
(m.position.x - from[0]).powi(2)
|
||||
+ (m.position.y - from[1]).powi(2)
|
||||
+ (m.position.z - from[2]).powi(2)
|
||||
};
|
||||
d(a.1).total_cmp(&d(b.1))
|
||||
})
|
||||
.map(|(i, _)| i);
|
||||
let Some(attacker) = attacker else { return };
|
||||
/// Thorns on worn armor when the mob `attacker` hits the player: the
|
||||
/// `affected: attacker` effect of `Enchantment.doPostAttack` hurts the
|
||||
/// entity that caused the hit.
|
||||
pub(super) fn thorns(&mut self, attacker: u32) {
|
||||
let Some(index) = self.mobs.iter().position(|m| m.id == attacker && m.alive()) else {
|
||||
return;
|
||||
};
|
||||
for damage in self.thorns_damage() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.hurt(damage, None, &mut self.world.random);
|
||||
let center = [m.position.x, m.position.y, m.position.z];
|
||||
self.audio
|
||||
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// The victim's side of thorns: for each worn piece whose chance comes up,
|
||||
/// the damage to deal the attacker (`DamageEntity` min 1, max 5) and the
|
||||
/// piece's wear (`ChangeItemDamage` 2).
|
||||
pub(super) fn thorns_damage(&mut self) -> Vec<f32> {
|
||||
let mut hits = Vec::new();
|
||||
for slot in 0..4 {
|
||||
let Some(stack) = self.armor[slot].clone() else {
|
||||
continue;
|
||||
@@ -299,15 +302,7 @@ impl Game<'_> {
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
// DamageEntity(min 1, max 5) and ChangeItemDamage(2).
|
||||
let damage = 1.0 + self.world.random.next_float() * 4.0;
|
||||
let center = {
|
||||
let m = &mut self.mobs[attacker];
|
||||
m.hurt(damage, None, &mut self.world.random);
|
||||
[m.position.x, m.position.y, m.position.z]
|
||||
};
|
||||
self.audio
|
||||
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
|
||||
hits.push(1.0 + self.world.random.next_float() * 4.0);
|
||||
if let Some(s) = self.armor[slot].as_mut() {
|
||||
s.damage += 2;
|
||||
if self
|
||||
@@ -320,6 +315,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
|
||||
pub(super) fn mending(&mut self, amount: u32) -> u32 {
|
||||
@@ -388,7 +384,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
|
||||
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
|
||||
/// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the
|
||||
/// `ammo_use` conditions as the tool, so Infinity only frees plain arrows.
|
||||
pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) {
|
||||
let Some(bow) = self.inventory[self.selected].clone() else {
|
||||
return (0.0, false);
|
||||
};
|
||||
@@ -407,7 +405,7 @@ impl Game<'_> {
|
||||
0.0,
|
||||
);
|
||||
let ammo_ctx = EffectContext {
|
||||
tool: Some("minecraft:arrow"),
|
||||
tool: Some(ammo),
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -129,15 +129,32 @@ impl EndState {
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
|
||||
/// `EyeOfEnder.tick` on a server's client: it moves by its last known
|
||||
/// motion; only the server steers it.
|
||||
pub(super) fn step_remote_eyes(&mut self) {
|
||||
for e in &mut self.eyes {
|
||||
e.previous = e.position;
|
||||
e.life += 1;
|
||||
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
|
||||
e.position += e.velocity;
|
||||
}
|
||||
}
|
||||
/// A server's move: an eye has no interpolation handler, so
|
||||
/// `moveOrInterpolateTo` puts it there at once. False when `id` isn't one.
|
||||
pub(super) fn move_remote_eye(&mut self, id: u32, at: [f64; 3]) -> bool {
|
||||
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
|
||||
return false;
|
||||
};
|
||||
e.position = glam::DVec3::from_array(at);
|
||||
true
|
||||
}
|
||||
/// `handleSetEntityMotion` for an eye. False when `id` isn't one.
|
||||
pub(super) fn remote_eye_motion(&mut self, id: u32, velocity: [f64; 3]) -> bool {
|
||||
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
|
||||
return false;
|
||||
};
|
||||
e.velocity = glam::DVec3::from_array(velocity);
|
||||
true
|
||||
}
|
||||
pub(super) fn remove_remote_eye(&mut self, id: u32) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
}
|
||||
@@ -1099,6 +1116,7 @@ impl Game<'_> {
|
||||
/// EyeOfEnder.tick: fly, then drop (4 in 5) or shatter after 80 ticks.
|
||||
fn tick_eyes(&mut self) {
|
||||
let mut eyes = std::mem::take(&mut self.end.eyes);
|
||||
let mut shattered = Vec::new();
|
||||
eyes.retain_mut(|eye| {
|
||||
eye.previous = eye.position;
|
||||
let next = eye.position + eye.velocity;
|
||||
@@ -1122,10 +1140,44 @@ impl Game<'_> {
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stack, at, [vx, 0.2, vz], 0);
|
||||
} else {
|
||||
shattered.push(at.map(|v| v.floor() as i32));
|
||||
}
|
||||
false
|
||||
});
|
||||
self.end.eyes = eyes;
|
||||
for pos in shattered {
|
||||
self.level_event_at(2003, pos, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelEventHandler` 2003: a shattered eye's item crumbs and two rings
|
||||
/// of portal particles drawn in towards it.
|
||||
pub(super) fn eye_break_particles(&mut self, pos: [i32; 3]) {
|
||||
let (x, y, z) = (
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
);
|
||||
let crumbs = self.item_particle(&ItemStack::new("minecraft:ender_eye", 1));
|
||||
for _ in 0..8 {
|
||||
let r = self.particles.random();
|
||||
let v = [
|
||||
r.next_gaussian() * 0.15,
|
||||
r.next_double() * 0.2,
|
||||
r.next_gaussian() * 0.15,
|
||||
];
|
||||
self.particles.add(&crumbs, [x, y, z], v);
|
||||
}
|
||||
let portal = ParticleOptions::simple(ParticleType::Portal);
|
||||
let mut angle = 0.0f64;
|
||||
while angle < std::f64::consts::PI * 2.0 {
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
let at = [x + c * 5.0, y - 0.4, z + s * 5.0];
|
||||
self.particles.add(&portal, at, [c * -5.0, 0.0, s * -5.0]);
|
||||
self.particles.add(&portal, at, [c * -7.0, 0.0, s * -7.0]);
|
||||
angle += std::f64::consts::PI / 20.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last
|
||||
|
||||
@@ -296,12 +296,14 @@ impl Game<'_> {
|
||||
if !creative
|
||||
&& !spectator
|
||||
&& hurtable
|
||||
&& let Ok(o) = p.hurt_blocking(
|
||||
&& let Ok(o) = p.hurt_caused_by(
|
||||
&self.motion.health,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
// The exploding mob is the causing entity.
|
||||
exploding.is_some(),
|
||||
)
|
||||
{
|
||||
outcome = o;
|
||||
@@ -314,9 +316,6 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if outcome.hurt && !creative {
|
||||
self.hurt_armor(amount);
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, None, false);
|
||||
}
|
||||
@@ -391,9 +390,11 @@ impl Game<'_> {
|
||||
[feet[0] - w, feet[1], feet[2] - w],
|
||||
[feet[0] + w, feet[1] + h, feet[2] + w],
|
||||
);
|
||||
// The exploding mob is the damage source's entity, so victims
|
||||
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
attacker: None,
|
||||
attacker: exploding.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
if hurtable && !m.damage(damage(dist, exposure), source) {
|
||||
@@ -620,6 +621,7 @@ impl Game<'_> {
|
||||
&mut self.world.random,
|
||||
Some((&mut self.rand_sequences, self.level.seed)),
|
||||
decay,
|
||||
false,
|
||||
);
|
||||
for mut stack in drops {
|
||||
if &*stack.item == "minecraft:air" || stack.count == 0 {
|
||||
|
||||
@@ -108,16 +108,10 @@ impl Game<'_> {
|
||||
} else {
|
||||
amount
|
||||
};
|
||||
let hurt = if let Some(p) = self.player.as_mut()
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(kind)
|
||||
{
|
||||
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if hurt {
|
||||
self.hurt_armor(amount);
|
||||
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,16 +81,28 @@ impl Game<'_> {
|
||||
};
|
||||
bite(self);
|
||||
self.each_guest_hit(bite);
|
||||
// `AbstractIllager.considersEntityAsAlly`: with no teams, the illager
|
||||
// tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose
|
||||
// owner is allied (itself or another illager). Teams are not modelled.
|
||||
let illager = |s: Species| {
|
||||
matches!(
|
||||
s,
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
|
||||
)
|
||||
};
|
||||
let illagers: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| illager(m.species()))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
// A missing owner hurts everyone (`currentOwner == null`).
|
||||
let owner_known = illagers.contains(&f.owner);
|
||||
for m in &mut self.mobs {
|
||||
let ally = matches!(
|
||||
m.species(),
|
||||
Species::Evoker
|
||||
| Species::Illusioner
|
||||
| Species::Vex
|
||||
| Species::Pillager
|
||||
| Species::Vindicator
|
||||
| Species::Witch
|
||||
);
|
||||
let ally = owner_known
|
||||
&& (illager(m.species())
|
||||
|| m.species() == Species::Vex
|
||||
&& illagers.contains(&(m.extra.counter2 as u32)));
|
||||
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
|
||||
m.damage(
|
||||
6.0,
|
||||
|
||||
@@ -17,6 +17,8 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17;
|
||||
|
||||
pub(super) struct Rocket {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
pub id: u32,
|
||||
@@ -261,6 +263,14 @@ impl Game<'_> {
|
||||
/// `FireworkRocketEntity.explode` and `dealExplosionDamage`.
|
||||
fn explode_rocket(&mut self, r: &Rocket) {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
// FireworkRocketEntity.explode broadcasts before discard. Send now:
|
||||
// tick_lan removes missing tracked entities before flushing effects.
|
||||
if r.id != 0
|
||||
&& let Some(host) = &self.lan
|
||||
{
|
||||
host.hub
|
||||
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
|
||||
}
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
let from = r.position;
|
||||
@@ -322,6 +332,16 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
}
|
||||
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
|
||||
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
|
||||
let camera = self.eye_position();
|
||||
if let Some(r) = self.rockets.iter().find(|r| r.id == id as u32) {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
|
||||
}
|
||||
}
|
||||
/// `dealExplosionDamage` on a player that is not the rocket's
|
||||
/// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or
|
||||
/// its middle (`ClipContext.Block.COLLIDER`, no fluids).
|
||||
@@ -352,17 +372,11 @@ impl Game<'_> {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
}
|
||||
let hurt = if let Some(p) = self.player.as_mut()
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
|
||||
{
|
||||
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if hurt {
|
||||
self.hurt_armor(amount);
|
||||
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
}
|
||||
}
|
||||
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
|
||||
|
||||
@@ -154,37 +154,54 @@ impl Game<'_> {
|
||||
let mut damage = 0;
|
||||
if let State::Hooked(id) = hook.state {
|
||||
// pullEntity: 0.1 of the way to the owner.
|
||||
let d = Vec3::new(
|
||||
owner.x - hook.position.x,
|
||||
owner.y - hook.position.y,
|
||||
owner.z - hook.position.z,
|
||||
);
|
||||
let pulled = |v: Vec3| Vec3::new(v.x + d.x * 0.1, v.y + d.y * 0.1, v.z + d.z * 0.1);
|
||||
let mut hooked_item = false;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
let d = Vec3::new(
|
||||
owner.x - hook.position.x,
|
||||
owner.y - hook.position.y,
|
||||
owner.z - hook.position.z,
|
||||
);
|
||||
m.velocity = Vec3::new(
|
||||
m.velocity.x + d.x * 0.1,
|
||||
m.velocity.y + d.y * 0.1,
|
||||
m.velocity.z + d.z * 0.1,
|
||||
);
|
||||
m.velocity = pulled(m.velocity);
|
||||
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
}
|
||||
damage = 5;
|
||||
// `hookedIn instanceof ItemEntity ? 3 : 5`.
|
||||
damage = if hooked_item { 3 } else { 5 };
|
||||
} else if hook.nibble > 0 {
|
||||
let biome = self.biome_name_at(hook.position);
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
else {
|
||||
return damage;
|
||||
};
|
||||
// `withLuck(this.luck + owner.getLuck())`: the rod's Luck of the Sea
|
||||
// plus the owner's luck attribute (the Luck and Bad Luck effects).
|
||||
let owner_luck = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:luck").ok())
|
||||
.unwrap_or(0.0) as f32;
|
||||
let mut random = self.world.random.clone();
|
||||
let items = loot.roll(
|
||||
"minecraft:gameplay/fishing",
|
||||
&mut random,
|
||||
hook.luck as f32,
|
||||
hook.luck as f32 + owner_luck,
|
||||
biome.as_deref(),
|
||||
Some(hook.open_water),
|
||||
);
|
||||
self.world.random = random;
|
||||
// FishingHook.retrieve: fish_caught for a catch.
|
||||
self.stats.custom("fish_caught", 1);
|
||||
for stack in items {
|
||||
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:fishes")
|
||||
.is_some_and(|t| t.contains(stack.item.as_str()))
|
||||
{
|
||||
self.stats.custom("fish_caught", 1);
|
||||
}
|
||||
let mut item = ItemStack {
|
||||
item: Arc::from(stack.item.as_str()),
|
||||
count: stack.count.max(1) as u32,
|
||||
@@ -231,7 +248,7 @@ impl Game<'_> {
|
||||
}
|
||||
damage
|
||||
}
|
||||
fn biome_name_at(&self, p: Vec3) -> Option<String> {
|
||||
pub(super) fn biome_name_at(&self, p: Vec3) -> Option<String> {
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let c = self.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(
|
||||
@@ -457,35 +474,47 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: hook the first living mob it touches.
|
||||
// checkCollision: hook the first living mob it touches, or (via
|
||||
// `canHitEntity`) a dropped item.
|
||||
let bb = bounds(h.position);
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
if let Some(m) = self.mobs.iter().find(|m| {
|
||||
let mb = m.bounds();
|
||||
let (a, z) = (mb.min_position(), mb.max_position());
|
||||
m.alive()
|
||||
&& a.x < hi.x
|
||||
&& lo.x < z.x
|
||||
&& a.y < hi.y
|
||||
&& lo.y < z.y
|
||||
&& a.z < hi.z
|
||||
&& lo.z < z.z
|
||||
}) {
|
||||
h.state = State::Hooked(m.id);
|
||||
let touches = |b: Aabb| {
|
||||
let (a, z) = (b.min_position(), b.max_position());
|
||||
a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z
|
||||
};
|
||||
let hit = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.alive() && touches(m.bounds()))
|
||||
.map(|m| m.id)
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|i| touches(i.bounds()))
|
||||
.map(|i| i.id)
|
||||
});
|
||||
if let Some(id) = hit {
|
||||
h.state = State::Hooked(id);
|
||||
h.velocity = Vec3::ZERO;
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
match self.mobs.iter().find(|m| m.id == id && m.alive()) {
|
||||
Some(m) => {
|
||||
h.position = Vec3::new(
|
||||
m.position.x,
|
||||
m.position.y + f64::from(m.height()) * 0.8,
|
||||
m.position.z,
|
||||
)
|
||||
}
|
||||
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
|
||||
let hooked = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.alive())
|
||||
.map(|m| (m.position, f64::from(m.height())))
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH))
|
||||
});
|
||||
match hooked {
|
||||
Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z),
|
||||
None => h.state = State::Flying,
|
||||
}
|
||||
self.hook = Some(h);
|
||||
|
||||
@@ -355,12 +355,23 @@ impl Game<'_> {
|
||||
* glam::DMat4::from_rotation_y(y_rot.to_radians());
|
||||
let light = self.world.light(f.pos[0], f.pos[1], f.pos[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// ItemFrameRenderer.getBlockLightLevel: a glow frame's model is at least 5 bright.
|
||||
let frame_light = if f.glow {
|
||||
(light & 0xF0) | (light & 15).max(5)
|
||||
} else {
|
||||
light
|
||||
};
|
||||
let frame_packed =
|
||||
(u32::from(frame_light >> 4) * 16) | ((u32::from(frame_light & 15) * 16) << 8);
|
||||
let mut quad =
|
||||
|m: &glam::DMat4, corners: [[f64; 3]; 4], uv: [[f32; 2]; 4], light: u32| {
|
||||
for (c, uv) in corners.iter().zip(uv) {
|
||||
let p = m.transform_point3(glam::DVec3::from_array(*c));
|
||||
// The frame model is drawn with the entity shader, lit by its face normal.
|
||||
let at = corners.map(|c| m.transform_point3(glam::DVec3::from_array(c)));
|
||||
let n = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
|
||||
let normal = [0, 1, 2].map(|k| (n[k].clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
for (p, uv) in at.into_iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
normal: [normal[0], normal[1], normal[2], -127],
|
||||
pos: p.as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -393,7 +404,7 @@ impl Game<'_> {
|
||||
]
|
||||
}),
|
||||
map_uv(r, *uv),
|
||||
packed,
|
||||
frame_packed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ impl Game<'_> {
|
||||
before(self.painting_hit().map(|h| h.1))
|
||||
|| before(self.frame_hit().map(|h| h.1))
|
||||
|| before(self.armor_stand_hit().map(|h| h.1))
|
||||
|| before(self.cushion_hit().map(|h| h.1))
|
||||
|| before(self.boat_hit().map(|h| h.1))
|
||||
|| before(self.cart_hit().map(|h| h.1))
|
||||
|| before(self.dragon_hit().map(|h| h.1))
|
||||
|
||||
@@ -222,7 +222,6 @@ impl Game<'_> {
|
||||
options.report = None;
|
||||
options.server = None;
|
||||
options.world = None;
|
||||
options.dev_mods.clear();
|
||||
self.join_world_rest()?;
|
||||
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
|
||||
let flags = Arc::new(Flags::default());
|
||||
@@ -292,6 +291,9 @@ impl Game<'_> {
|
||||
launch.create.as_ref(),
|
||||
)?)?;
|
||||
let saved = Box::new(self.level.clone());
|
||||
// IntegratedServer runs the server mods; the window the client mods.
|
||||
self.mods.dev = self.options.dev_mods.clone();
|
||||
self.start_mods();
|
||||
// Math.max(2, options.renderDistance): past vanilla's 32 when the
|
||||
// window's render distance is (Horizon's options go to 64).
|
||||
let view_distance = self.settings.render_distance.max(2);
|
||||
|
||||
@@ -196,13 +196,31 @@ impl Game<'_> {
|
||||
skins: &self.skin_downloads,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it.
|
||||
/// The client level as item models read it: worked out once a frame (a
|
||||
/// screen of items read the same level, its sky sampled once).
|
||||
pub(super) fn item_level(&self) -> LevelState {
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => "minecraft:overworld",
|
||||
Dim::Nether => "minecraft:the_nether",
|
||||
Dim::End => "minecraft:the_end",
|
||||
};
|
||||
let key = (
|
||||
self.frames,
|
||||
self.world.game_time,
|
||||
self.world.day_time,
|
||||
dimension,
|
||||
);
|
||||
let mut cached = self.item_level_cache.borrow_mut();
|
||||
if let Some((at, level)) = cached.as_ref()
|
||||
&& *at == key
|
||||
{
|
||||
return level.clone();
|
||||
}
|
||||
let level = self.level_state_now(dimension);
|
||||
*cached = Some((key, level.clone()));
|
||||
level
|
||||
}
|
||||
fn level_state_now(&self, dimension: &'static str) -> LevelState {
|
||||
let sky = self
|
||||
.dim_environment()
|
||||
.sample(self.world.day_time, None, None);
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, leash::Holder},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -147,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
player_entity: Option<i32>,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
@@ -196,6 +197,32 @@ fn mob_meta(
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
let target = if e.flag {
|
||||
m.ai.target.and_then(|who| match who {
|
||||
Who::Player => player_entity,
|
||||
Who::Mob(id) => Some(id as i32),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(target) = target {
|
||||
meta.push((17, serializer::INT, Value::Int(target)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0, flying by default.
|
||||
"minecraft:bat" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
|
||||
"minecraft:sheep" => {
|
||||
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
|
||||
@@ -637,6 +664,7 @@ impl Game<'_> {
|
||||
self.world.light_log = None;
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.streaming.extra.clear();
|
||||
if announce {
|
||||
self.system_message(Text::translate(
|
||||
@@ -823,12 +851,13 @@ impl Game<'_> {
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity);
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
@@ -838,7 +867,7 @@ impl Game<'_> {
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
@@ -915,6 +944,13 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
// `runBlockEvents`' broadcast precedes the tick's block changes
|
||||
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
|
||||
// guest re-runs a piston's `triggerEvent` while its level still
|
||||
// has the blocks as they were before the move.
|
||||
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
|
||||
host.hub.block_event(pos, b0, b1, state);
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
@@ -963,6 +999,9 @@ impl Game<'_> {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::PlayerChat { sender, text } => {
|
||||
self.mod_player_chat(super::server_mods::Who::Uuid(sender), &text);
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
@@ -1145,17 +1184,29 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
match e {
|
||||
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
}
|
||||
// `ServerLevel.runBlockEvents` broadcasts a handled block
|
||||
// event (`ClientboundBlockEventPacket`): the guest re-runs
|
||||
// the block's `triggerEvent` — a piston's `moveBlocks` is
|
||||
// what animates the move there. `state` is the block the
|
||||
// event named, read before it ran (the packet's `block`).
|
||||
horizon_world::level::LevelEvent::BlockEntityEvent { pos, state, a, b } => {
|
||||
self.lan_block_events
|
||||
.push(([pos.x, pos.y, pos.z], *a, *b, state.0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1173,8 +1224,30 @@ impl Game<'_> {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
|
||||
/// attacker's client and observers once the hub is back in place.
|
||||
pub(super) fn critical_hit(
|
||||
&mut self,
|
||||
attacker: Option<u32>,
|
||||
id: i32,
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
self.particles.tracking_emitter(
|
||||
ParticleOptions::simple(ParticleType::Crit),
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_critical_hits.push((attacker, id));
|
||||
}
|
||||
}
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
|
||||
if kind == 2001 {
|
||||
host.broken
|
||||
@@ -1247,6 +1320,14 @@ impl Game<'_> {
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
for (attacker, target) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the chunks around every guest loaded.
|
||||
@@ -1414,7 +1495,11 @@ impl Game<'_> {
|
||||
let tag = if secure { "" } else { "[Not Secure] " };
|
||||
super::dedicated::log("INFO", &format!("{tag}{line}"));
|
||||
}
|
||||
HostEvent::Chat { line, .. } => self.system_message(Text::literal(line)),
|
||||
// Player chat (and `/say`'s or `/me`'s, which are chat messages
|
||||
// too): the host's Chat option decides, as `ChatListener` does
|
||||
// for the guests' packets. Command feedback is a system message
|
||||
// and takes the system-message permission instead.
|
||||
HostEvent::Chat { line, .. } => self.player_message(Text::literal(line)),
|
||||
HostEvent::Warn(line) if dedicated => super::dedicated::log("WARN", &line),
|
||||
HostEvent::Warn(line) => log::warn!("{line}"),
|
||||
HostEvent::Error(line) if dedicated => super::dedicated::log("ERROR", &line),
|
||||
|
||||
@@ -77,6 +77,10 @@ pub(super) struct GuestPlay {
|
||||
last_inventory: u64,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
|
||||
/// its client's PERFORM_RESPAWN (`respawn_requested`).
|
||||
pub(super) won_game: bool,
|
||||
pub(super) respawn_requested: bool,
|
||||
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
|
||||
xp_delay: u32,
|
||||
/// What last killed the guest outright (`/kill`): the death message's key.
|
||||
@@ -875,7 +879,11 @@ impl Game<'_> {
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| {
|
||||
g.menu.as_ref().is_some_and(|m| {
|
||||
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
|
||||
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|
||||
|| m.pos.is_some_and(|k| {
|
||||
k.1 == super::minecarts::CART_KEY_Y
|
||||
&& !self.chest_vehicle_still_valid(k.2, g)
|
||||
})
|
||||
})
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
@@ -889,6 +897,35 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
|
||||
/// boat is still there and within the guest's entity interaction range
|
||||
/// plus 4 of its box.
|
||||
fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
let bounds = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id as i32 == id)
|
||||
.map(|c| c.bounds())
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id as i32 == id)
|
||||
.map(|b| b.bounds())
|
||||
});
|
||||
let Some(b) = bounds else {
|
||||
return false;
|
||||
};
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let d = [
|
||||
eye[0] - eye[0].clamp(lo.x, hi.x),
|
||||
eye[1] - eye[1].clamp(lo.y, hi.y),
|
||||
eye[2] - eye[2].clamp(lo.z, hi.z),
|
||||
];
|
||||
let range = if g.creative() { 5.0 } else { 3.0 } + 4.0;
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -1314,6 +1351,13 @@ impl Game<'_> {
|
||||
self.guest_suggestions(host, conn, &payload);
|
||||
}
|
||||
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
|
||||
// handleClientCommand: PERFORM_RESPAWN after the poem leaves the End
|
||||
// (the next server tick sends the player on).
|
||||
S::ClientCommand(c)
|
||||
if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) =>
|
||||
{
|
||||
host.play.entry(conn).or_default().respawn_requested = true;
|
||||
}
|
||||
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
|
||||
// ServerStatsCounter.sendStats.
|
||||
S::ClientCommand(c) if c.action == 1 => {
|
||||
@@ -1539,6 +1583,10 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A server mod's command, for every player whatever their level.
|
||||
if self.guest_server_mod_command(host, conn, command.trim_start_matches('/')) {
|
||||
return;
|
||||
}
|
||||
// An integrated server runs every player's commands at their own
|
||||
// permission level (`CommandSourceStack.permissions`); a LAN world
|
||||
// follows its Allow Commands, and a dedicated server leaves it to
|
||||
@@ -2253,6 +2301,7 @@ impl Game<'_> {
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
let velocity = marked.then_some(player.velocity);
|
||||
game.record_combat(&events);
|
||||
game.wear_armor(&events);
|
||||
(events, velocity)
|
||||
});
|
||||
let Some((events, marked)) = events else {
|
||||
@@ -2346,9 +2395,13 @@ impl Game<'_> {
|
||||
}
|
||||
if (next / 10) % 2 == 0
|
||||
&& !creative
|
||||
&& let Some(elytra) = game.armor[1].as_mut()
|
||||
&& let Some(stack) = game.armor[1].clone()
|
||||
{
|
||||
elytra.damage += 1;
|
||||
// `ItemStack.hurtAndBreak`: Unbreaking can save the wear.
|
||||
let loss = game.durability_loss(&stack, 1);
|
||||
if let Some(elytra) = game.armor[1].as_mut() {
|
||||
elytra.damage += loss;
|
||||
}
|
||||
}
|
||||
let ctx = game.player_context(None);
|
||||
game.post_game_event("minecraft:elytra_glide", at, ctx);
|
||||
@@ -2611,7 +2664,13 @@ impl Game<'_> {
|
||||
.random
|
||||
.next_int_bound(touching.len() as i32)
|
||||
.unwrap_or(0) as usize;
|
||||
let orb = self.orbs.remove(touching[pick]);
|
||||
// A merged orb gives its value once per count and goes at zero.
|
||||
let orb = self.orbs[touching[pick]].clone();
|
||||
if orb.count <= 1 {
|
||||
self.orbs.remove(touching[pick]);
|
||||
} else {
|
||||
self.orbs[touching[pick]].count -= 1;
|
||||
}
|
||||
play.xp_delay = 2;
|
||||
host.hub.take_item(orb.id as i32, id, 1, at);
|
||||
// ClientPacketListener.handleTakeItemEntity's sound, at the orb.
|
||||
|
||||
@@ -245,6 +245,16 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
for c in &self.cushions {
|
||||
let p = c.position;
|
||||
let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false);
|
||||
// Cushion.DATA_COLOR, set unless it is the default white.
|
||||
if c.color != 0 {
|
||||
e.meta
|
||||
.push((8, serializer::DYE_COLOR, Value::Id(i32::from(c.color))));
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
self.lan_projectile_states(host_entity, hub, out);
|
||||
for o in &self.others {
|
||||
let p = o.position;
|
||||
@@ -279,6 +289,10 @@ impl Game<'_> {
|
||||
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.potions.thrown_ids().for_each(&mut give);
|
||||
self.end.eye_ids().for_each(&mut give);
|
||||
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.dragon_fireballs
|
||||
.iter_mut()
|
||||
.for_each(|b| give(&mut b.id));
|
||||
}
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
@@ -435,6 +449,35 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
|
||||
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
|
||||
// client draws them from these (`tick_remote_clouds`).
|
||||
let breath = self.network_particle(
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::DragonBreath,
|
||||
),
|
||||
None,
|
||||
);
|
||||
for b in self.dragon_fireballs.iter().filter(|b| b.id != 0) {
|
||||
let p = b.position;
|
||||
let mut e = state(b.id, "minecraft:dragon_fireball", at(p), 0.0, false);
|
||||
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
|
||||
out.push(e);
|
||||
}
|
||||
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
|
||||
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
|
||||
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
|
||||
if b.age < 20 {
|
||||
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if let Some(p) = &breath {
|
||||
e.meta
|
||||
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
|
||||
}
|
||||
e.defaults
|
||||
.push((9, serializer::BOOLEAN, Value::Bool(false)));
|
||||
out.push(e);
|
||||
}
|
||||
out.extend(self.lan_dragon_state());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +370,7 @@ mod tests {
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: [0; 4],
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
|
||||
@@ -25,6 +25,33 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
|
||||
/// Keep the local event's rendering; connected players receive one at rest.
|
||||
pub(super) fn lan_sonic_boom(&self, options: &ParticleOptions, at: [f64; 3]) {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.0; 3],
|
||||
max_speed: [0.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
|
||||
/// network id and the data it carries (`state` for the block particles).
|
||||
/// None while not serving, or for a particle whose data isn't sent from
|
||||
@@ -68,7 +95,11 @@ impl Game<'_> {
|
||||
|
||||
/// The options a server's particle stands for (None for a type or data
|
||||
/// this client doesn't draw).
|
||||
fn received_particle(&mut self, p: &Particle, at: [f64; 3]) -> Option<ParticleOptions> {
|
||||
pub(super) fn received_particle(
|
||||
&mut self,
|
||||
p: &Particle,
|
||||
at: [f64; 3],
|
||||
) -> Option<ParticleOptions> {
|
||||
let name = self
|
||||
.remote
|
||||
.as_ref()?
|
||||
|
||||
@@ -32,6 +32,8 @@ pub(super) enum Victim {
|
||||
|
||||
/// The attacker's side of the hit.
|
||||
pub(super) struct Strike {
|
||||
/// The attacking guest, kept after their turn through `as_guest` ends.
|
||||
attacker: Option<u32>,
|
||||
amount: f32,
|
||||
scale: f32,
|
||||
crit: bool,
|
||||
@@ -119,6 +121,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.swing_arm();
|
||||
Some(Strike {
|
||||
attacker: self.lan_acting,
|
||||
amount,
|
||||
scale,
|
||||
crit,
|
||||
@@ -242,14 +245,46 @@ impl Game<'_> {
|
||||
if hurt && strength > 0.0 {
|
||||
g.knock_back_player(f64::from(strength), xd, zd);
|
||||
}
|
||||
hurt
|
||||
// The victim's thorns, dealt to the attacker below.
|
||||
let thorns = if hurt { g.thorns_damage() } else { Vec::new() };
|
||||
(hurt, thorns)
|
||||
};
|
||||
let hurt = self.ui.rules.bool("pvp")
|
||||
&& harm
|
||||
&& match victim {
|
||||
let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm {
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
if hurt && s.crit {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.motion.as_deref().map(|p| (g.entity_id, p))),
|
||||
};
|
||||
if let Some((id, p)) = target {
|
||||
let d = p.dimensions;
|
||||
self.critical_hit(
|
||||
s.attacker,
|
||||
id,
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
f64::from(d.width),
|
||||
f64::from(d.height),
|
||||
);
|
||||
}
|
||||
}
|
||||
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
|
||||
for damage in thorns {
|
||||
match s.attacker {
|
||||
None => self.hurt_player_by("minecraft:thorns", damage),
|
||||
Some(conn) => {
|
||||
self.guest_hit_by(host, conn, |g| g.hurt_player_by("minecraft:thorns", damage));
|
||||
}
|
||||
}
|
||||
}
|
||||
let sound = match (hurt, s.crit, s.scale > 0.9) {
|
||||
(false, ..) => "minecraft:entity.player.attack.nodamage",
|
||||
(true, true, _) => "minecraft:entity.player.attack.crit",
|
||||
|
||||
@@ -144,15 +144,16 @@ impl Game<'_> {
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let cost =
|
||||
|st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost {
|
||||
item: hub.item_id(&st.item),
|
||||
count: st.count as i32,
|
||||
};
|
||||
let cost = |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| {
|
||||
hub.stack(&Some(st.clone())).map(play::NetCost::from)
|
||||
};
|
||||
Some(play::NetOffer {
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub),
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub)?,
|
||||
result: host.hub.stack(&Some(o.result.clone()))?,
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)),
|
||||
cost_b: match &o.cost_b {
|
||||
Some(b) => Some(cost(&b.stack, &mut host.hub)?),
|
||||
None => None,
|
||||
},
|
||||
out_of_stock: o.out_of_stock(),
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
|
||||
@@ -56,6 +56,13 @@ impl Game<'_> {
|
||||
let (lo, hi) = corners(a.bounds());
|
||||
return Some((lo, hi, at(a.position)));
|
||||
}
|
||||
if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
|
||||
let (lo, hi) = corners(Aabb::new(
|
||||
Vec3::new(c.position.x - 0.5, c.position.y, c.position.z - 0.5),
|
||||
Vec3::new(c.position.x + 0.5, c.position.y + 0.25, c.position.z + 0.5),
|
||||
));
|
||||
return Some((lo, hi, at(c.position)));
|
||||
}
|
||||
if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
|
||||
let (lo, hi) = f.bounds();
|
||||
return Some((lo, hi, centre(f.pos)));
|
||||
@@ -98,9 +105,10 @@ impl Game<'_> {
|
||||
let boat = self.boats.iter().position(|b| b.id == vehicle);
|
||||
let cart = self.carts.iter().any(|c| c.id == vehicle);
|
||||
let mount = self.mobs.iter().any(|m| m.id == vehicle);
|
||||
let cushion = self.cushions.iter().any(|c| c.id == vehicle);
|
||||
// The vehicle is gone (broken, in another level) or the rider died:
|
||||
// its client drops it with the entity or the death screen.
|
||||
if dead || (boat.is_none() && !cart && !mount) {
|
||||
if dead || (boat.is_none() && !cart && !mount && !cushion) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.vehicle, g.vehicle_good) = (None, None);
|
||||
return;
|
||||
@@ -115,6 +123,17 @@ impl Game<'_> {
|
||||
// Getting out goes through leave_guest, which tells the client and
|
||||
// teleports it (ServerPlayer.dismountTo).
|
||||
let seat = self.as_guest(host, conn, |game| {
|
||||
// A cushion does not move: the rider sits on it until it sneaks
|
||||
// (`Cushion.removePassenger` plays the get-up sound).
|
||||
if game.cushions.iter().any(|c| Some(c.id) == game.riding) {
|
||||
if keys.shift {
|
||||
game.tick_cushion_riding(keys);
|
||||
return None;
|
||||
}
|
||||
let c = game.cushions.iter().find(|c| Some(c.id) == game.riding)?;
|
||||
let p = c.seat();
|
||||
return Some([p.x, p.y, p.z]);
|
||||
}
|
||||
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
|
||||
if keys.shift {
|
||||
game.dismount(i);
|
||||
@@ -168,6 +187,12 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.cushions
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| (c.position, c.seat()))
|
||||
});
|
||||
if let Some((p, seat)) = at {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! The game's tick code reads one level: the one in place, held in `Game`'s
|
||||
//! own fields (the world, its streaming and save, block ticks, entities).
|
||||
//! A server (the dedicated server and single player's server thread) keeps
|
||||
//! the other dimensions' levels here and ticks each one that has players by
|
||||
//! swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! the other dimensions' levels here and ticks each one with players or forced
|
||||
//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! in, so the tick code runs unchanged, one level at a time. The in-frame
|
||||
//! game holds one level, which follows its player (`enter_dimension`).
|
||||
//!
|
||||
@@ -50,6 +50,7 @@ pub(super) struct Parked {
|
||||
paintings: Vec<horizon_client::entities::Painting>,
|
||||
item_frames: Vec<horizon_client::entities::ItemFrame>,
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
others: Vec<commands::OtherEntity>,
|
||||
boats: Vec<boats::Boat>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
@@ -74,6 +75,7 @@ pub(super) struct Parked {
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32)>,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
@@ -107,6 +109,19 @@ pub(super) struct Levels {
|
||||
parked: HashMap<Dim, Parked>,
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
/// The chunks of each level not in place (capability `world` lends
|
||||
/// them to server mods for a call).
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) fn parked_chunks(
|
||||
&mut self,
|
||||
) -> impl Iterator<Item = (Dim, &mut horizon_client::chunk_map::ChunkMap)> {
|
||||
self.parked
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
|
||||
fn open_level(&self, dim: Dim) -> Result<Parked> {
|
||||
@@ -135,6 +150,7 @@ impl Game<'_> {
|
||||
paintings: Vec::new(),
|
||||
item_frames: Vec::new(),
|
||||
armor_stands: Vec::new(),
|
||||
cushions: Vec::new(),
|
||||
others: Vec::new(),
|
||||
boats: Vec::new(),
|
||||
carts: Vec::new(),
|
||||
@@ -159,6 +175,7 @@ impl Game<'_> {
|
||||
lan_events: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
anims: Default::default(),
|
||||
raiding: Default::default(),
|
||||
sculk: Default::default(),
|
||||
@@ -185,6 +202,7 @@ impl Game<'_> {
|
||||
swap(&mut self.paintings, &mut p.paintings);
|
||||
swap(&mut self.item_frames, &mut p.item_frames);
|
||||
swap(&mut self.armor_stands, &mut p.armor_stands);
|
||||
swap(&mut self.cushions, &mut p.cushions);
|
||||
swap(&mut self.others, &mut p.others);
|
||||
swap(&mut self.boats, &mut p.boats);
|
||||
swap(&mut self.carts, &mut p.carts);
|
||||
@@ -209,6 +227,7 @@ impl Game<'_> {
|
||||
swap(&mut self.lan_events, &mut p.lan_events);
|
||||
swap(&mut self.lan_block_events, &mut p.lan_block_events);
|
||||
swap(&mut self.lan_entity_events, &mut p.lan_entity_events);
|
||||
swap(&mut self.lan_critical_hits, &mut p.lan_critical_hits);
|
||||
let (a, b) = (&mut self.be_anims, &mut p.anims);
|
||||
swap(&mut a.chests, &mut b.chests);
|
||||
swap(&mut a.books, &mut b.books);
|
||||
@@ -219,6 +238,7 @@ impl Game<'_> {
|
||||
swap(&mut a.pots, &mut b.pots);
|
||||
swap(&mut a.gateways, &mut b.gateways);
|
||||
swap(&mut a.gateway_servers, &mut b.gateway_servers);
|
||||
swap(&mut a.conduit_servers, &mut b.conduit_servers);
|
||||
self.raiding.swap_level(&mut p.raiding);
|
||||
self.sculk.swap_level(&mut p.sculk);
|
||||
self.creaking.swap_level(&mut p.creaking);
|
||||
@@ -287,8 +307,8 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// One server tick (`MinecraftServer.tickServer`): each level with
|
||||
/// players in turn (`tickChildren`), its chunks streamed and loaded and
|
||||
/// its world ticked. The first pass reads the network and runs the
|
||||
/// players or forced chunks in turn (`tickChildren`), its chunks streamed
|
||||
/// and loaded and its world ticked. The first pass reads the network and runs the
|
||||
/// tick's shared parts (the clock, the weather, functions, the autosave);
|
||||
/// the next ones only their level. While `paused`, only the connections
|
||||
/// run.
|
||||
@@ -307,6 +327,8 @@ impl Game<'_> {
|
||||
};
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
// What server mods set in this level since its last tick.
|
||||
self.apply_mod_block_writes();
|
||||
result = if paused {
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
@@ -390,7 +412,7 @@ impl Game<'_> {
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
/// (the Overworld, the Nether, the End): every level with a player in
|
||||
/// it or a `/pregen` task, or the Overworld while nobody is online.
|
||||
/// it, forced chunks or a `/pregen` task, or the Overworld while idle.
|
||||
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
|
||||
let guests = self
|
||||
.lan
|
||||
@@ -399,7 +421,17 @@ impl Game<'_> {
|
||||
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d)
|
||||
let forced = if *d == self.dim {
|
||||
!self.streaming.forced.is_empty()
|
||||
} else {
|
||||
self.levels
|
||||
.parked
|
||||
.get(d)
|
||||
.is_some_and(|p| !p.streaming.forced.is_empty())
|
||||
};
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
|| forced
|
||||
|| self.pregen_level() == Some(*d)
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
|
||||
@@ -74,14 +74,74 @@ impl Game<'_> {
|
||||
self.spawn_bolt(at, trap);
|
||||
}
|
||||
}
|
||||
/// `findLightningTargetAround`: a living entity under open sky in the
|
||||
/// column (3 blocks around), else the top of the column.
|
||||
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
|
||||
/// lightning rod points of interest within 128 blocks of `center` whose
|
||||
/// block is the top (`WORLD_SURFACE`) of its column; the strike lands one
|
||||
/// above it. Horizon keeps no point-of-interest index, so this scans the
|
||||
/// loaded chunks the way `/locate poi` does.
|
||||
fn find_lightning_rod(&self, center: [i32; 3]) -> Option<[i32; 3]> {
|
||||
const RANGE: i32 = 128;
|
||||
let facts = self.res.facts();
|
||||
let mut is_rod: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
|
||||
let mut best: Option<(i64, [i32; 3])> = None;
|
||||
let (cx0, cz0) = ((center[0] - RANGE) >> 4, (center[2] - RANGE) >> 4);
|
||||
let (cx1, cz1) = ((center[0] + RANGE) >> 4, (center[2] + RANGE) >> 4);
|
||||
for cx in cx0..=cx1 {
|
||||
for cz in cz0..=cz1 {
|
||||
let Some(chunk) = self.world.chunks.get(&(cx, cz)) else {
|
||||
continue;
|
||||
};
|
||||
for (i, section) in chunk.sections.iter().enumerate() {
|
||||
let mut rod = |state: u32| {
|
||||
*is_rod.entry(state).or_insert_with(|| {
|
||||
facts
|
||||
.get(horizon_world::registry::BlockStateId(state))
|
||||
.name
|
||||
.ends_with("lightning_rod")
|
||||
})
|
||||
};
|
||||
if !section.blocks.maybe_has(&mut rod) {
|
||||
continue;
|
||||
}
|
||||
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
|
||||
for j in 0..4096 {
|
||||
if !rod(section.get(j)) {
|
||||
continue;
|
||||
}
|
||||
let p = [
|
||||
cx * 16 + (j & 15) as i32,
|
||||
base_y + (j >> 8) as i32,
|
||||
cz * 16 + ((j >> 4) & 15) as i32,
|
||||
];
|
||||
let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]
|
||||
.map(|d| i64::from(d) * i64::from(d))
|
||||
.iter()
|
||||
.sum::<i64>();
|
||||
if d > i64::from(RANGE * RANGE) || best.is_some_and(|(b, _)| d >= b) {
|
||||
continue;
|
||||
}
|
||||
let surface = self.world.world_surface(p[0], p[2], |s| facts.is_air(s));
|
||||
if p[1] == surface - 1 {
|
||||
best = Some((d, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| [p[0], p[1] + 1, p[2]])
|
||||
}
|
||||
/// `findLightningTargetAround`: the closest lightning rod, else a living
|
||||
/// entity under open sky in the column (3 blocks around), else the top of
|
||||
/// the column.
|
||||
fn lightning_target(&mut self, x: i32, z: i32) -> Option<[i32; 3]> {
|
||||
if !self.world.loaded(x, z) {
|
||||
return None;
|
||||
}
|
||||
let top =
|
||||
self.with_level(|b, level, _| b.motion_blocking_top(level, BlockPos::new(x, 0, z)));
|
||||
if let Some(rod) = self.find_lightning_rod([x, top.y, z]) {
|
||||
return Some(rod);
|
||||
}
|
||||
let sees_sky = |g: &Self, p: [i32; 3]| g.world.light(p[0], p[1], p[2]) >> 4 == 15;
|
||||
let mut candidates: Vec<[i32; 3]> = self
|
||||
.mobs
|
||||
@@ -162,11 +222,9 @@ impl Game<'_> {
|
||||
converted: Vec::new(),
|
||||
});
|
||||
}
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours.
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
|
||||
/// wherever `BaseFireBlock.getState` fire can survive.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
|
||||
if self.ui.difficulty < 2 {
|
||||
return;
|
||||
}
|
||||
let base = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
@@ -183,18 +241,13 @@ impl Game<'_> {
|
||||
if !self.res.facts().is_air(state) {
|
||||
continue;
|
||||
}
|
||||
// BaseFireBlock.getState: soul fire on soul soil and sand.
|
||||
let below = self.res.facts().get(self.world.block(p[0], p[1] - 1, p[2]));
|
||||
let name = if matches!(&*below.name, "minecraft:soul_soil" | "minecraft:soul_sand") {
|
||||
"minecraft:soul_fire"
|
||||
} else {
|
||||
"minecraft:fire"
|
||||
};
|
||||
// FireBlock.canSurvive: a sturdy floor (flammable neighbours aside).
|
||||
if below.sturdy_full >> 1 & 1 == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(fire) = self.pack.assets.registry.default_state(name) {
|
||||
// BaseFireBlock.getState and FireBlock/SoulFireBlock.canSurvive.
|
||||
let at = BlockPos::new(p[0], p[1], p[2]);
|
||||
let fire = self.with_level(|b, level, ticks| {
|
||||
let fire = b.fire_state_for(level, ticks, at);
|
||||
b.can_survive(level, ticks, at, fire).then_some(fire)
|
||||
});
|
||||
if let Some(fire) = fire {
|
||||
let _ = self.level_set(p, fire);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +272,10 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
if !b.visual_only {
|
||||
self.bolt_fire(b.at, 4);
|
||||
// Only Normal and Hard start fires with the strike itself.
|
||||
if self.ui.difficulty >= 2 {
|
||||
self.bolt_fire(b.at, 4);
|
||||
}
|
||||
self.strike_rod(b.at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const KNOCKBACK_POWER: f32 = 0.7;
|
||||
/// `Entity.isAlliedTo` between the player (`player` its UUID) and a mob
|
||||
/// without teams: `TamableAnimal.considersEntityAsAlly` (a pet it tamed) and
|
||||
/// `Allay.considersEntityAsAlly` (an allay that likes it).
|
||||
fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|
||||
pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|
||||
let tamable = matches!(
|
||||
m.species(),
|
||||
Species::Wolf
|
||||
|
||||
@@ -159,8 +159,10 @@ impl Containers<'_> {
|
||||
});
|
||||
}
|
||||
let entity = self.world.block_entities.get(&pos)?;
|
||||
// A beehive keeps bees, not items: it is no `Container`.
|
||||
if matches!(entity, BlockEntity::Beehive(_)) {
|
||||
// A beehive keeps bees, not items, and a campfire cooks its own: neither
|
||||
// is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops
|
||||
// in front of them and a hopper leaves them alone.
|
||||
if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) {
|
||||
return None;
|
||||
}
|
||||
let kind = match (entity, name) {
|
||||
|
||||
@@ -87,9 +87,12 @@ pub(super) struct Minecart {
|
||||
pub fuse: i32,
|
||||
/// Hopper minecarts: an unpowered activator rail enables them.
|
||||
pub enabled: bool,
|
||||
/// Chest minecarts: the loot table rolled into `items` on load, saved
|
||||
/// again while the items are still that roll.
|
||||
pub loot: Option<horizon_client::save::RolledLoot>,
|
||||
}
|
||||
const WIDTH: f64 = 0.98;
|
||||
const HEIGHT: f64 = 0.7;
|
||||
pub(super) const WIDTH: f64 = 0.98;
|
||||
pub(super) const HEIGHT: f64 = 0.7;
|
||||
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
|
||||
fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
@@ -97,7 +100,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
pub(super) const CART_KEY_Y: i32 = horizon_client::lan::menu::VEHICLE_KEY_Y;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum Shape {
|
||||
@@ -182,6 +185,7 @@ impl Minecart {
|
||||
push: [0.0; 2],
|
||||
fuse: -1,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
}
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -1527,6 +1531,12 @@ impl Minecart {
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if !self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
@@ -272,8 +272,14 @@ impl Game<'_> {
|
||||
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;
|
||||
let fit = atlas.font.fit(&title, room);
|
||||
let shown: String = title.chars().take(fit).collect();
|
||||
// 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,
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::{path::PathBuf, time::Duration};
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
super::Screen,
|
||||
super::{execution_role::ExecutionRole, lan::LanHost, server_mods::ModEvent},
|
||||
horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
@@ -28,7 +29,8 @@ use {
|
||||
ui,
|
||||
},
|
||||
horizon_mods::{
|
||||
Clock, ModKey, Mods, Output, Player, Runs, Snapshot, Status, UiScreen, UiWidget,
|
||||
Clock, ModKey, Mods, Output, Player, Runs, ServerPlayer, Snapshot, Status, UiScreen,
|
||||
UiWidget,
|
||||
},
|
||||
std::time::Instant,
|
||||
winit::{event::MouseButton, keyboard::KeyCode},
|
||||
@@ -52,6 +54,23 @@ pub(super) struct ModsState {
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
/// A server mod runs here: what players do is queued for it.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) server: bool,
|
||||
/// The players server mods saw last tick, for joins and leaves.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) players: Vec<ServerPlayer>,
|
||||
/// What players did this tick, for server mods at its end.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) events: Vec<ModEvent>,
|
||||
/// A running server mod has capability `world`: its calls get the
|
||||
/// levels' chunks to read.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) world: bool,
|
||||
/// Blocks server mods set, by dimension, made before that level's next
|
||||
/// tick.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) block_writes: Vec<(String, horizon_mods::BlockChange)>,
|
||||
/// `command.run` lines for this window's own world, run on the next tick:
|
||||
/// run at once, their chat lines would reach the mods while their output
|
||||
/// is still being shown, and a mod answering its own lines would recurse.
|
||||
@@ -285,7 +304,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load_for(Runs::Client, &paths, on, vanilla_command);
|
||||
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
@@ -297,8 +316,19 @@ impl Game<'_> {
|
||||
self.sync_mod_commands();
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Which mods this game runs: a server the server mods, the window
|
||||
/// client mods, and both when it simulates the world itself (in-frame).
|
||||
fn mod_runs(&self) -> Runs {
|
||||
match self.options.execution_role {
|
||||
ExecutionRole::IntegratedServer | ExecutionRole::DedicatedServer => Runs::Server,
|
||||
ExecutionRole::Client if self.options.server_thread => Runs::Client,
|
||||
ExecutionRole::Client => Runs::Both,
|
||||
}
|
||||
}
|
||||
/// The Mods page's rows, from what loaded.
|
||||
fn list_mods(&mut self) {
|
||||
self.mods.server = self.mods.mods.runs_server();
|
||||
self.mods.world = self.mods.mods.wants_world();
|
||||
let dev = &self.mods.dev;
|
||||
self.settings.mods.installed = self
|
||||
.mods
|
||||
@@ -384,8 +414,18 @@ impl Game<'_> {
|
||||
self.start_mods();
|
||||
}
|
||||
}
|
||||
/// What mods read this tick (or frame, for mods with a `frame` hook).
|
||||
/// What mods read this tick (or frame, for mods with a `frame` hook): the
|
||||
/// server's players as they were at the last tick's end.
|
||||
fn mod_snapshot(&mut self) -> Snapshot {
|
||||
let players = if self.mods.server {
|
||||
self.mods.players.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.mod_snapshot_with(players)
|
||||
}
|
||||
/// What mods read, with these players on the server.
|
||||
pub(super) fn mod_snapshot_with(&mut self, players: Vec<ServerPlayer>) -> Snapshot {
|
||||
let player = self.player.as_ref().map(|p| Player {
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
@@ -430,7 +470,7 @@ impl Game<'_> {
|
||||
mappings,
|
||||
cps,
|
||||
font_widths: self.mods.font_widths.clone(),
|
||||
players: Vec::new(),
|
||||
players,
|
||||
view_hit: self.mod_view_hit(),
|
||||
recipes,
|
||||
known_recipes,
|
||||
@@ -558,23 +598,32 @@ impl Game<'_> {
|
||||
self.mods.mods.tick(self.ticks);
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Once per frame: the frame hook, and dev mods' rebuilds.
|
||||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||||
if !self.mods.dev.is_empty()
|
||||
&& self
|
||||
/// Restarts the dev mods rebuilt since the last check, once a second.
|
||||
pub(super) fn reload_dev_mods(&mut self) {
|
||||
if self.mods.dev.is_empty()
|
||||
|| self
|
||||
.mods
|
||||
.checked
|
||||
.is_none_or(|t| t.elapsed() >= RELOAD_CHECK)
|
||||
.is_some_and(|t| t.elapsed() < RELOAD_CHECK)
|
||||
{
|
||||
self.mods.checked = Some(Instant::now());
|
||||
if self.mods.mods.reload_changed(vanilla_command) {
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
return;
|
||||
}
|
||||
self.mods.checked = Some(Instant::now());
|
||||
if self.mods.mods.reload_changed(vanilla_command) {
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
if self.options.execution_role.is_client() {
|
||||
self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY));
|
||||
} else {
|
||||
log::info!("Horizon: mods reloaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Once per frame: the frame hook, and dev mods' rebuilds.
|
||||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||||
self.reload_dev_mods();
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
@@ -605,8 +654,13 @@ impl Game<'_> {
|
||||
self.mods.mods.chat(line);
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Chat lines, command feedback and stopped mods, into the chat.
|
||||
fn show_mod_output(&mut self) {
|
||||
/// What mods showed: client mods' lines into the chat, server mods'
|
||||
/// to their players.
|
||||
pub(super) fn show_mod_output(&mut self) {
|
||||
self.route_mod_output(None);
|
||||
}
|
||||
/// [`Game::show_mod_output`], with the hub when it is out of `self.lan`.
|
||||
pub(super) fn route_mod_output(&mut self, mut host: Option<&mut LanHost>) {
|
||||
for output in self.mods.mods.drain() {
|
||||
let line = match output {
|
||||
Output::Chat { name, text } => {
|
||||
@@ -638,7 +692,18 @@ impl Game<'_> {
|
||||
// `mod_ui_view` finds no widgets and the screen closes.
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
|
||||
let line = format!("Mod {name} stopped: {reason}");
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log("WARN", &line);
|
||||
}
|
||||
Text::literal(line).with_color(text::RED)
|
||||
}
|
||||
server @ (Output::Tell { .. }
|
||||
| Output::Broadcast { .. }
|
||||
| Output::Reply { .. }
|
||||
| Output::SetBlocks { .. }) => {
|
||||
self.route_server_output(host.as_deref_mut(), server);
|
||||
continue;
|
||||
}
|
||||
Output::OpenScreen { owner } => {
|
||||
self.open_mod_screen(owner, false);
|
||||
@@ -666,20 +731,8 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Server mods' lines: this game runs no server mod yet.
|
||||
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
|
||||
};
|
||||
// Not through system_message: a mod must not hear its own lines.
|
||||
// Written by this client, so the Chat option doesn't hide it.
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
self.add_mod_chat_line(line);
|
||||
}
|
||||
// A mod may set or clear its settings page in any call, not only
|
||||
// while loading: its row's Settings button follows.
|
||||
@@ -688,6 +741,20 @@ impl Game<'_> {
|
||||
row.has_settings = e.has_ui_settings();
|
||||
}
|
||||
}
|
||||
/// A mod's line in the window's chat; not through system_message: a mod
|
||||
/// must not hear its own lines.
|
||||
/// Written by this client, so the Chat option doesn't hide it.
|
||||
pub(super) fn add_mod_chat_line(&mut self, line: Text) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
}
|
||||
/// The mods' HUD panels, stacked with the extras in their corners.
|
||||
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
for panel in self.mods.mods.panels() {
|
||||
|
||||
@@ -91,7 +91,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// `isEyeInFluid(WATER)`: the eye inside the water of its block column.
|
||||
fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
|
||||
pub(super) fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
|
||||
let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32);
|
||||
let f = self.res.facts().get(self.world.block(bx, by, bz));
|
||||
if f.fluid != Fluid::Water {
|
||||
|
||||
@@ -86,6 +86,10 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
|
||||
let players = players.into_iter().map(|(m, name)| (m, name, true));
|
||||
// Who is riding whom: asked once per tag below, and rescanning every mob
|
||||
// for each tag made this quadratic in the number of loaded mobs.
|
||||
let ridden: std::collections::HashSet<u32> =
|
||||
self.mobs.iter().filter_map(|o| o.extra.mount).collect();
|
||||
for (m, name, player) in named.chain(players) {
|
||||
if !m.alive() {
|
||||
continue;
|
||||
@@ -134,7 +138,7 @@ impl Game<'_> {
|
||||
invisible_to_viewer: invisible,
|
||||
hud_hidden: self.ui.hide_gui,
|
||||
is_camera: false,
|
||||
is_vehicle: self.mobs.iter().any(|o| o.extra.mount == Some(m.id)),
|
||||
is_vehicle: ridden.contains(&m.id),
|
||||
};
|
||||
// MobRenderer.shouldShowName: a visible name, or the mob under the
|
||||
// crosshair; players (LivingEntityRenderer) need neither.
|
||||
@@ -310,7 +314,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The lines of one name tag, `o` from the camera: the text at
|
||||
/// `text_alpha` in its layer, on the translucent black background
|
||||
/// (unlit, a hair behind) when `background`.
|
||||
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn name_tag_quads(
|
||||
&self,
|
||||
@@ -382,7 +386,7 @@ impl Game<'_> {
|
||||
[white.u0, white.v0, white.u1, white.v1],
|
||||
BACKGROUND,
|
||||
true,
|
||||
2,
|
||||
text_layer,
|
||||
);
|
||||
}
|
||||
let mut x = x0;
|
||||
|
||||
@@ -454,11 +454,24 @@ impl Game<'_> {
|
||||
for m in &self.mobs {
|
||||
self.portals.before.insert(m.id, m.position);
|
||||
}
|
||||
for b in &self.boats {
|
||||
self.portals.before.insert(b.id, b.position);
|
||||
}
|
||||
for c in &self.carts {
|
||||
self.portals.before.insert(c.id, c.position);
|
||||
}
|
||||
let ids: Vec<(u32, bool)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.map(|b| b.id)
|
||||
.chain(self.carts.iter().map(|c| c.id))
|
||||
.map(|id| (id, !self.vehicle_occupied(id))),
|
||||
)
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
@@ -501,8 +514,15 @@ impl Game<'_> {
|
||||
&& self.riding != Some(m.id)
|
||||
&& m.species() != Species::Wither
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items and mobs that moved this
|
||||
/// tick: the portal blocks their box swept through.
|
||||
/// Whether the player, a server player or a mob rides vehicle `id`.
|
||||
fn vehicle_occupied(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
|
||||
/// and minecarts that moved this tick: the portal blocks their box swept
|
||||
/// through.
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -519,8 +539,23 @@ impl Game<'_> {
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
|
||||
.chain(self.carts.iter().map(|c| {
|
||||
(
|
||||
c.id,
|
||||
c.position,
|
||||
super::minecarts::WIDTH,
|
||||
super::minecarts::HEIGHT,
|
||||
)
|
||||
}))
|
||||
.filter(|(id, ..)| !self.vehicle_occupied(*id))
|
||||
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
|
||||
.collect();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
@@ -678,6 +713,44 @@ impl Game<'_> {
|
||||
}
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
let plan = self.portal_plan(entry, b.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = &self.boats[i];
|
||||
let tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.boats.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let c = &self.carts[i];
|
||||
let (width, height) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.portal_plan(entry, c.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
|
||||
self.carts.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
|
||||
@@ -257,6 +257,7 @@ impl Game<'_> {
|
||||
lang::set(lang::Language::load(&[&root], code));
|
||||
self.refresh_remote_language();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
self.creative_screen.forget_items();
|
||||
}
|
||||
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
|
||||
pub(super) fn apply_ui_settings(&mut self) {
|
||||
@@ -268,6 +269,11 @@ impl Game<'_> {
|
||||
r.animated_textures = s.horizon.animated_textures;
|
||||
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
|
||||
r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa);
|
||||
r.set_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.hdr_paper_white as f32,
|
||||
s.horizon.hdr_peak as f32,
|
||||
);
|
||||
}
|
||||
// Window.updateFullscreenIfChanged: only a changed option asks the
|
||||
// window to change, so applying settings never undoes a fullscreen
|
||||
|
||||
@@ -163,7 +163,7 @@ impl Game<'_> {
|
||||
self.pack.context.biomes.clone(),
|
||||
self.pack.context.light.clone(),
|
||||
)?;
|
||||
// Keep the working GPU resources, but release its exclusive window surface.
|
||||
// Keep the working GPU resources, and hand its window surface over.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
}
|
||||
@@ -221,6 +221,8 @@ impl Game<'_> {
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
// The creative screen survives the reload; item names may not.
|
||||
self.creative_screen.forget_items();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -279,7 +279,8 @@ impl Game<'_> {
|
||||
k if super::minecarts::CartKind::from_id(k).is_some() => {
|
||||
let kind = super::minecarts::CartKind::from_id(k).unwrap();
|
||||
let id = self.next_entity + 1;
|
||||
if let Some(c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) {
|
||||
if let Some(mut c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) {
|
||||
self.roll_cart_loot(&mut c, &tag);
|
||||
self.next_entity = id;
|
||||
self.remember(id, uuid, tag);
|
||||
self.carts.push(c);
|
||||
@@ -314,6 +315,13 @@ impl Game<'_> {
|
||||
self.item_frames.push(f);
|
||||
}
|
||||
}
|
||||
"minecraft:cushion" => {
|
||||
self.next_entity += 1;
|
||||
if let Some(c) = entity_nbt::cushion_from_nbt(&tag, self.next_entity) {
|
||||
self.remember(c.id, uuid, tag);
|
||||
self.cushions.push(c);
|
||||
}
|
||||
}
|
||||
"minecraft:painting" => {
|
||||
if let Some((pos, facing, variant)) = entity_nbt::painting_from_nbt(&tag)
|
||||
&& let Some(v) = self.painting_variants.get(&variant).cloned()
|
||||
@@ -527,6 +535,30 @@ impl Game<'_> {
|
||||
pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> {
|
||||
self.persist.raw.get(&id)
|
||||
}
|
||||
/// A loaded chest minecart with a loot table and no items: the table
|
||||
/// rolled now, as generated containers are (Horizon does not unpack on
|
||||
/// first open), and kept to save it again while unchanged.
|
||||
fn roll_cart_loot(&self, cart: &mut super::minecarts::Minecart, tag: &Tag) {
|
||||
let Some(table) = horizon_client::save::text(tag.get("LootTable")) else {
|
||||
return;
|
||||
};
|
||||
if cart.items.is_empty() || cart.items.iter().any(Option::is_some) {
|
||||
return;
|
||||
}
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
let p = cart.position;
|
||||
let pos = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let biome = self.biome_name_at(p).unwrap_or_default();
|
||||
let seed = horizon_client::structure_blocks::loot_seed(tag);
|
||||
cart.loot = Some(horizon_client::structure_blocks::roll_container(
|
||||
&mut cart.items,
|
||||
loot.as_deref(),
|
||||
table,
|
||||
seed,
|
||||
pos,
|
||||
&biome,
|
||||
));
|
||||
}
|
||||
fn remember(&mut self, id: u32, uuid: Option<[i32; 4]>, tag: Tag) {
|
||||
self.restore_portal_cooldown(id, &tag);
|
||||
if let Some(u) = uuid {
|
||||
@@ -776,6 +808,18 @@ impl Game<'_> {
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for c in &self.cushions {
|
||||
if keys.contains(&chunk_of(c.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&c.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::cushion_to_nbt(c, uuid, self.persist.raw.get(&c.id));
|
||||
out.entry(chunk_of(c.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for p in &self.paintings {
|
||||
let at = painting_center(p);
|
||||
if keys.contains(&chunk_of(at)) {
|
||||
@@ -868,6 +912,13 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
self.cushions.retain(|c| {
|
||||
let keep = !gone(c.position);
|
||||
if !keep {
|
||||
removed.push(c.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
@@ -934,6 +985,7 @@ impl Game<'_> {
|
||||
.chain(self.boats.iter().map(|e| e.id))
|
||||
.chain(self.carts.iter().map(|e| e.id))
|
||||
.chain(self.armor_stands.iter().map(|e| e.id))
|
||||
.chain(self.cushions.iter().map(|e| e.id))
|
||||
.chain(self.item_frames.iter().map(|e| e.id))
|
||||
.chain(self.paintings.iter().map(|e| e.id))
|
||||
.chain(self.others.iter().map(|e| e.id))
|
||||
|
||||
@@ -155,6 +155,8 @@ mod tests {
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t)
|
||||
}
|
||||
/// `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`.
|
||||
|
||||
@@ -71,6 +71,8 @@ pub(super) struct BodyPotions {
|
||||
pub(super) struct PotionState {
|
||||
pub(super) effects: PlayerEffects,
|
||||
thrown: Vec<ThrownPotion>,
|
||||
/// The area effect clouds a server tracks (game/remote_projectiles.rs).
|
||||
pub(super) clouds: Vec<super::remote_projectiles::RemoteCloud>,
|
||||
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
|
||||
/// Armor the protection table was computed for.
|
||||
protection_for: Option<Vec<Option<ItemStack>>>,
|
||||
@@ -141,14 +143,22 @@ impl PotionState {
|
||||
t.stack = stack;
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
for t in &mut self.thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
/// A server's move: thrown potions have no interpolation handler, so
|
||||
/// `moveOrInterpolateTo` puts them there at once. False when `id` isn't one.
|
||||
pub(super) fn move_remote_thrown(&mut self, id: u32, at: Vec3) -> bool {
|
||||
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
|
||||
return false;
|
||||
};
|
||||
t.position = at;
|
||||
true
|
||||
}
|
||||
/// `handleSetEntityMotion` for a thrown potion. False when `id` isn't one.
|
||||
pub(super) fn remote_thrown_motion(&mut self, id: u32, velocity: Vec3) -> bool {
|
||||
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
|
||||
return false;
|
||||
};
|
||||
t.velocity = velocity;
|
||||
true
|
||||
}
|
||||
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
@@ -266,7 +276,8 @@ impl Game<'_> {
|
||||
} else if let Some(rest) = self.give(filled)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
|
||||
// `player.drop(stack, false)`.
|
||||
self.throw_from_eyes(rest, eye, false);
|
||||
}
|
||||
}
|
||||
/// Drinking a potion while right-click is held; true when handled.
|
||||
@@ -390,8 +401,28 @@ impl Game<'_> {
|
||||
for action in actions {
|
||||
self.apply_effect_action(action);
|
||||
}
|
||||
self.turtle_helmet_tick();
|
||||
self.apply_effect_status();
|
||||
}
|
||||
/// `Player.tick` -> `turtleHelmetTick`: with a turtle helmet on the head
|
||||
/// and the eyes out of the water, Water Breathing for 200 ticks (no
|
||||
/// particles, an icon).
|
||||
fn turtle_helmet_tick(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let eye_y = p.position.y + f64::from(p.dimensions.eye_height);
|
||||
if !p.health.dead_or_dying()
|
||||
&& self.armor[0]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:turtle_helmet")
|
||||
&& !self.eye_in_water(p.position.x, eye_y, p.position.z)
|
||||
{
|
||||
let mut effect = EffectInstance::new("water_breathing", 200, 0);
|
||||
effect.visible = false;
|
||||
self.potions.effects.add(effect);
|
||||
}
|
||||
}
|
||||
/// A client's own effects (`LivingEntity.tickEffects` on the client,
|
||||
/// `MobEffectInstance.tickClient`): they count down, and the server
|
||||
/// removes them. Their modifiers and flags move the predicted player.
|
||||
@@ -484,6 +515,7 @@ impl Game<'_> {
|
||||
thrown.retain_mut(|t| {
|
||||
t.age += 1;
|
||||
t.previous = t.position;
|
||||
self.thrown_potion_motion(t);
|
||||
let next = Vec3::new(
|
||||
t.position.x + t.velocity.x,
|
||||
t.position.y + t.velocity.y,
|
||||
@@ -520,18 +552,6 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
// ThrowableItemProjectile: drag 0.99 then gravity 0.05.
|
||||
// ThrownExperienceBottle.getDefaultGravity is 0.07.
|
||||
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
|
||||
0.07
|
||||
} else {
|
||||
0.05
|
||||
};
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x * 0.99,
|
||||
t.velocity.y * 0.99 - gravity,
|
||||
t.velocity.z * 0.99,
|
||||
);
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
@@ -539,6 +559,60 @@ impl Game<'_> {
|
||||
self.splash(&stack, at);
|
||||
}
|
||||
}
|
||||
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
|
||||
/// come before the move: gravity 0.05 (0.07 for an experience bottle),
|
||||
/// then drag 0.99 (0.8 in water).
|
||||
fn thrown_potion_motion(&self, t: &mut ThrownPotion) {
|
||||
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
|
||||
0.07
|
||||
} else {
|
||||
0.05
|
||||
};
|
||||
let at = t.position;
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
let inertia = if water { 0.8 } else { 0.99 };
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x * inertia,
|
||||
(t.velocity.y - gravity) * inertia,
|
||||
t.velocity.z * inertia,
|
||||
);
|
||||
}
|
||||
/// `ThrowableProjectile.tick` on a server's client: it falls and moves
|
||||
/// by itself between the server's updates and stops at a block; only
|
||||
/// the server breaks it.
|
||||
pub(super) fn step_remote_thrown(&mut self) {
|
||||
let mut thrown = std::mem::take(&mut self.potions.thrown);
|
||||
for t in &mut thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
self.thrown_potion_motion(t);
|
||||
let next = Vec3::new(
|
||||
t.position.x + t.velocity.x,
|
||||
t.position.y + t.velocity.y,
|
||||
t.position.z + t.velocity.z,
|
||||
);
|
||||
let state = self.world.block(
|
||||
next.x.floor() as i32,
|
||||
next.y.floor() as i32,
|
||||
next.z.floor() as i32,
|
||||
);
|
||||
if self.res.facts().get(state).full_collision {
|
||||
t.velocity = Vec3::ZERO;
|
||||
} else {
|
||||
t.position = next;
|
||||
}
|
||||
}
|
||||
self.potions.thrown = thrown;
|
||||
}
|
||||
/// `ThrownItemRenderer` for thrown splash and lingering potions and
|
||||
/// experience bottles: the item (GROUND) facing the camera at scale 1,
|
||||
/// lit at the light probe (the eye, 0.85 of the 0.25 height).
|
||||
|
||||
@@ -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;
|
||||
|
||||
Loaded 100 of 470 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user