mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Merge origin/main into fix/witch-drinking
Keeps main's sitting/ridden fields on Seen beside this branch's effects. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
f8415efe29
420 files changed
+13936
-18050
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
+9
@@ -1368,6 +1368,7 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -1426,6 +1427,14 @@ dependencies = [
|
||||
"spake2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-hdr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+68
-54
@@ -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.
|
||||
@@ -262,6 +262,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::QuietCommands { lines, .. } => {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
|
||||
@@ -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());
|
||||
@@ -1091,7 +1098,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
|
||||
@@ -7,7 +7,7 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
@@ -161,7 +161,15 @@ fn scene(
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let ([x, y, z], [u, w]) = (v.pos, v.uv());
|
||||
json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
|
||||
json!([
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
u,
|
||||
w,
|
||||
argb(v.color | EMISSION_BITS),
|
||||
light_coords(v.light)
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
mobs::{Mob, MobTypes, aquatic},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
let types = MobTypes::load(&root)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let kind = Arc::new(
|
||||
types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(),
|
||||
);
|
||||
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
mob.fluids.in_water = row["water"].as_bool().unwrap();
|
||||
mob.velocity = Vec3::new(
|
||||
row["x"].as_f64().unwrap(),
|
||||
row["y"].as_f64().unwrap(),
|
||||
row["z"].as_f64().unwrap(),
|
||||
);
|
||||
mob.body_yaw = row["body"].as_f64().unwrap() as f32;
|
||||
for (i, value) in row["v"].as_array().unwrap().iter().enumerate() {
|
||||
mob.anim.v[i] = value.as_f64().unwrap() as f32;
|
||||
}
|
||||
if row["reset"].as_bool().unwrap() {
|
||||
aquatic::squid_cycle_event(&mut mob);
|
||||
}
|
||||
let velocity = mob.velocity;
|
||||
aquatic::squid_client_step(&mut mob);
|
||||
assert_eq!(
|
||||
velocity, mob.velocity,
|
||||
"a remote animation must preserve packet velocity"
|
||||
);
|
||||
write!(out, "{:08x}", mob.body_yaw.to_bits())?;
|
||||
for value in &mob.anim.v[..6] {
|
||||
write!(out, " {:08x}", value.to_bits())?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,9 +94,66 @@ pub enum BlockEntity {
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
|
||||
let Some(slot) = self.items.iter().position(Option::is_none) else {
|
||||
return false;
|
||||
};
|
||||
self.total[slot] = cooking_time;
|
||||
self.progress[slot] = 0;
|
||||
self.items[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..held.clone()
|
||||
});
|
||||
held.count = held.count.saturating_sub(1);
|
||||
true
|
||||
}
|
||||
/// `cookTick` (lit): each item's progress rises by one; the slots whose
|
||||
/// progress reached their cooking time give up their item (the caller
|
||||
/// drops its result).
|
||||
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
|
||||
let mut done = Vec::new();
|
||||
for i in 0..4 {
|
||||
if self.items[i].is_none() {
|
||||
continue;
|
||||
}
|
||||
self.progress[i] += 1;
|
||||
if self.progress[i] >= self.total[i]
|
||||
&& let Some(item) = self.items[i].take()
|
||||
{
|
||||
done.push((i, item));
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
|
||||
/// Whether anything changed (`setChanged`).
|
||||
pub fn cooldown_tick(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
for i in 0..4 {
|
||||
if self.progress[i] > 0 {
|
||||
changed = true;
|
||||
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
@@ -304,6 +372,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:brewing_stand" {
|
||||
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
|
||||
}
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -341,6 +412,7 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -362,6 +434,7 @@ impl BlockEntity {
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -382,7 +455,9 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &[],
|
||||
| Self::CommandBlock(_)
|
||||
// Not a Container (only Clearable): hoppers don't reach in.
|
||||
| Self::Campfire(_) => &[],
|
||||
}
|
||||
}
|
||||
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
|
||||
@@ -403,7 +478,8 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &mut [],
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Campfire(_) => &mut [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +714,10 @@ impl Furnace {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
@@ -692,6 +771,41 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
let mut beef = ItemStack::new("minecraft:beef", 2);
|
||||
// placeFood: the first free slot, one item, its recipe's time.
|
||||
assert!(c.place_food(&mut beef, 600));
|
||||
assert_eq!(beef.count, 1);
|
||||
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
|
||||
assert_eq!((c.progress[0], c.total[0]), (0, 600));
|
||||
let mut fish = ItemStack::new("minecraft:cod", 1);
|
||||
assert!(c.place_food(&mut fish, 3));
|
||||
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
|
||||
// cookTick: the cod is done on its third tick, the beef keeps cooking.
|
||||
assert!(c.cook_tick().is_empty());
|
||||
assert!(c.cook_tick().is_empty());
|
||||
let done = c.cook_tick();
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
|
||||
assert!(c.items[1].is_none());
|
||||
assert_eq!(c.progress[0], 3);
|
||||
// cooldownTick: two a tick, never below zero.
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 1);
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 0);
|
||||
// Full: four slots, no fifth.
|
||||
for _ in 0..3 {
|
||||
let mut more = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(c.place_food(&mut more, 600));
|
||||
}
|
||||
let mut fifth = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(!c.place_food(&mut fifth, 600));
|
||||
assert_eq!(fifth.count, 1);
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -591,6 +591,57 @@ impl Painting {
|
||||
)
|
||||
}
|
||||
}
|
||||
/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when
|
||||
/// the name is unknown.
|
||||
pub fn dye_index(name: &str) -> u8 {
|
||||
crate::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|n| *n == name)
|
||||
.unwrap_or(0) as u8
|
||||
}
|
||||
/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow
|
||||
/// that seats a player. It sits on the top face of a block, at the clicked
|
||||
/// height, and is coloured by its `DyeColor`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cushion {
|
||||
pub id: u32,
|
||||
/// `Entity.position`: the bottom centre of the cushion.
|
||||
pub position: Vec3,
|
||||
/// `BlockAttachedEntity.pos` (the saved `block_pos`).
|
||||
pub pos: [i32; 3],
|
||||
pub yaw: f32,
|
||||
/// `DyeColor` ordinal: white 0, orange 1, ..., black 15.
|
||||
pub color: u8,
|
||||
/// BlockAttachedEntity.ticksSinceLastCheck.
|
||||
pub check_interval: u32,
|
||||
}
|
||||
impl Cushion {
|
||||
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
|
||||
pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
|
||||
let p = self.position;
|
||||
(
|
||||
[p.x - 0.5, p.y, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 0.25, p.z + 0.5],
|
||||
)
|
||||
}
|
||||
/// The `DyeColor` name of the cushion.
|
||||
pub fn color_name(&self) -> &'static str {
|
||||
crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))]
|
||||
}
|
||||
/// `Cushion.getCushionItemStackWithData`: the matching coloured item.
|
||||
pub fn item(&self) -> String {
|
||||
format!("minecraft:{}_cushion", self.color_name())
|
||||
}
|
||||
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
|
||||
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
|
||||
pub fn seat(&self) -> Vec3 {
|
||||
Vec3::new(self.position.x, self.position.y - 0.35, self.position.z)
|
||||
}
|
||||
/// `Entity.getDismountLocationForPassenger`: on top of the cushion.
|
||||
pub fn dismount(&self) -> Vec3 {
|
||||
Vec3::new(self.position.x, self.position.y + 0.25, self.position.z)
|
||||
}
|
||||
}
|
||||
/// An item frame or glow item frame (`ItemFrame`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemFrame {
|
||||
|
||||
@@ -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::{
|
||||
@@ -1313,6 +1313,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
|
||||
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
|
||||
Some((pos, facing, text(tag.get("variant"))?))
|
||||
}
|
||||
/// Cushion.addAdditionalSaveData: the block it sits on and its colour.
|
||||
pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: "minecraft:cushion",
|
||||
uuid,
|
||||
position: c.position,
|
||||
motion: Vec3::ZERO,
|
||||
rotation: [c.yaw, 0.0],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec()));
|
||||
put(&mut tag, "color", string(c.color_name()));
|
||||
tag
|
||||
}
|
||||
/// The position, block position, yaw and colour of a saved cushion.
|
||||
pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option<Cushion> {
|
||||
let position = position(tag)?;
|
||||
let pos = match tag.get("block_pos") {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
|
||||
_ => [
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
],
|
||||
};
|
||||
let yaw = match tag.get("Rotation") {
|
||||
Some(Tag::List(l)) => l
|
||||
.first()
|
||||
.and_then(|v| crate::save::float(Some(v)))
|
||||
.unwrap_or(0.0) as f32,
|
||||
_ => 0.0,
|
||||
};
|
||||
let color =
|
||||
crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into()));
|
||||
Some(Cushion {
|
||||
id,
|
||||
position,
|
||||
pos,
|
||||
yaw,
|
||||
color,
|
||||
check_interval: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cushion_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() {
|
||||
let cushion = Cushion {
|
||||
id: 7,
|
||||
position: Vec3::new(-1.5, 64.5, 3.5),
|
||||
pos: [-2, 64, 3],
|
||||
yaw: 270.0,
|
||||
color: 14,
|
||||
check_interval: 55,
|
||||
};
|
||||
let raw = Tag::compound([("CustomName", string("Seat"))]);
|
||||
let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw));
|
||||
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat"));
|
||||
let loaded = cushion_from_nbt(&tag, 8).unwrap();
|
||||
assert_eq!(loaded.id, 8);
|
||||
assert_eq!(loaded.position, cushion.position);
|
||||
assert_eq!(loaded.pos, cushion.pos);
|
||||
assert_eq!(loaded.yaw, cushion.yaw);
|
||||
assert_eq!(loaded.color_name(), "red");
|
||||
assert_eq!(loaded.check_interval, 0);
|
||||
assert_eq!(loaded.bounds(), cushion.bounds());
|
||||
}
|
||||
}
|
||||
/// ItemFrame.addAdditionalSaveData.
|
||||
pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let (lo, hi) = f.bounds();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,8 @@ mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
mod breezes;
|
||||
mod campfire;
|
||||
mod campfire_douse;
|
||||
mod cartography;
|
||||
mod chat;
|
||||
mod client_ambient;
|
||||
@@ -33,6 +35,7 @@ mod copper_golems;
|
||||
mod creaking;
|
||||
mod creatures;
|
||||
mod crossbow;
|
||||
pub(crate) mod cushions;
|
||||
mod debug_keys;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
@@ -80,6 +83,7 @@ mod lan_mobs;
|
||||
mod lan_mount_menu;
|
||||
mod lan_particles;
|
||||
mod lan_pvp;
|
||||
mod lan_quiet;
|
||||
mod lan_screen;
|
||||
mod lan_stations;
|
||||
mod lan_trading;
|
||||
@@ -147,6 +151,7 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
@@ -552,6 +557,11 @@ struct GameLevel<'w> {
|
||||
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
rules: horizon_world::level::GameRules,
|
||||
/// `Difficulty.getId()` (peaceful 0 .. hard 3).
|
||||
difficulty: i32,
|
||||
/// The feet of the players who keep fire spreading around them: the local
|
||||
/// player and the LAN guests in this level, spectators aside.
|
||||
fire_players: Vec<[f64; 3]>,
|
||||
/// Block loot random sequences (`ServerLevel.getRandomSequence`).
|
||||
rand_sequences: &'w mut horizon_client::random_sequences::RandomSequences,
|
||||
pending: &'w mut spill::Pending,
|
||||
@@ -561,6 +571,9 @@ struct GameLevel<'w> {
|
||||
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
|
||||
/// Players with each chest open (a trapped chest's signal).
|
||||
lid_openers: &'w HashMap<(i32, i32, i32), i32>,
|
||||
/// `Level.isClientSide`: a client's copy of the level (a guest of a LAN
|
||||
/// world, a window on its server thread, a dedicated server's client).
|
||||
client_side: bool,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -650,6 +663,9 @@ impl GameLevel<'_> {
|
||||
}
|
||||
}
|
||||
impl LevelAccess for GameLevel<'_> {
|
||||
fn is_client_side(&self) -> bool {
|
||||
self.client_side
|
||||
}
|
||||
fn world_surface(&self, x: i32, z: i32) -> i32 {
|
||||
self.world.world_surface(x, z, |s| self.facts.is_air(s))
|
||||
}
|
||||
@@ -715,6 +731,34 @@ impl LevelAccess for GameLevel<'_> {
|
||||
fn game_rules(&self) -> horizon_world::level::GameRules {
|
||||
self.rules
|
||||
}
|
||||
fn difficulty(&self) -> i32 {
|
||||
self.difficulty
|
||||
}
|
||||
/// `ServerLevel.canSpreadFireAround`: a player (not a spectator) closer
|
||||
/// than the `fire_spread_radius_around_player` rule to the block's corner
|
||||
/// (`ChunkMap.anyPlayerCloseEnoughTo`); -1 spreads everywhere.
|
||||
fn can_spread_fire_around(&self, pos: BlockPos) -> bool {
|
||||
let radius = self.rules.fire_spread_radius_around_player;
|
||||
radius == -1
|
||||
|| self.fire_players.iter().any(|p| {
|
||||
let d = [
|
||||
p[0] - f64::from(pos.x),
|
||||
p[1] - f64::from(pos.y),
|
||||
p[2] - f64::from(pos.z),
|
||||
];
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < f64::from(radius) * f64::from(radius)
|
||||
})
|
||||
}
|
||||
fn increased_fire_burnout(&self, pos: BlockPos) -> bool {
|
||||
self.world
|
||||
.chunks
|
||||
.get(&(pos.x >> 4, pos.z >> 4))
|
||||
.is_some_and(|chunk| {
|
||||
self.biomes
|
||||
.get(chunk.biome(pos.x, pos.y, pos.z))
|
||||
.increased_fire_burnout
|
||||
})
|
||||
}
|
||||
fn block_state(&self, pos: BlockPos) -> BlockStateId {
|
||||
self.world.block(pos.x, pos.y, pos.z)
|
||||
}
|
||||
@@ -853,7 +897,16 @@ impl LevelAccess for GameLevel<'_> {
|
||||
fn emit(&mut self, event: LevelEvent) {
|
||||
match event {
|
||||
// Block.dropResources draws its loot and spots right away.
|
||||
LevelEvent::DropResources { pos, state } => self.drop_resources_now(pos, state),
|
||||
// `popResource` only drops server-side (`!level.isClientSide`):
|
||||
// a client's moveBlocks (a piston's block event) skips it.
|
||||
LevelEvent::DropResources { pos, state } if !self.client_side => {
|
||||
self.drop_resources_now(pos, state)
|
||||
}
|
||||
LevelEvent::DropResources { .. } => {}
|
||||
// `ClientLevel.playSeededSound` plays only when the excluded
|
||||
// player is this client's own: the block's `playSound(null, ...)`
|
||||
// (a piston's move, a note) reaches it from the server instead.
|
||||
LevelEvent::Sound { .. } if self.client_side => {}
|
||||
// Block.popResource of the block's own item (tall flowers, flower beds).
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
@@ -1266,6 +1319,13 @@ struct Game<'a> {
|
||||
/// Controllers (Horizon's own).
|
||||
pad: pad::Pads,
|
||||
frames: u64,
|
||||
/// `item_level` for one frame, game time, day time and dimension.
|
||||
item_level_cache: std::cell::RefCell<
|
||||
Option<(
|
||||
(u64, i64, i64, &'static str),
|
||||
horizon_assets::item_model::LevelState,
|
||||
)>,
|
||||
>,
|
||||
/// Frames drawn with the player in the world, for `--world-frames`.
|
||||
world_frames: u64,
|
||||
fps: f32,
|
||||
@@ -1429,6 +1489,8 @@ struct Game<'a> {
|
||||
map_frame_model: frames::FrameModel,
|
||||
glow_map_frame_model: frames::FrameModel,
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
/// `Cushion` decoration entities (game/cushions.rs).
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
/// Entities of types Horizon does not simulate.
|
||||
others: Vec<commands::OtherEntity>,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
@@ -1444,6 +1506,8 @@ struct Game<'a> {
|
||||
boat_models: boats::BoatModels,
|
||||
/// EndCrystalModel's baked layer.
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
/// CushionModel's baked layer.
|
||||
cushion_model: Option<horizon_client::mobs::Part>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
@@ -1580,6 +1644,8 @@ struct Game<'a> {
|
||||
/// position) for the guests.
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
/// Successful crits (attacking guest, hit entity), queued while the hub is taken.
|
||||
lan_critical_hits: Vec<(Option<u32>, i32)>,
|
||||
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
|
||||
lan_global_events: Vec<(i32, [i32; 3], i32)>,
|
||||
/// `ContainerOpenersCounter`: how many players (the host and the guests)
|
||||
@@ -2452,6 +2518,19 @@ impl Game<'_> {
|
||||
Dim::End => LevelDimension::End,
|
||||
};
|
||||
let rules = self.level_rules();
|
||||
let fire_players: Vec<[f64; 3]> = self
|
||||
.player
|
||||
.iter()
|
||||
.filter(|_| !spectator)
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
.chain(self.lan.iter().flat_map(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| h.hub.here(g) && !g.spectator())
|
||||
.map(|g| g.position)
|
||||
}))
|
||||
.collect();
|
||||
let blockers = Blockers {
|
||||
mobs: &self.mobs,
|
||||
armor_stands: &self.armor_stands,
|
||||
@@ -2482,12 +2561,15 @@ impl Game<'_> {
|
||||
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
|
||||
registry: self.pack.assets.registry,
|
||||
rules,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
fire_players,
|
||||
rand_sequences: &mut self.rand_sequences,
|
||||
pending: &mut self.pending,
|
||||
spill: &mut self.spill,
|
||||
tickets: self.tickets.levels(),
|
||||
random_tick_sections: &mut self.random_tick_sections,
|
||||
lid_openers: &self.lid_openers,
|
||||
client_side: self.remote.is_some(),
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
@@ -2549,14 +2631,15 @@ impl Game<'_> {
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
));
|
||||
}
|
||||
let sources = (self.options.dedicated.is_some() || !players.is_empty()).then(|| {
|
||||
horizon_client::ticket_levels::Sources {
|
||||
players,
|
||||
forced: self.streaming.forced.iter().copied().collect(),
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.simulation_distance.max(2),
|
||||
}
|
||||
});
|
||||
let sources =
|
||||
(!self.options.execution_role.is_client() || !players.is_empty()).then(|| {
|
||||
horizon_client::ticket_levels::Sources {
|
||||
players,
|
||||
forced: self.streaming.forced.iter().copied().collect(),
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.simulation_distance.max(2),
|
||||
}
|
||||
});
|
||||
self.tickets.update(sources);
|
||||
}
|
||||
/// `ServerLevel.startTickingChunk`: a loaded chunk's saved ticks are
|
||||
@@ -2785,6 +2868,9 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.consume_teleport_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent { id: 2003, pos, .. } => {
|
||||
self.eye_break_particles([pos.x, pos.y, pos.z]);
|
||||
}
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2013,
|
||||
pos,
|
||||
@@ -3333,9 +3419,17 @@ impl Game<'_> {
|
||||
self.with_player_actor(|game| game.break_block_as_player(pos))
|
||||
}
|
||||
fn break_block_as_player(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
if !self
|
||||
.res
|
||||
.items()
|
||||
.can_destroy_block(self.held(), self.creative)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
|
||||
// ServerPlayerGameMode.destroyBlock.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let broken = state;
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
let state = self.crack_mined_pot(pos, state);
|
||||
// Block.playerWillDestroy: mining their gold angers all nearby piglins.
|
||||
@@ -3367,6 +3461,9 @@ impl Game<'_> {
|
||||
creative,
|
||||
)
|
||||
});
|
||||
if removed {
|
||||
self.mod_block_event(pos, broken, false);
|
||||
}
|
||||
if removed && !creative {
|
||||
let held = self.held().cloned();
|
||||
self.mine_block_with_held(&facts.name, facts.hardness);
|
||||
@@ -3475,6 +3572,7 @@ impl Game<'_> {
|
||||
fn interact_entity(&mut self) -> Result<bool> {
|
||||
Ok(self.interact_frame()
|
||||
|| self.interact_armor_stand()
|
||||
|| self.interact_cushion()
|
||||
|| self.interact_boat()
|
||||
|| self.interact_cart()
|
||||
|| self.interact_mob()?
|
||||
@@ -3536,6 +3634,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_campfire_douse(hit.pos, held.as_ref())? {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
match self.use_respawn_anchor(hit.pos, held.as_ref())? {
|
||||
respawn_anchor::AnchorUse::Charged => {
|
||||
self.swing_arm();
|
||||
@@ -3661,6 +3763,7 @@ impl Game<'_> {
|
||||
|| self.place_painting(hit, &stack)
|
||||
|| self.place_item_frame(hit, &stack)
|
||||
|| self.place_armor_stand(hit, &stack)
|
||||
|| self.place_cushion(hit, &stack)
|
||||
|| self.place_end_crystal(hit, &stack))
|
||||
{
|
||||
return Ok(true);
|
||||
@@ -3709,6 +3812,7 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
};
|
||||
let target = [pos.x, pos.y, pos.z];
|
||||
self.mod_block_event(target, state, true);
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
|
||||
let key = (target[0], target[1], target[2]);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
@@ -3951,7 +4055,12 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
// GameType.isBlockPlacingRestricted: adventure and spectator.
|
||||
if !self.may_build() {
|
||||
if !self.may_build()
|
||||
|| !self
|
||||
.res
|
||||
.items()
|
||||
.can_destroy_block(self.held(), self.creative)
|
||||
{
|
||||
self.mining = None;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -4225,8 +4334,9 @@ impl Game<'_> {
|
||||
if running {
|
||||
self.world.game_time += 1;
|
||||
if self.ui.rules.bool("advance_weather") {
|
||||
self.world.weather.tick(&mut self.world.random);
|
||||
self.world.weather.tick_timers(&mut self.world.random);
|
||||
}
|
||||
self.world.weather.tick_levels();
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
if self.ticks.is_multiple_of(6000) {
|
||||
@@ -4242,6 +4352,7 @@ impl Game<'_> {
|
||||
self.update_totem_ready();
|
||||
self.tick_command_extras();
|
||||
self.tick_mods();
|
||||
self.tick_server_mods();
|
||||
let camera_eye = self.camera_eye_height();
|
||||
let stick = self.pad_move();
|
||||
if self.riding.is_some() && self.player.is_some() {
|
||||
@@ -4544,6 +4655,7 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
|
||||
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
|
||||
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands());
|
||||
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
|
||||
horizon_client::spans::time("tick_boats", || self.tick_boats());
|
||||
horizon_client::spans::time("tick_carts", || self.tick_carts());
|
||||
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
|
||||
@@ -4752,7 +4864,10 @@ impl Game<'_> {
|
||||
if capture && !self.focused {
|
||||
return;
|
||||
}
|
||||
if !capture {
|
||||
if capture {
|
||||
// The inventory's screens are behind us: no drag survives them.
|
||||
self.reset_inventory_clicks();
|
||||
} else {
|
||||
self.stop_mining();
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
@@ -5278,7 +5393,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::Skull(_),
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::Campfire(_),
|
||||
) => {}
|
||||
None => {
|
||||
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
|
||||
@@ -5548,7 +5664,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::Skull(_),
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::Campfire(_),
|
||||
) => {}
|
||||
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
|
||||
None => {
|
||||
@@ -5591,7 +5708,18 @@ impl Game<'_> {
|
||||
self.draw_inventory_effects(batch, (ox, oy), 176);
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen; Horizon keeps them on the game, so they are
|
||||
/// dropped when a screen closes. A press whose release never reaches the
|
||||
/// screen (the pointer left the window, or the screen closed first) would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks = Default::default();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
pub(super) fn close_container(&mut self) {
|
||||
self.reset_inventory_clicks();
|
||||
self.close_station();
|
||||
self.mount_closed();
|
||||
self.recipe_ui.reset_screen();
|
||||
@@ -5687,8 +5815,9 @@ impl Game<'_> {
|
||||
if self.creative_screen.tab().search {
|
||||
// CreativeModeInventoryScreen.slotClicked: moveCursorToEnd, then
|
||||
// setHighlightPos(0), so typing replaces the whole query.
|
||||
let search = &self.creative_screen.search;
|
||||
self.text_selection.select(("creative.search", 0), search);
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.move_cursor_to_end(false);
|
||||
search.highlight = 0;
|
||||
}
|
||||
let items = self.creative_screen.items(&self.pack.names);
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
@@ -6330,6 +6459,13 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
if outside {
|
||||
// AbstractRecipeBookScreen.mouseClicked: the book takes the click
|
||||
// before the outside test, and its tabs sit left of the strip
|
||||
// hasClickedOutside counts as the book.
|
||||
if self.recipe_book_click(right) {
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
// slotId -999: an empty hand throws nothing; a carried stack
|
||||
// starts a drag whose release drops it.
|
||||
match input {
|
||||
@@ -6895,6 +7031,9 @@ impl Game<'_> {
|
||||
self.prepare_entity_culling(camera);
|
||||
self.prepare_trims();
|
||||
self.mark_entity(out.len(), "minecraft:entity_flame");
|
||||
// One scan for both passes that draw them (block entities below and the
|
||||
// overlay near the end of this function).
|
||||
let cracks = self.cracks(camera);
|
||||
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
|
||||
self.yaw + 180.0
|
||||
} else {
|
||||
@@ -6991,6 +7130,9 @@ impl Game<'_> {
|
||||
// `VaultRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.vault_geometry(camera, alpha, &mut out);
|
||||
// `CampfireRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.campfire_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
// Mobs and their projectiles (a span each).
|
||||
@@ -7120,7 +7262,7 @@ impl Game<'_> {
|
||||
.render(&scene, visible, &mut out);
|
||||
}
|
||||
self.mark_stage(out.len(), "DESTROY");
|
||||
for (pos, stage) in self.cracks(camera) {
|
||||
for &(pos, stage) in &cracks {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.res
|
||||
.block_entity_render()
|
||||
@@ -7233,6 +7375,7 @@ impl Game<'_> {
|
||||
self.frame_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:armor_stand");
|
||||
self.armor_stand_geometry(camera, alpha, &mut out);
|
||||
self.cushion_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:oak_boat");
|
||||
self.boat_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:minecart");
|
||||
@@ -7263,7 +7406,7 @@ impl Game<'_> {
|
||||
);
|
||||
// Breaking progress overlays (destroy_stage_0..9): ours and the other players'.
|
||||
self.mark_stage(out.len(), "DESTROY");
|
||||
for (pos, stage) in self.cracks(camera) {
|
||||
for &(pos, stage) in &cracks {
|
||||
let o = (glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
- camera)
|
||||
.as_vec3();
|
||||
@@ -7311,6 +7454,20 @@ impl Game<'_> {
|
||||
/// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the
|
||||
/// furthest progress on each block).
|
||||
fn cracks(&self, camera: glam::DVec3) -> Vec<([i32; 3], [f32; 2])> {
|
||||
// `destroy_stage_0` .. `destroy_stage_9`: the sprite name by stage, so the
|
||||
// lookup does not format a string per crack.
|
||||
const DESTROY_STAGES: [&str; 10] = [
|
||||
"block/destroy_stage_0",
|
||||
"block/destroy_stage_1",
|
||||
"block/destroy_stage_2",
|
||||
"block/destroy_stage_3",
|
||||
"block/destroy_stage_4",
|
||||
"block/destroy_stage_5",
|
||||
"block/destroy_stage_6",
|
||||
"block/destroy_stage_7",
|
||||
"block/destroy_stage_8",
|
||||
"block/destroy_stage_9",
|
||||
];
|
||||
let mut stages: Vec<([i32; 3], u32)> = Vec::new();
|
||||
let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) {
|
||||
Some(c) => c.1 = c.1.max(stage),
|
||||
@@ -7336,11 +7493,8 @@ impl Game<'_> {
|
||||
d.length_squared() <= 1024.0
|
||||
})
|
||||
.filter_map(|(pos, stage)| {
|
||||
let r = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("block/destroy_stage_{stage}"))?;
|
||||
let name = DESTROY_STAGES.get(stage as usize)?;
|
||||
let r = self.gui.atlas.sprites.get(*name)?;
|
||||
Some((pos, [r.u0, r.v0]))
|
||||
})
|
||||
.collect()
|
||||
@@ -7518,6 +7672,7 @@ impl Game<'_> {
|
||||
.map_or(0.0, |i| i.attack_damage);
|
||||
if self.attack_frame()
|
||||
|| self.attack_armor_stand()
|
||||
|| self.attack_cushion()
|
||||
|| self.attack_boat(hand_damage)
|
||||
|| self.attack_cart(hand_damage)
|
||||
|| self.attack_dragon()
|
||||
@@ -7605,10 +7760,9 @@ impl Game<'_> {
|
||||
self.stats
|
||||
.custom("damage_dealt", (actual * 10.0).round() as i32);
|
||||
if crit {
|
||||
self.particles.tracking_emitter(
|
||||
horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::Crit,
|
||||
),
|
||||
self.critical_hit(
|
||||
self.lan_acting,
|
||||
self.mobs[index].id as i32,
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
@@ -7990,6 +8144,7 @@ impl Game<'_> {
|
||||
/// (scaled coordinates, or the End portal's fixed destination).
|
||||
pub(super) fn switch_level(&mut self, target: Dim, from: Vec3) -> Result<Vec3> {
|
||||
// Save the departing dimension's changed chunks.
|
||||
self.save.save_forced_chunks(&self.streaming.forced)?;
|
||||
self.streaming.save(&mut self.world, true);
|
||||
let keys: Vec<_> = self.world.modified.drain().collect();
|
||||
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
|
||||
@@ -8263,6 +8418,7 @@ impl Game<'_> {
|
||||
self.boats.clear();
|
||||
self.carts.clear();
|
||||
self.armor_stands.clear();
|
||||
self.cushions.clear();
|
||||
self.item_frames.clear();
|
||||
self.paintings.clear();
|
||||
self.others.clear();
|
||||
@@ -8681,6 +8837,13 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(UseResult::Success { swing: true });
|
||||
}
|
||||
if allow_block_actions
|
||||
&& let Some(hit) = self.pick()
|
||||
&& let Some(placed) = self.use_on_campfire(hit.pos)?
|
||||
{
|
||||
// SUCCESS_SERVER swings; a full campfire's CONSUME does not.
|
||||
return Ok(UseResult::Success { swing: placed });
|
||||
}
|
||||
// BucketItem has no useOn: a guest's UseItemOn leaves it to UseItem.
|
||||
let bucket = horizon_client::items::bucket_contents(&stack.item);
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
@@ -9579,8 +9742,9 @@ impl Game<'_> {
|
||||
rain.extend(snow_vertices);
|
||||
rain
|
||||
}
|
||||
/// Player.startSleepInBed: night or thunder, no monsters within 8 blocks;
|
||||
/// the bed becomes the respawn point either way.
|
||||
/// ServerPlayer.startSleepInBed: in reach, not obstructed, night or thunder,
|
||||
/// and (outside creative) no monsters within 8 blocks; the bed becomes the
|
||||
/// respawn point either way.
|
||||
fn try_sleep(&mut self, pos: [i32; 3]) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
@@ -9595,22 +9759,40 @@ impl Game<'_> {
|
||||
.state(state)
|
||||
.map(|s| s.properties.clone())
|
||||
.unwrap_or_default();
|
||||
let head = if props.get("part").map(String::as_str) == Some("foot") {
|
||||
let d = facing_offset(props.get("facing").map_or("north", String::as_str));
|
||||
[pos[0] + d[0], pos[1], pos[2] + d[2]]
|
||||
// The head and the foot: the block faces from the foot to the head.
|
||||
let d = facing_offset(props.get("facing").map_or("north", String::as_str));
|
||||
let (head, foot) = if props.get("part").map(String::as_str) == Some("foot") {
|
||||
([pos[0] + d[0], pos[1], pos[2] + d[2]], pos)
|
||||
} else {
|
||||
pos
|
||||
(pos, [pos[0] - d[0], pos[1], pos[2] - d[2]])
|
||||
};
|
||||
let dist = ((p.position.x - f64::from(head[0]) - 0.5).powi(2)
|
||||
+ (p.position.z - f64::from(head[2]) - 0.5).powi(2))
|
||||
.sqrt();
|
||||
if dist > 3.0 {
|
||||
// ServerPlayer.bedInRange / isReachableBedBlock: within 3 blocks
|
||||
// sideways and 2 vertically of the head or the foot.
|
||||
let reachable = |bed: [i32; 3]| {
|
||||
(p.position.x - f64::from(bed[0]) - 0.5).abs() <= 3.0
|
||||
&& (p.position.y - f64::from(bed[1])).abs() <= 2.0
|
||||
&& (p.position.z - f64::from(bed[2]) - 0.5).abs() <= 3.0
|
||||
};
|
||||
if !reachable(head) && !reachable(foot) {
|
||||
self.message = Some((
|
||||
"You may not rest now; the bed is too far away".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.bedBlocked: Player.freeAt (not suffocating) for the
|
||||
// blocks above the head and the foot, as `in_wall` reads suffocating.
|
||||
let blocked = [head, foot].iter().any(|bed| {
|
||||
let f = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(bed[0], bed[1] + 1, bed[2]));
|
||||
f.full_collision && f.solid_render
|
||||
});
|
||||
if blocked {
|
||||
self.message = Some(("This bed is obstructed".into(), Instant::now()));
|
||||
return;
|
||||
}
|
||||
// In reach (bedInRange), day or night: setRespawnPosition(
|
||||
// RespawnConfig(the head, the player's yaw), not forced). It replaces
|
||||
// an anchor or a spawnpoint; respawning checks the bed is still there
|
||||
@@ -9640,13 +9822,15 @@ impl Game<'_> {
|
||||
));
|
||||
return;
|
||||
}
|
||||
let monsters = self.mobs.iter().any(|m| {
|
||||
m.hostile()
|
||||
&& m.alive()
|
||||
&& (m.position.x - p.position.x).abs() < 8.0
|
||||
&& (m.position.y - p.position.y).abs() < 5.0
|
||||
&& (m.position.z - p.position.z).abs() < 8.0
|
||||
});
|
||||
// Creative players skip the monster check (`!this.isCreative()`).
|
||||
let monsters = !self.creative
|
||||
&& self.mobs.iter().any(|m| {
|
||||
m.hostile()
|
||||
&& m.alive()
|
||||
&& (m.position.x - p.position.x).abs() < 8.0
|
||||
&& (m.position.y - p.position.y).abs() < 5.0
|
||||
&& (m.position.z - p.position.z).abs() < 8.0
|
||||
});
|
||||
if monsters {
|
||||
self.message = Some((
|
||||
"You may not rest now; there are monsters nearby".into(),
|
||||
@@ -9696,14 +9880,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// ServerLevel.tick: once enough of the players have slept 100 ticks,
|
||||
// skip to morning, clear the weather and wake everyone.
|
||||
// skip to morning if `advance_time` is on, wake everyone, and
|
||||
// resetWeatherCycle if `advance_weather` is on and it is raining.
|
||||
if self.enough_slept() {
|
||||
let t = self.world.day_time;
|
||||
self.world.day_time = t + 24000 - t.rem_euclid(24000);
|
||||
self.world.weather.raining = false;
|
||||
self.world.weather.thundering = false;
|
||||
self.world.weather.rain_time = 0;
|
||||
self.world.weather.thunder_time = 0;
|
||||
if self.ui.rules.bool("advance_time") {
|
||||
let t = self.world.day_time;
|
||||
self.world.day_time = t + 24000 - t.rem_euclid(24000);
|
||||
}
|
||||
// Level.isRaining: the rain level is above 0.2.
|
||||
if self.ui.rules.bool("advance_weather") && self.world.weather.levels(1.0).0 > 0.2 {
|
||||
self.world.weather.raining = false;
|
||||
self.world.weather.thundering = false;
|
||||
self.world.weather.rain_time = 0;
|
||||
self.world.weather.thunder_time = 0;
|
||||
}
|
||||
if self.sleeping.is_some() {
|
||||
self.wake_up();
|
||||
}
|
||||
@@ -9731,8 +9921,9 @@ impl Game<'_> {
|
||||
fn draw_creative(&mut self, batch: &mut HudBatch) {
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
let selected = self.creative_screen.selected;
|
||||
let tabs = self.creative_screen.tabs.clone();
|
||||
let tab = self.creative_screen.tab().clone();
|
||||
// Only what is drawn of the tabs: their stacks stay where they are.
|
||||
let tabs = self.creative_screen.tab_headers();
|
||||
let tab = tabs[selected.min(tabs.len() - 1)].clone();
|
||||
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
|
||||
format!(
|
||||
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
|
||||
@@ -9798,20 +9989,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
if tab.search {
|
||||
// The search box: an unbordered EditBox of 80 at (82, 6), white.
|
||||
let search = &self.creative_screen.search;
|
||||
horizon_client::ui::append_field(
|
||||
batch,
|
||||
atlas,
|
||||
search,
|
||||
(ox + 82.0, oy + 6.0, 80.0),
|
||||
(u32::MAX, true),
|
||||
clipboard::CREATIVE_SEARCH_CHARS,
|
||||
self.text_selection
|
||||
.covers(clipboard::CREATIVE_SEARCH, search),
|
||||
true,
|
||||
self.creative_screen.search_focused.elapsed().as_millis(),
|
||||
);
|
||||
// CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6).
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = ox + 82.0;
|
||||
search.y = oy + 6.0;
|
||||
search.width = 80.0;
|
||||
search.bordered = false;
|
||||
search.focused = true;
|
||||
search.text_color = u32::MAX;
|
||||
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
|
||||
search.draw(batch, atlas, None);
|
||||
}
|
||||
let mut hovered: Option<ItemStack> = None;
|
||||
if tab.inventory {
|
||||
@@ -9919,8 +10106,7 @@ impl Game<'_> {
|
||||
} else if self.carried.is_none()
|
||||
&& let Some(item) = hovered
|
||||
{
|
||||
let lines = self.stack_tooltip(&item);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
}
|
||||
@@ -9973,6 +10159,9 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !right && self.creative_search_click(ox, oy) {
|
||||
return;
|
||||
}
|
||||
// Holding the scrollbar scrolls as the mouse moves (mouseDragged).
|
||||
if !right
|
||||
&& self
|
||||
@@ -9985,6 +10174,24 @@ impl Game<'_> {
|
||||
self.inventory_press(right);
|
||||
}
|
||||
}
|
||||
/// A click on the search tab's box (`EditBox.onClick`).
|
||||
fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool {
|
||||
if !self.creative_screen.tab().search {
|
||||
return false;
|
||||
}
|
||||
let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0);
|
||||
let (mx, my) = self.cursor;
|
||||
if mx < x || my < y || mx >= x + w || my >= y + h {
|
||||
return false;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = x;
|
||||
search.y = y;
|
||||
search.width = w;
|
||||
search.click(&self.gui.atlas, mx, shift, false);
|
||||
true
|
||||
}
|
||||
/// Copy the live player and world state into the `Level` that gets saved.
|
||||
pub(super) fn sync_level(&mut self) {
|
||||
// Level.write saves the game rules (game_rules.dat) with the rest.
|
||||
@@ -10104,6 +10311,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Save the level in place: its changed chunks, its entities and its raids.
|
||||
fn save_level(&mut self, wait: bool) -> Result<()> {
|
||||
self.save.save_forced_chunks(&self.streaming.forced)?;
|
||||
self.streaming.save(&mut self.world, wait);
|
||||
let keys: Vec<_> = self.world.modified.drain().collect();
|
||||
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
|
||||
@@ -10303,8 +10511,6 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
// Map textures changed since the last drawn frame (game/maps.rs).
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
|
||||
let title = self.title.is_some()
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
@@ -10406,6 +10612,10 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.shader_pack_inputs(camera, alpha as f32)
|
||||
};
|
||||
// Map textures and font glyphs changed while building this frame's
|
||||
// geometry (game/maps.rs, world text) upload with this batch; draining
|
||||
// map_patches any earlier leaves them a frame behind.
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
@@ -11492,16 +11702,17 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
self.extras_mouse(button, pressed);
|
||||
self.mod_mouse_key(button, pressed);
|
||||
match self.settings.keys.translate_mouse(button) {
|
||||
Some(Err(b)) => self.play_mouse(b, pressed)?,
|
||||
Some(Ok(k)) if pressed => {
|
||||
self.keys.insert(k);
|
||||
self.key_pressed(k)?;
|
||||
for mapped in self.settings.keys.translate_mouse_all(button) {
|
||||
match mapped {
|
||||
Err(b) => self.play_mouse(b, pressed)?,
|
||||
Ok(k) if pressed => {
|
||||
self.keys.insert(k);
|
||||
self.key_pressed(k)?;
|
||||
}
|
||||
Ok(k) => {
|
||||
self.keys.remove(&k);
|
||||
}
|
||||
}
|
||||
Some(Ok(k)) => {
|
||||
self.keys.remove(&k);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Screen::Inventory | Screen::Creative
|
||||
@@ -11628,8 +11839,17 @@ impl ApplicationHandler for Game<'_> {
|
||||
// The hovered slot took the key (`ignoreTextInput`).
|
||||
} else if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.creative_search_shortcut(shortcut);
|
||||
} else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) {
|
||||
self.creative_search_backspace(self.has_control_down());
|
||||
} else if let PhysicalKey::Code(key) = event.physical_key {
|
||||
// EditBox.keyPressed: arrows, Home, End, Delete and Backspace.
|
||||
let before = self.creative_screen.search.value.clone();
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
if self.creative_screen.search.key(key, ctrl, shift) {
|
||||
if self.creative_screen.search.value != before {
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
} else {
|
||||
self.creative_search_edit(event.text.as_deref());
|
||||
}
|
||||
} else {
|
||||
self.creative_search_edit(event.text.as_deref());
|
||||
}
|
||||
@@ -11650,6 +11870,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
// The anvil rename box takes every key but Escape (AnvilScreen.keyPressed).
|
||||
if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.station_shortcut(shortcut);
|
||||
} else if let PhysicalKey::Code(key) = event.physical_key
|
||||
&& self.station_key(key)
|
||||
{
|
||||
} else {
|
||||
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
|
||||
self.station_text(event.text.as_deref(), backspace);
|
||||
@@ -11737,18 +11960,24 @@ impl ApplicationHandler for Game<'_> {
|
||||
{
|
||||
self.smooth_camera = !self.smooth_camera;
|
||||
}
|
||||
// Key binds: act as the default key of the bound mapping.
|
||||
let key = match self.settings.keys.translate(key) {
|
||||
Some(Ok(k)) => k,
|
||||
Some(Err(button)) => {
|
||||
if self.screen == Screen::Playing && !event.repeat {
|
||||
self.play_mouse(button, event.state == ElementState::Pressed)?;
|
||||
// Key binds: act as the default key of every mapping bound
|
||||
// to it (KeyMapping.set drives them all).
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(k) => {
|
||||
self.mapped_key(
|
||||
k,
|
||||
event.state == ElementState::Pressed,
|
||||
event.repeat,
|
||||
)?;
|
||||
}
|
||||
Err(button) => {
|
||||
if self.screen == Screen::Playing && !event.repeat {
|
||||
self.play_mouse(button, event.state == ElementState::Pressed)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
None => return Ok(()),
|
||||
};
|
||||
self.mapped_key(key, event.state == ElementState::Pressed, event.repeat)?;
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.observe_fullscreen();
|
||||
@@ -12162,6 +12391,7 @@ impl<'a> Game<'a> {
|
||||
last_input: now,
|
||||
pad: Default::default(),
|
||||
frames: 0,
|
||||
item_level_cache: Default::default(),
|
||||
world_frames: 0,
|
||||
fps: 0.0,
|
||||
fps_frames: 0,
|
||||
@@ -12266,6 +12496,7 @@ impl<'a> Game<'a> {
|
||||
map_frame_model: prepared.map_frame_model,
|
||||
glow_map_frame_model: prepared.glow_map_frame_model,
|
||||
armor_stands: Vec::new(),
|
||||
cushions: Vec::new(),
|
||||
others: Vec::new(),
|
||||
armor_stand_assets: prepared.armor_stand_assets,
|
||||
waypoint_styles: prepared.waypoint_styles,
|
||||
@@ -12275,6 +12506,7 @@ impl<'a> Game<'a> {
|
||||
boats: Vec::new(),
|
||||
boat_models: prepared.boat_models,
|
||||
crystal_model: prepared.crystal_model,
|
||||
cushion_model: prepared.cushion_model,
|
||||
carts: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
@@ -12361,6 +12593,7 @@ impl<'a> Game<'a> {
|
||||
lan_events: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
lan_global_events: Vec::new(),
|
||||
lid_openers: HashMap::new(),
|
||||
lan_acting: None,
|
||||
@@ -12412,6 +12645,7 @@ struct GameCpuResources {
|
||||
boat_models: boats::BoatModels,
|
||||
cart_models: minecarts::CartModels,
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
cushion_model: Option<horizon_client::mobs::Part>,
|
||||
dragon_model: Option<horizon_client::mobs::Part>,
|
||||
arrow_model: Option<horizon_client::mobs::Part>,
|
||||
trident_model: Option<horizon_client::mobs::Part>,
|
||||
@@ -12432,6 +12666,7 @@ impl GameCpuResources {
|
||||
boat_models: boats::BoatModels::load(document.as_ref()),
|
||||
cart_models: minecarts::CartModels::load(document.as_ref()),
|
||||
crystal_model: model("minecraft:end_crystal#main"),
|
||||
cushion_model: model("minecraft:cushion#main"),
|
||||
dragon_model: model("minecraft:ender_dragon#main"),
|
||||
arrow_model: model("minecraft:arrow#main"),
|
||||
trident_model: model("minecraft:trident#main"),
|
||||
@@ -12991,6 +13226,14 @@ fn build_renderer(
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
let atlas = &gui.atlas;
|
||||
// The new renderer's atlas texture starts from `HudAtlas.pixels`, which
|
||||
// holds the textures but never a glyph rasterized at run time. The font's
|
||||
// cache still answers with the rects it drew into the previous renderer's
|
||||
// texture, so forget them: the glyphs that are drawn again are placed into
|
||||
// this texture (as `ui_theme` does when its atlas changes). Without this,
|
||||
// Chinese and Japanese menu labels go blank when the loading screen hands
|
||||
// over to the game.
|
||||
atlas.font.reset_cache();
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
@@ -13037,6 +13280,12 @@ fn loading_renderer(
|
||||
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
|
||||
None => (EMPTY_HUD.as_slice(), 1, 1),
|
||||
};
|
||||
// A glyph rasterized into an earlier texture is not in these pixels; the
|
||||
// new texture gets it again when the character is drawn (see
|
||||
// `build_renderer`).
|
||||
if let Some(atlas) = atlas {
|
||||
atlas.font.reset_cache();
|
||||
}
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
@@ -13773,7 +14022,7 @@ mod startup_cpu_tests {
|
||||
r#"{
|
||||
"minecraft:end_crystal#main": {}, "minecraft:ender_dragon#main": {},
|
||||
"minecraft:arrow#main": {}, "minecraft:trident#main": {},
|
||||
"minecraft:armor_stand#main": {}
|
||||
"minecraft:armor_stand#main": {}, "minecraft:cushion#main": {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -13793,6 +14042,7 @@ mod startup_cpu_tests {
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(prepared.crystal_model.is_some());
|
||||
assert!(prepared.cushion_model.is_some());
|
||||
assert!(prepared.dragon_model.is_some());
|
||||
assert!(prepared.arrow_model.is_some());
|
||||
assert!(prepared.trident_model.is_some());
|
||||
|
||||
@@ -515,8 +515,7 @@ impl Game<'_> {
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = stack_tip {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(
|
||||
|
||||
@@ -56,6 +56,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
|
||||
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +93,10 @@ impl Game<'_> {
|
||||
| "minecraft:vault"
|
||||
| "minecraft:beehive"
|
||||
| "minecraft:bee_nest"
|
||||
| "minecraft:brewing_stand" => Ticking::Yes,
|
||||
| "minecraft:brewing_stand"
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
"minecraft:jukebox" => {
|
||||
let record = registry
|
||||
.state(state)
|
||||
|
||||
@@ -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);
|
||||
@@ -52,11 +52,16 @@ impl Game<'_> {
|
||||
/// Remember who deals the next `damage_type` damage to the player.
|
||||
pub(super) fn note_attacker(&mut self, damage_type: &str, mob: u32) {
|
||||
if let Some(name) = self.mob_display_name(mob) {
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (mob, name)));
|
||||
self.note_named_attacker(damage_type, mob, name);
|
||||
}
|
||||
}
|
||||
/// `note_attacker` for an entity that is not one of `self.mobs` (the
|
||||
/// ender dragon).
|
||||
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name)));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
|
||||
@@ -85,16 +85,16 @@ fn format_double(v: f64) -> String {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) {
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.mod_command(command) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
let d = vanilla::game_dispatcher();
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let result = match parse.executable() {
|
||||
Err(e) => {
|
||||
self.syntax_failure(&e, command);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
Ok((ctx, _)) => {
|
||||
let ctx = ctx.clone();
|
||||
@@ -103,6 +103,7 @@ impl Game<'_> {
|
||||
self.run_chain(&root, &ctx)
|
||||
}
|
||||
};
|
||||
let ok = result.is_ok();
|
||||
if let Err(e) = result {
|
||||
if e.input.is_some() {
|
||||
self.syntax_failure(&e, command);
|
||||
@@ -114,6 +115,20 @@ impl Game<'_> {
|
||||
if self.player.is_some() {
|
||||
self.check_inventory_trigger();
|
||||
}
|
||||
ok
|
||||
}
|
||||
/// `run_command` with its output suppressed
|
||||
/// (`CommandSourceStack.withSuppressedOutput`): the player's own source,
|
||||
/// rights and checks, no feedback and no failure in the chat.
|
||||
pub(super) fn run_command_quietly(&mut self, command: &str) -> bool {
|
||||
let quiet = execute::Source {
|
||||
silent: true,
|
||||
..self.player_source()
|
||||
};
|
||||
let outer = self.command_source.replace(quiet);
|
||||
let ok = self.run_command(command);
|
||||
self.command_source = outer;
|
||||
ok
|
||||
}
|
||||
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
|
||||
self.command_failure(e.message.clone());
|
||||
@@ -1870,11 +1885,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, new)) = horizon_client::save::decode_block_entity(&tag) else {
|
||||
let Some((_, mut new)) = horizon_client::save::decode_block_entity(&tag) else {
|
||||
return false;
|
||||
};
|
||||
let rolled = self.take_loot_table(key, &tag, &mut new);
|
||||
let after = horizon_client::save::encode_block_entity(key, &new, &name);
|
||||
if after == before {
|
||||
if after == before && !rolled {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
@@ -1882,6 +1898,79 @@ impl Game<'_> {
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
true
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
/// container (`setblock`, `data`): an empty container with a `LootTable`
|
||||
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
|
||||
/// the roll is kept with its chunk, so that the container saves, and
|
||||
/// `data get` shows, its table and seed until its items change. A tag
|
||||
/// without one drops what the container had. True when it had a table.
|
||||
pub(super) fn take_loot_table(
|
||||
&mut self,
|
||||
key: (i32, i32, i32),
|
||||
tag: &horizon_world::nbt::Tag,
|
||||
entity: &mut horizon_client::containers::BlockEntity,
|
||||
) -> bool {
|
||||
use horizon_client::{containers::BlockEntity, save, structure_blocks};
|
||||
let table = save::text(tag.get("LootTable"));
|
||||
let rolled = match (table, entity) {
|
||||
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
|
||||
let loot = structure_blocks::loot_data(&self.options.resources);
|
||||
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
let biome = self.biome_name_at(at).unwrap_or_default();
|
||||
Some(structure_blocks::roll_container(
|
||||
slots,
|
||||
loot.as_deref(),
|
||||
table,
|
||||
structure_blocks::loot_seed(tag),
|
||||
key,
|
||||
&biome,
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
|
||||
return false;
|
||||
};
|
||||
let held = |c: &horizon_client::chunk::Chunk| {
|
||||
c.saved
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
|
||||
};
|
||||
if rolled.is_none() && !held(chunk) {
|
||||
return false;
|
||||
}
|
||||
let chunk = std::sync::Arc::make_mut(chunk);
|
||||
let mut extras = chunk
|
||||
.saved
|
||||
.take()
|
||||
.map(std::sync::Arc::unwrap_or_clone)
|
||||
.unwrap_or_default();
|
||||
extras.rolled_loot.retain(|r| r.pos != key);
|
||||
let taken = rolled.is_some();
|
||||
extras.rolled_loot.extend(rolled);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
taken
|
||||
}
|
||||
/// The loot table and seed a container still holding its roll saves
|
||||
/// (`RolledLoot::restore`), in place of its items.
|
||||
pub(super) fn restore_loot_table(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
tag: &mut horizon_world::nbt::Tag,
|
||||
) {
|
||||
let horizon_world::nbt::Tag::Compound(map) = tag else {
|
||||
return;
|
||||
};
|
||||
let rolled = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(key.0 >> 4, key.2 >> 4))
|
||||
.and_then(|c| c.saved.as_ref())
|
||||
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key));
|
||||
if let Some(r) = rolled {
|
||||
r.restore(map);
|
||||
}
|
||||
}
|
||||
fn cmd_fill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let a = self.block_pos_arg(ctx, "from")?;
|
||||
let b = self.block_pos_arg(ctx, "to")?;
|
||||
@@ -2578,38 +2667,7 @@ impl Game<'_> {
|
||||
/// `PoiTypes`: the point of interest type of a block state.
|
||||
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
|
||||
let s = self.pack.assets.registry.state(state)?;
|
||||
let name = s.name.strip_prefix("minecraft:")?;
|
||||
Some(match name {
|
||||
"blast_furnace" => "minecraft:armorer",
|
||||
"smoker" => "minecraft:butcher",
|
||||
"cartography_table" => "minecraft:cartographer",
|
||||
"brewing_stand" => "minecraft:cleric",
|
||||
"composter" => "minecraft:farmer",
|
||||
"barrel" => "minecraft:fisherman",
|
||||
"fletching_table" => "minecraft:fletcher",
|
||||
"cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => {
|
||||
"minecraft:leatherworker"
|
||||
}
|
||||
"lectern" => "minecraft:librarian",
|
||||
"stonecutter" => "minecraft:mason",
|
||||
"loom" => "minecraft:shepherd",
|
||||
"smithing_table" => "minecraft:toolsmith",
|
||||
"grindstone" => "minecraft:weaponsmith",
|
||||
"bell" => "minecraft:meeting",
|
||||
"beehive" => "minecraft:beehive",
|
||||
"bee_nest" => "minecraft:bee_nest",
|
||||
"nether_portal" => "minecraft:nether_portal",
|
||||
"lodestone" => "minecraft:lodestone",
|
||||
"test_instance_block" => "minecraft:test_instance",
|
||||
n if n.ends_with("_bed") => {
|
||||
if s.properties.get("part").map(String::as_str) != Some("head") {
|
||||
return None;
|
||||
}
|
||||
"minecraft:home"
|
||||
}
|
||||
n if n.ends_with("lightning_rod") => "minecraft:lightning_rod",
|
||||
_ => return None,
|
||||
})
|
||||
horizon_client::save::poi_type(s).map(|(kind, _)| kind)
|
||||
}
|
||||
/// `LocateCommand.locatePoi`: `PoiManager.findClosestWithType` within
|
||||
/// 256 blocks of the source, over the loaded chunks.
|
||||
|
||||
@@ -286,6 +286,7 @@ impl Game<'_> {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"keepPacked".into());
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
|
||||
@@ -296,7 +297,8 @@ impl Game<'_> {
|
||||
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
|
||||
DataRef::Block(p) => {
|
||||
let key = (p[0], p[1], p[2]);
|
||||
if let Some((_, new)) = save::decode_block_entity(&tag) {
|
||||
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
|
||||
self.take_loot_table(key, &tag, &mut new);
|
||||
self.world.block_entities.insert(key, new);
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
// Redraw what shows the block entity (signs, banners).
|
||||
|
||||
@@ -49,26 +49,12 @@ impl Game<'_> {
|
||||
let mut tracker = Tracker::default();
|
||||
for &t in &targets {
|
||||
let h = holder(self.target_name(t));
|
||||
// ServerRecipeBook.addRecipes/removeRecipes count recipe book
|
||||
// displays (a transmute recipe has one per material count).
|
||||
let mut n = 0;
|
||||
// ServerPlayer.awardRecipes / resetRecipes: the displays changed.
|
||||
let index = &self.res.stations().recipe_index;
|
||||
n += if give {
|
||||
let (count, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
count as i32
|
||||
} else {
|
||||
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
};
|
||||
// ServerPlayer.awardRecipes / resetRecipes on the target's own
|
||||
// ServerRecipeBook: a guest's is swapped in for the call, and the
|
||||
// packets go to its client.
|
||||
let n = self
|
||||
.for_player(t, |g| g.change_recipes(give, &recipes))
|
||||
.unwrap_or(0);
|
||||
tracker.track(&h, n);
|
||||
}
|
||||
let verb = if give { "give" } else { "take" };
|
||||
@@ -92,6 +78,27 @@ impl Game<'_> {
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// ServerRecipeBook.addRecipes/removeRecipes for the player in place: how
|
||||
/// many recipe book displays changed (a transmute recipe has one per
|
||||
/// material count).
|
||||
fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 {
|
||||
let index = &self.res.stations().recipe_index;
|
||||
if give {
|
||||
let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
return count as i32;
|
||||
}
|
||||
let removed = self.level.recipe_book.remove_recipes(index, recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
// ---- /damage ----------------------------------------------------------------
|
||||
|
||||
fn damage_kind(ty: &str) -> DamageKind {
|
||||
|
||||
@@ -50,9 +50,9 @@ pub(super) struct Creatures {
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
}
|
||||
struct ProjWorld<'a, 'w> {
|
||||
w: &'a MobWorld<'w>,
|
||||
targets: Vec<(Who, Aabb)>,
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
}
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
@@ -374,6 +374,7 @@ impl Game<'_> {
|
||||
// Drawn seated (Entity.isPassenger); set apart because the
|
||||
// json! above is at the macro recursion limit.
|
||||
state["passenger"] = m.extra.passenger.into();
|
||||
state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0));
|
||||
state
|
||||
})
|
||||
.collect();
|
||||
@@ -841,6 +842,7 @@ impl Game<'_> {
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
match e {
|
||||
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
|
||||
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
@@ -1066,7 +1068,10 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::BreakBlock { pos, drop } => {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.destroy_particles(pos, old);
|
||||
// Block.spawnDestroyParticles: fire breaks leave nothing.
|
||||
if !self.res.facts().get(old).has_class("BaseFireBlock") {
|
||||
self.destroy_particles(pos, old);
|
||||
}
|
||||
if drop {
|
||||
let drops = self.res.items().block_drops_in(
|
||||
self.pack.assets.registry,
|
||||
@@ -1088,6 +1093,9 @@ impl Game<'_> {
|
||||
self.ensure_item_particle_sprites();
|
||||
}
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
if ty == horizon_client::particles::ParticleType::SonicBoom {
|
||||
self.lan_sonic_boom(&options, at);
|
||||
}
|
||||
// Count 0: one particle exactly there, at rest.
|
||||
if count == 0 {
|
||||
self.particles.add(&options, at, [0.0; 3]);
|
||||
@@ -1121,7 +1129,7 @@ impl Game<'_> {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount),
|
||||
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
|
||||
MobEvent::AxolotlSupportPlayer => {
|
||||
let previous = self
|
||||
.potions
|
||||
@@ -1265,6 +1273,13 @@ impl Game<'_> {
|
||||
m.extra.egg_time = life;
|
||||
}
|
||||
}
|
||||
/// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the
|
||||
/// `mob_drops` game rule is on.
|
||||
fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) {
|
||||
if self.ui.rules.bool("mob_drops") {
|
||||
self.spawn_orbs(at, amount);
|
||||
}
|
||||
}
|
||||
/// Animal.spawnChildFromBreeding
|
||||
fn breed(
|
||||
&mut self,
|
||||
@@ -1358,7 +1373,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.world.random = r;
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
if p.species() == Species::Cat {
|
||||
@@ -1379,7 +1394,7 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
if p.species() == Species::Wolf {
|
||||
@@ -1401,7 +1416,7 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
|
||||
@@ -1417,7 +1432,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
}
|
||||
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
|
||||
/// mining fatigue III for five minutes unless a stronger or longer one
|
||||
@@ -1513,15 +1528,19 @@ impl Game<'_> {
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.note_attacker(damage_type, a);
|
||||
}
|
||||
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
|
||||
// owner) caused the hit, not a player.
|
||||
let living_cause = attacker.or(owner).is_some();
|
||||
let mut outcome = horizon_client::health::BlockOutcome::default();
|
||||
if let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_blocking(
|
||||
&& let Ok(o) = player.hurt_caused_by(
|
||||
&self.motion.health,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
living_cause,
|
||||
)
|
||||
&& {
|
||||
outcome = o;
|
||||
@@ -1540,7 +1559,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.hurt_armor(amount);
|
||||
self.thorns(from);
|
||||
// The thorns hurt the entity that caused the hit, if there is one.
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.thorns(a);
|
||||
}
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, attacker, projectile);
|
||||
@@ -3295,6 +3317,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
|
||||
map: false,
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A generated loot stack as a Horizon item stack.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -231,7 +231,7 @@ impl Game<'_> {
|
||||
}
|
||||
damage
|
||||
}
|
||||
fn biome_name_at(&self, p: Vec3) -> Option<String> {
|
||||
pub(super) fn biome_name_at(&self, p: Vec3) -> Option<String> {
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let c = self.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(
|
||||
|
||||
@@ -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;
|
||||
@@ -71,6 +71,8 @@ pub(super) struct LanHost {
|
||||
/// Guests' commands waiting for the hub to be back in `self.lan`, where
|
||||
/// a command can reach every player (game/lan_actions.rs).
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
|
||||
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
|
||||
/// The entities the guests track, described again every tick
|
||||
/// (`lan_entities`) over last tick's states.
|
||||
states: lan::entity::States,
|
||||
@@ -145,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
player_entity: Option<i32>,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
@@ -194,6 +197,32 @@ fn mob_meta(
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
let target = if e.flag {
|
||||
m.ai.target.and_then(|who| match who {
|
||||
Who::Player => player_entity,
|
||||
Who::Mob(id) => Some(id as i32),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(target) = target {
|
||||
meta.push((17, serializer::INT, Value::Int(target)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0, flying by default.
|
||||
"minecraft:bat" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
|
||||
"minecraft:sheep" => {
|
||||
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
|
||||
@@ -596,6 +625,7 @@ impl Game<'_> {
|
||||
waypoint_dead: Default::default(),
|
||||
border_sent: None,
|
||||
commands: Vec::new(),
|
||||
quiet: std::collections::VecDeque::new(),
|
||||
states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
@@ -634,6 +664,7 @@ impl Game<'_> {
|
||||
self.world.light_log = None;
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.streaming.extra.clear();
|
||||
if announce {
|
||||
self.system_message(Text::translate(
|
||||
@@ -820,12 +851,13 @@ impl Game<'_> {
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity);
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
@@ -835,7 +867,7 @@ impl Game<'_> {
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
@@ -912,6 +944,13 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
// `runBlockEvents`' broadcast precedes the tick's block changes
|
||||
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
|
||||
// guest re-runs a piston's `triggerEvent` while its level still
|
||||
// has the blocks as they were before the move.
|
||||
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
|
||||
host.hub.block_event(pos, b0, b1, state);
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
@@ -960,6 +999,9 @@ impl Game<'_> {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::PlayerChat { sender, text } => {
|
||||
self.mod_player_chat(super::server_mods::Who::Uuid(sender), &text);
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
@@ -1029,6 +1071,14 @@ impl Game<'_> {
|
||||
}
|
||||
host.states = states;
|
||||
}
|
||||
if !host.quiet.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_quiet_commands();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the quiet commands");
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
@@ -1134,17 +1184,29 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
match e {
|
||||
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
}
|
||||
// `ServerLevel.runBlockEvents` broadcasts a handled block
|
||||
// event (`ClientboundBlockEventPacket`): the guest re-runs
|
||||
// the block's `triggerEvent` — a piston's `moveBlocks` is
|
||||
// what animates the move there. `state` is the block the
|
||||
// event named, read before it ran (the packet's `block`).
|
||||
horizon_world::level::LevelEvent::BlockEntityEvent { pos, state, a, b } => {
|
||||
self.lan_block_events
|
||||
.push(([pos.x, pos.y, pos.z], *a, *b, state.0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1162,8 +1224,30 @@ impl Game<'_> {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
|
||||
/// attacker's client and observers once the hub is back in place.
|
||||
pub(super) fn critical_hit(
|
||||
&mut self,
|
||||
attacker: Option<u32>,
|
||||
id: i32,
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
self.particles.tracking_emitter(
|
||||
ParticleOptions::simple(ParticleType::Crit),
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_critical_hits.push((attacker, id));
|
||||
}
|
||||
}
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
|
||||
if kind == 2001 {
|
||||
host.broken
|
||||
@@ -1236,6 +1320,14 @@ impl Game<'_> {
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
for (attacker, target) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the chunks around every guest loaded.
|
||||
@@ -1403,7 +1495,11 @@ impl Game<'_> {
|
||||
let tag = if secure { "" } else { "[Not Secure] " };
|
||||
super::dedicated::log("INFO", &format!("{tag}{line}"));
|
||||
}
|
||||
HostEvent::Chat { line, .. } => self.system_message(Text::literal(line)),
|
||||
// Player chat (and `/say`'s or `/me`'s, which are chat messages
|
||||
// too): the host's Chat option decides, as `ChatListener` does
|
||||
// for the guests' packets. Command feedback is a system message
|
||||
// and takes the system-message permission instead.
|
||||
HostEvent::Chat { line, .. } => self.player_message(Text::literal(line)),
|
||||
HostEvent::Warn(line) if dedicated => super::dedicated::log("WARN", &line),
|
||||
HostEvent::Warn(line) => log::warn!("{line}"),
|
||||
HostEvent::Error(line) if dedicated => super::dedicated::log("ERROR", &line),
|
||||
|
||||
@@ -77,6 +77,10 @@ pub(super) struct GuestPlay {
|
||||
last_inventory: u64,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
|
||||
/// its client's PERFORM_RESPAWN (`respawn_requested`).
|
||||
pub(super) won_game: bool,
|
||||
pub(super) respawn_requested: bool,
|
||||
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
|
||||
xp_delay: u32,
|
||||
/// What last killed the guest outright (`/kill`): the death message's key.
|
||||
@@ -94,11 +98,14 @@ pub(super) struct GuestPlay {
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
pub(super) struct GuestCommand {
|
||||
conn: u32,
|
||||
command: String,
|
||||
permission: u8,
|
||||
pub(super) conn: u32,
|
||||
pub(super) command: String,
|
||||
pub(super) permission: u8,
|
||||
/// An integrated server's: no level teleports (yet).
|
||||
integrated: bool,
|
||||
pub(super) integrated: bool,
|
||||
/// A mod's quiet command (game/lan_quiet.rs): its output suppressed, as
|
||||
/// `CommandSourceStack.withSuppressedOutput` does.
|
||||
pub(super) quiet: bool,
|
||||
}
|
||||
|
||||
/// The game's own state while a guest's is swapped in (`enter_guest`), and
|
||||
@@ -872,7 +879,11 @@ impl Game<'_> {
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| {
|
||||
g.menu.as_ref().is_some_and(|m| {
|
||||
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
|
||||
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|
||||
|| m.pos.is_some_and(|k| {
|
||||
k.1 == super::minecarts::CART_KEY_Y
|
||||
&& !self.chest_vehicle_still_valid(k.2, g)
|
||||
})
|
||||
})
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
@@ -886,6 +897,35 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
|
||||
/// boat is still there and within the guest's entity interaction range
|
||||
/// plus 4 of its box.
|
||||
fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
let bounds = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id as i32 == id)
|
||||
.map(|c| c.bounds())
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id as i32 == id)
|
||||
.map(|b| b.bounds())
|
||||
});
|
||||
let Some(b) = bounds else {
|
||||
return false;
|
||||
};
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let d = [
|
||||
eye[0] - eye[0].clamp(lo.x, hi.x),
|
||||
eye[1] - eye[1].clamp(lo.y, hi.y),
|
||||
eye[2] - eye[2].clamp(lo.z, hi.z),
|
||||
];
|
||||
let range = if g.creative() { 5.0 } else { 3.0 } + 4.0;
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -1210,6 +1250,9 @@ impl Game<'_> {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
|
||||
S::CustomPayload(p) if p.channel == horizon_protocol::quiet::CHANNEL => {
|
||||
self.guest_quiet_commands(host, conn, &p.data)
|
||||
}
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
|
||||
@@ -1308,6 +1351,13 @@ impl Game<'_> {
|
||||
self.guest_suggestions(host, conn, &payload);
|
||||
}
|
||||
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
|
||||
// handleClientCommand: PERFORM_RESPAWN after the poem leaves the End
|
||||
// (the next server tick sends the player on).
|
||||
S::ClientCommand(c)
|
||||
if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) =>
|
||||
{
|
||||
host.play.entry(conn).or_default().respawn_requested = true;
|
||||
}
|
||||
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
|
||||
// ServerStatsCounter.sendStats.
|
||||
S::ClientCommand(c) if c.action == 1 => {
|
||||
@@ -1533,6 +1583,10 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A server mod's command, for every player whatever their level.
|
||||
if self.guest_server_mod_command(host, conn, command.trim_start_matches('/')) {
|
||||
return;
|
||||
}
|
||||
// An integrated server runs every player's commands at their own
|
||||
// permission level (`CommandSourceStack.permissions`); a LAN world
|
||||
// follows its Allow Commands, and a dedicated server leaves it to
|
||||
@@ -1559,6 +1613,7 @@ impl Game<'_> {
|
||||
command: command.trim_start_matches('/').to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1573,41 +1628,54 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let c = host.commands.remove(0);
|
||||
let mut host = self.lan.take().expect("checked above");
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
|
||||
self.lan = Some(host);
|
||||
continue;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
self.run_command(&c.command);
|
||||
self.ui.registries.permission = level;
|
||||
let lines = self.chat_capture.take().unwrap_or_default();
|
||||
let mut host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
if !c.integrated {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
self.run_guest_command(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// One guest's command as that guest, its feedback sent to them; false
|
||||
/// when it failed (or the guest is gone).
|
||||
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
|
||||
self.lan = Some(host);
|
||||
return false;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
let ok = if c.quiet {
|
||||
self.run_command_quietly(&c.command)
|
||||
} else {
|
||||
self.run_command(&c.command)
|
||||
};
|
||||
self.ui.registries.permission = level;
|
||||
let lines = self.chat_capture.take().unwrap_or_default();
|
||||
let mut host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
if !c.integrated {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
ok
|
||||
}
|
||||
|
||||
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
|
||||
/// whose it was; none for the host's or the console's): every other
|
||||
/// operator gets them, and the console logs them as the server's own
|
||||
|
||||
@@ -28,7 +28,11 @@ impl Game<'_> {
|
||||
|
||||
/// SignableCommand.of: the command's message arguments, by name, with
|
||||
/// their text, not past a redirect to the root (`execute ... run`).
|
||||
fn signable_arguments(&mut self, permission: u8, command: &str) -> Vec<(String, String)> {
|
||||
pub(super) fn signable_arguments(
|
||||
&mut self,
|
||||
permission: u8,
|
||||
command: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let parse = self.parse_as(permission, command);
|
||||
let nodes = &vanilla::game_dispatcher().nodes;
|
||||
let root = parse.context.root;
|
||||
|
||||
@@ -245,6 +245,16 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
for c in &self.cushions {
|
||||
let p = c.position;
|
||||
let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false);
|
||||
// Cushion.DATA_COLOR, set unless it is the default white.
|
||||
if c.color != 0 {
|
||||
e.meta
|
||||
.push((8, serializer::DYE_COLOR, Value::Id(i32::from(c.color))));
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
self.lan_projectile_states(host_entity, hub, out);
|
||||
for o in &self.others {
|
||||
let p = o.position;
|
||||
@@ -279,6 +289,10 @@ impl Game<'_> {
|
||||
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.potions.thrown_ids().for_each(&mut give);
|
||||
self.end.eye_ids().for_each(&mut give);
|
||||
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.dragon_fireballs
|
||||
.iter_mut()
|
||||
.for_each(|b| give(&mut b.id));
|
||||
}
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
@@ -435,6 +449,35 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
|
||||
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
|
||||
// client draws them from these (`tick_remote_clouds`).
|
||||
let breath = self.network_particle(
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::DragonBreath,
|
||||
),
|
||||
None,
|
||||
);
|
||||
for b in self.dragon_fireballs.iter().filter(|b| b.id != 0) {
|
||||
let p = b.position;
|
||||
let mut e = state(b.id, "minecraft:dragon_fireball", at(p), 0.0, false);
|
||||
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
|
||||
out.push(e);
|
||||
}
|
||||
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
|
||||
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
|
||||
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
|
||||
if b.age < 20 {
|
||||
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if let Some(p) = &breath {
|
||||
e.meta
|
||||
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
|
||||
}
|
||||
e.defaults
|
||||
.push((9, serializer::BOOLEAN, Value::Bool(false)));
|
||||
out.push(e);
|
||||
}
|
||||
out.extend(self.lan_dragon_state());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,33 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
|
||||
/// Keep the local event's rendering; connected players receive one at rest.
|
||||
pub(super) fn lan_sonic_boom(&self, options: &ParticleOptions, at: [f64; 3]) {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.0; 3],
|
||||
max_speed: [0.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
|
||||
/// network id and the data it carries (`state` for the block particles).
|
||||
/// None while not serving, or for a particle whose data isn't sent from
|
||||
@@ -68,7 +95,11 @@ impl Game<'_> {
|
||||
|
||||
/// The options a server's particle stands for (None for a type or data
|
||||
/// this client doesn't draw).
|
||||
fn received_particle(&mut self, p: &Particle, at: [f64; 3]) -> Option<ParticleOptions> {
|
||||
pub(super) fn received_particle(
|
||||
&mut self,
|
||||
p: &Particle,
|
||||
at: [f64; 3],
|
||||
) -> Option<ParticleOptions> {
|
||||
let name = self
|
||||
.remote
|
||||
.as_ref()?
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Quiet commands (`horizon_protocol::quiet`): a Horizon client's mod sends a
|
||||
//! batch of command lines in one payload, and the server runs each exactly as
|
||||
//! that player typing it would (`guest_command`'s checks, the player's own
|
||||
//! permission level, at the time it runs) with its output suppressed, as
|
||||
//! vanilla's functions run (`CommandSourceStack.withSuppressedOutput`): no
|
||||
//! feedback and no failure in anyone's chat. A batch waits in a queue and runs
|
||||
//! [`PER_TICK`] lines a player a tick, so a mod can't flood a server; each
|
||||
//! finished batch is one line in the server's log.
|
||||
use super::{Game, lan::LanHost, lan_actions::GuestCommand};
|
||||
use horizon_protocol::quiet;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
/// Lines a player may have waiting at most; a batch past it loses the rest.
|
||||
pub(super) const PENDING: usize = 4096;
|
||||
/// Lines run a player a tick at most.
|
||||
pub(super) const PER_TICK: usize = 64;
|
||||
|
||||
/// A batch waiting to run.
|
||||
pub(super) struct QuietBatch {
|
||||
conn: u32,
|
||||
mod_id: String,
|
||||
lines: VecDeque<String>,
|
||||
ran: usize,
|
||||
failed: usize,
|
||||
/// Lines past [`PENDING`], never run.
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `horizon:quiet_commands` payload from `conn`: queued (or logged as
|
||||
/// refused when it breaks the payload's caps).
|
||||
pub(super) fn guest_quiet_commands(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
|
||||
let Some(name) = host.hub.guest(conn).map(|g| g.name.clone()) else {
|
||||
return;
|
||||
};
|
||||
let batch = match quiet::decode(data) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
super::dedicated::log(
|
||||
"WARN",
|
||||
&format!("{name} sent quiet commands that were refused: {e}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let waiting: usize = host
|
||||
.quiet
|
||||
.iter()
|
||||
.filter(|b| b.conn == conn)
|
||||
.map(|b| b.lines.len())
|
||||
.sum();
|
||||
let room = PENDING.saturating_sub(waiting);
|
||||
let dropped = batch.lines.len().saturating_sub(room);
|
||||
host.quiet.push_back(QuietBatch {
|
||||
conn,
|
||||
mod_id: batch.mod_id,
|
||||
lines: batch.lines.into_iter().take(room).collect(),
|
||||
ran: 0,
|
||||
failed: 0,
|
||||
dropped,
|
||||
});
|
||||
}
|
||||
|
||||
/// The waiting batches, [`PER_TICK`] lines a player this tick, in order.
|
||||
pub(super) fn run_quiet_commands(&mut self) {
|
||||
let mut left: HashMap<u32, usize> = HashMap::new();
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(batch) = host.quiet.get_mut(i) else {
|
||||
return;
|
||||
};
|
||||
let conn = batch.conn;
|
||||
let budget = left.entry(conn).or_insert(PER_TICK);
|
||||
let gone = host.hub.guest(conn).is_none();
|
||||
if gone || batch.lines.is_empty() {
|
||||
let batch = host.quiet.remove(i).expect("got it above");
|
||||
if !gone {
|
||||
let name = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map_or(String::new(), |g| g.name.clone());
|
||||
super::dedicated::log("INFO", &summary(&name, &batch));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *budget == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
*budget -= 1;
|
||||
let line = batch.lines.pop_front().expect("not empty");
|
||||
let ok = self.run_quiet_line(conn, &line);
|
||||
if let Some(batch) = self.lan.as_mut().and_then(|h| h.quiet.get_mut(i)) {
|
||||
batch.ran += 1;
|
||||
batch.failed += usize::from(!ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line as `guest_command` would run it for `conn`, quietly. False
|
||||
/// when it is refused or fails. A command that carries a chat message
|
||||
/// (`say`, `msg`...) is refused: it would need the player's signature.
|
||||
fn run_quiet_line(&mut self, conn: u32, line: &str) -> bool {
|
||||
let allowed = self.options.dedicated.is_some() || self.allow_commands();
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let integrated = host.hub.config.integrated;
|
||||
if !integrated && (!host.settings.guest_commands || !allowed) {
|
||||
return false;
|
||||
}
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
return false;
|
||||
};
|
||||
let command = line.trim_start_matches('/');
|
||||
if !self.signable_arguments(permission, command).is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.run_guest_command(GuestCommand {
|
||||
conn,
|
||||
command: command.to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The batch's line in the server's log.
|
||||
fn summary(name: &str, b: &QuietBatch) -> String {
|
||||
let mut line = format!(
|
||||
"{name} ran {} quiet command(s) from mod {}: {} failed",
|
||||
b.ran, b.mod_id, b.failed
|
||||
);
|
||||
if b.dropped > 0 {
|
||||
line += &format!(", {} dropped (more than {PENDING} waiting)", b.dropped);
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_log_line_counts_the_batch() {
|
||||
let b = QuietBatch {
|
||||
conn: 1,
|
||||
mod_id: "orbital".into(),
|
||||
lines: VecDeque::new(),
|
||||
ran: 12,
|
||||
failed: 3,
|
||||
dropped: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
summary("Alex", &b),
|
||||
"Alex ran 12 quiet command(s) from mod orbital: 3 failed"
|
||||
);
|
||||
let b = QuietBatch { dropped: 5, ..b };
|
||||
assert!(summary("Alex", &b).ends_with(", 5 dropped (more than 4096 waiting)"));
|
||||
}
|
||||
}
|
||||
@@ -144,15 +144,16 @@ impl Game<'_> {
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let cost =
|
||||
|st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost {
|
||||
item: hub.item_id(&st.item),
|
||||
count: st.count as i32,
|
||||
};
|
||||
let cost = |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| {
|
||||
hub.stack(&Some(st.clone())).map(play::NetCost::from)
|
||||
};
|
||||
Some(play::NetOffer {
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub),
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub)?,
|
||||
result: host.hub.stack(&Some(o.result.clone()))?,
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)),
|
||||
cost_b: match &o.cost_b {
|
||||
Some(b) => Some(cost(&b.stack, &mut host.hub)?),
|
||||
None => None,
|
||||
},
|
||||
out_of_stock: o.out_of_stock(),
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
|
||||
@@ -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,
|
||||
@@ -135,6 +137,7 @@ impl Game<'_> {
|
||||
paintings: Vec::new(),
|
||||
item_frames: Vec::new(),
|
||||
armor_stands: Vec::new(),
|
||||
cushions: Vec::new(),
|
||||
others: Vec::new(),
|
||||
boats: Vec::new(),
|
||||
carts: Vec::new(),
|
||||
@@ -159,6 +162,7 @@ impl Game<'_> {
|
||||
lan_events: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
anims: Default::default(),
|
||||
raiding: Default::default(),
|
||||
sculk: Default::default(),
|
||||
@@ -185,6 +189,7 @@ impl Game<'_> {
|
||||
swap(&mut self.paintings, &mut p.paintings);
|
||||
swap(&mut self.item_frames, &mut p.item_frames);
|
||||
swap(&mut self.armor_stands, &mut p.armor_stands);
|
||||
swap(&mut self.cushions, &mut p.cushions);
|
||||
swap(&mut self.others, &mut p.others);
|
||||
swap(&mut self.boats, &mut p.boats);
|
||||
swap(&mut self.carts, &mut p.carts);
|
||||
@@ -209,6 +214,7 @@ impl Game<'_> {
|
||||
swap(&mut self.lan_events, &mut p.lan_events);
|
||||
swap(&mut self.lan_block_events, &mut p.lan_block_events);
|
||||
swap(&mut self.lan_entity_events, &mut p.lan_entity_events);
|
||||
swap(&mut self.lan_critical_hits, &mut p.lan_critical_hits);
|
||||
let (a, b) = (&mut self.be_anims, &mut p.anims);
|
||||
swap(&mut a.chests, &mut b.chests);
|
||||
swap(&mut a.books, &mut b.books);
|
||||
@@ -287,8 +293,8 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// One server tick (`MinecraftServer.tickServer`): each level with
|
||||
/// players in turn (`tickChildren`), its chunks streamed and loaded and
|
||||
/// its world ticked. The first pass reads the network and runs the
|
||||
/// players or forced chunks in turn (`tickChildren`), its chunks streamed
|
||||
/// and loaded and its world ticked. The first pass reads the network and runs the
|
||||
/// tick's shared parts (the clock, the weather, functions, the autosave);
|
||||
/// the next ones only their level. While `paused`, only the connections
|
||||
/// run.
|
||||
@@ -390,7 +396,7 @@ impl Game<'_> {
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
/// (the Overworld, the Nether, the End): every level with a player in
|
||||
/// it or a `/pregen` task, or the Overworld while nobody is online.
|
||||
/// it, forced chunks or a `/pregen` task, or the Overworld while idle.
|
||||
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
|
||||
let guests = self
|
||||
.lan
|
||||
@@ -399,7 +405,17 @@ impl Game<'_> {
|
||||
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d)
|
||||
let forced = if *d == self.dim {
|
||||
!self.streaming.forced.is_empty()
|
||||
} else {
|
||||
self.levels
|
||||
.parked
|
||||
.get(d)
|
||||
.is_some_and(|p| !p.streaming.forced.is_empty())
|
||||
};
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
|| forced
|
||||
|| self.pregen_level() == Some(*d)
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
|
||||
@@ -162,11 +162,9 @@ impl Game<'_> {
|
||||
converted: Vec::new(),
|
||||
});
|
||||
}
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours.
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
|
||||
/// wherever `BaseFireBlock.getState` fire can survive.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
|
||||
if self.ui.difficulty < 2 {
|
||||
return;
|
||||
}
|
||||
let base = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
@@ -183,18 +181,13 @@ impl Game<'_> {
|
||||
if !self.res.facts().is_air(state) {
|
||||
continue;
|
||||
}
|
||||
// BaseFireBlock.getState: soul fire on soul soil and sand.
|
||||
let below = self.res.facts().get(self.world.block(p[0], p[1] - 1, p[2]));
|
||||
let name = if matches!(&*below.name, "minecraft:soul_soil" | "minecraft:soul_sand") {
|
||||
"minecraft:soul_fire"
|
||||
} else {
|
||||
"minecraft:fire"
|
||||
};
|
||||
// FireBlock.canSurvive: a sturdy floor (flammable neighbours aside).
|
||||
if below.sturdy_full >> 1 & 1 == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(fire) = self.pack.assets.registry.default_state(name) {
|
||||
// BaseFireBlock.getState and FireBlock/SoulFireBlock.canSurvive.
|
||||
let at = BlockPos::new(p[0], p[1], p[2]);
|
||||
let fire = self.with_level(|b, level, ticks| {
|
||||
let fire = b.fire_state_for(level, ticks, at);
|
||||
b.can_survive(level, ticks, at, fire).then_some(fire)
|
||||
});
|
||||
if let Some(fire) = fire {
|
||||
let _ = self.level_set(p, fire);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +212,10 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
if !b.visual_only {
|
||||
self.bolt_fire(b.at, 4);
|
||||
// Only Normal and Hard start fires with the strike itself.
|
||||
if self.ui.difficulty >= 2 {
|
||||
self.bolt_fire(b.at, 4);
|
||||
}
|
||||
self.strike_rod(b.at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::{path::PathBuf, time::Duration};
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
super::Screen,
|
||||
super::{execution_role::ExecutionRole, lan::LanHost, server_mods::ModEvent},
|
||||
horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
@@ -28,7 +29,8 @@ use {
|
||||
ui,
|
||||
},
|
||||
horizon_mods::{
|
||||
Clock, ModKey, Mods, Output, Player, Runs, Snapshot, Status, UiScreen, UiWidget,
|
||||
Clock, ModKey, Mods, Output, Player, Runs, ServerPlayer, Snapshot, Status, UiScreen,
|
||||
UiWidget,
|
||||
},
|
||||
std::time::Instant,
|
||||
winit::{event::MouseButton, keyboard::KeyCode},
|
||||
@@ -52,11 +54,24 @@ pub(super) struct ModsState {
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
/// A server mod runs here: what players do is queued for it.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) server: bool,
|
||||
/// The players server mods saw last tick, for joins and leaves.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) players: Vec<ServerPlayer>,
|
||||
/// What players did this tick, for server mods at its end.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) events: Vec<ModEvent>,
|
||||
/// `command.run` lines for this window's own world, run on the next tick:
|
||||
/// run at once, their chat lines would reach the mods while their output
|
||||
/// is still being shown, and a mod answering its own lines would recurse.
|
||||
#[cfg(feature = "mods")]
|
||||
commands: Vec<String>,
|
||||
/// `command.run-quiet` batches for this window's own world, by mod id,
|
||||
/// run on the next tick as `commands` are.
|
||||
#[cfg(feature = "mods")]
|
||||
quiet: Vec<(String, Vec<String>)>,
|
||||
/// The server's command tree with the running mods' commands added.
|
||||
#[cfg(feature = "mods")]
|
||||
remote_tree: Option<Dispatcher>,
|
||||
@@ -272,7 +287,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load_for(Runs::Client, &paths, on, vanilla_command);
|
||||
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
@@ -282,8 +297,18 @@ impl Game<'_> {
|
||||
self.sync_mod_commands();
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Which mods this game runs: a server the server mods, the window
|
||||
/// client mods, and both when it simulates the world itself (in-frame).
|
||||
fn mod_runs(&self) -> Runs {
|
||||
match self.options.execution_role {
|
||||
ExecutionRole::IntegratedServer | ExecutionRole::DedicatedServer => Runs::Server,
|
||||
ExecutionRole::Client if self.options.server_thread => Runs::Client,
|
||||
ExecutionRole::Client => Runs::Both,
|
||||
}
|
||||
}
|
||||
/// The Mods page's rows, from what loaded.
|
||||
fn list_mods(&mut self) {
|
||||
self.mods.server = self.mods.mods.runs_server();
|
||||
let dev = &self.mods.dev;
|
||||
self.settings.mods.installed = self
|
||||
.mods
|
||||
@@ -369,8 +394,18 @@ impl Game<'_> {
|
||||
self.start_mods();
|
||||
}
|
||||
}
|
||||
/// What mods read this tick (or frame, for mods with a `frame` hook).
|
||||
/// What mods read this tick (or frame, for mods with a `frame` hook): the
|
||||
/// server's players as they were at the last tick's end.
|
||||
fn mod_snapshot(&mut self) -> Snapshot {
|
||||
let players = if self.mods.server {
|
||||
self.mods.players.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.mod_snapshot_with(players)
|
||||
}
|
||||
/// What mods read, with these players on the server.
|
||||
pub(super) fn mod_snapshot_with(&mut self, players: Vec<ServerPlayer>) -> Snapshot {
|
||||
let player = self.player.as_ref().map(|p| Player {
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
@@ -414,7 +449,7 @@ impl Game<'_> {
|
||||
mappings,
|
||||
cps,
|
||||
font_widths: self.mods.font_widths.clone(),
|
||||
players: Vec::new(),
|
||||
players,
|
||||
view_hit: self.mod_view_hit(),
|
||||
}
|
||||
}
|
||||
@@ -474,6 +509,51 @@ impl Game<'_> {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// A mod's `command.run-quiet` lines (vanilla's commands only, as
|
||||
/// `command.run`): to a server that runs them quietly as one payload
|
||||
/// (`horizon_protocol::quiet`), to any other as chat commands, or for this
|
||||
/// window's own world next tick.
|
||||
fn mod_quiet_commands(&mut self, mod_id: String, lines: Vec<String>) {
|
||||
let lines: Vec<String> = lines
|
||||
.into_iter()
|
||||
.filter(|l| vanilla_command(l.split(' ').next().unwrap_or_default()))
|
||||
.collect();
|
||||
match self.remote.as_ref().map(|r| r.quiet_commands) {
|
||||
Some(true) => {
|
||||
for data in horizon_protocol::quiet::encode(&mod_id, &lines) {
|
||||
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
|
||||
horizon_protocol::packets::configuration::CustomPayload {
|
||||
channel: horizon_protocol::quiet::CHANNEL.into(),
|
||||
data,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
// The server checks the player's permissions; its feedback shows.
|
||||
Some(false) => {
|
||||
for line in lines {
|
||||
self.send_chat(&format!("/{line}"));
|
||||
}
|
||||
}
|
||||
None => self.mods.quiet.push((mod_id, lines)),
|
||||
}
|
||||
}
|
||||
/// A quiet batch in this window's own world, as a server runs one
|
||||
/// (game/lan_quiet.rs): each line as the player typing it, no chat, a
|
||||
/// chat message refused; one line in the log.
|
||||
fn run_quiet_batch(&mut self, mod_id: &str, lines: &[String]) {
|
||||
let permission = self.ui.registries.permission;
|
||||
let mut failed = 0;
|
||||
for line in lines {
|
||||
let ok = self.signable_arguments(permission, line).is_empty()
|
||||
&& self.run_command_quietly(line);
|
||||
failed += usize::from(!ok);
|
||||
}
|
||||
log::info!(
|
||||
"[mod {mod_id}] ran {} quiet command(s): {failed} failed",
|
||||
lines.len()
|
||||
);
|
||||
}
|
||||
/// A game tick.
|
||||
pub(super) fn tick_mods(&mut self) {
|
||||
// Here, at the player's level: shown inside a console call (a control
|
||||
@@ -481,6 +561,9 @@ impl Game<'_> {
|
||||
for line in std::mem::take(&mut self.mods.commands) {
|
||||
self.run_command(&line);
|
||||
}
|
||||
for (mod_id, lines) in std::mem::take(&mut self.mods.quiet) {
|
||||
self.run_quiet_batch(&mod_id, &lines);
|
||||
}
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
@@ -490,23 +573,32 @@ impl Game<'_> {
|
||||
self.mods.mods.tick(self.ticks);
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Once per frame: the frame hook, and dev mods' rebuilds.
|
||||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||||
if !self.mods.dev.is_empty()
|
||||
&& self
|
||||
/// Restarts the dev mods rebuilt since the last check, once a second.
|
||||
pub(super) fn reload_dev_mods(&mut self) {
|
||||
if self.mods.dev.is_empty()
|
||||
|| self
|
||||
.mods
|
||||
.checked
|
||||
.is_none_or(|t| t.elapsed() >= RELOAD_CHECK)
|
||||
.is_some_and(|t| t.elapsed() < RELOAD_CHECK)
|
||||
{
|
||||
self.mods.checked = Some(Instant::now());
|
||||
if self.mods.mods.reload_changed(vanilla_command) {
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
return;
|
||||
}
|
||||
self.mods.checked = Some(Instant::now());
|
||||
if self.mods.mods.reload_changed(vanilla_command) {
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
if self.options.execution_role.is_client() {
|
||||
self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY));
|
||||
} else {
|
||||
log::info!("Horizon: mods reloaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Once per frame: the frame hook, and dev mods' rebuilds.
|
||||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||||
self.reload_dev_mods();
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
@@ -537,8 +629,13 @@ impl Game<'_> {
|
||||
self.mods.mods.chat(line);
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Chat lines, command feedback and stopped mods, into the chat.
|
||||
fn show_mod_output(&mut self) {
|
||||
/// What mods showed: client mods' lines into the chat, server mods'
|
||||
/// to their players.
|
||||
pub(super) fn show_mod_output(&mut self) {
|
||||
self.route_mod_output(None);
|
||||
}
|
||||
/// [`Game::show_mod_output`], with the hub when it is out of `self.lan`.
|
||||
pub(super) fn route_mod_output(&mut self, mut host: Option<&mut LanHost>) {
|
||||
for output in self.mods.mods.drain() {
|
||||
let line = match output {
|
||||
Output::Chat { name, text } => {
|
||||
@@ -561,12 +658,25 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Output::QuietCommands { mod_id, lines } => {
|
||||
self.mod_quiet_commands(mod_id, lines);
|
||||
continue;
|
||||
}
|
||||
Output::Stopped { name, reason } => {
|
||||
// Its screen goes with it: the entry's run is gone, so
|
||||
// `mod_ui_view` finds no widgets and the screen closes.
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
|
||||
let line = format!("Mod {name} stopped: {reason}");
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log("WARN", &line);
|
||||
}
|
||||
Text::literal(line).with_color(text::RED)
|
||||
}
|
||||
server
|
||||
@ (Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. }) => {
|
||||
self.route_server_output(host.as_deref_mut(), server);
|
||||
continue;
|
||||
}
|
||||
Output::OpenScreen { owner } => {
|
||||
self.open_mod_screen(owner, false);
|
||||
@@ -582,20 +692,8 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Server mods' lines: this game runs no server mod yet.
|
||||
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
|
||||
};
|
||||
// Not through system_message: a mod must not hear its own lines.
|
||||
// Written by this client, so the Chat option doesn't hide it.
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
self.add_mod_chat_line(line);
|
||||
}
|
||||
// A mod may set or clear its settings page in any call, not only
|
||||
// while loading: its row's Settings button follows.
|
||||
@@ -604,6 +702,20 @@ impl Game<'_> {
|
||||
row.has_settings = e.has_ui_settings();
|
||||
}
|
||||
}
|
||||
/// A mod's line in the window's chat; not through system_message: a mod
|
||||
/// must not hear its own lines.
|
||||
/// Written by this client, so the Chat option doesn't hide it.
|
||||
pub(super) fn add_mod_chat_line(&mut self, line: Text) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
}
|
||||
/// The mods' HUD panels, stacked with the extras in their corners.
|
||||
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
for panel in self.mods.mods.panels() {
|
||||
|
||||
@@ -314,7 +314,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The lines of one name tag, `o` from the camera: the text at
|
||||
/// `text_alpha` in its layer, on the translucent black background
|
||||
/// (unlit, a hair behind) when `background`.
|
||||
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn name_tag_quads(
|
||||
&self,
|
||||
@@ -386,7 +386,7 @@ impl Game<'_> {
|
||||
[white.u0, white.v0, white.u1, white.v1],
|
||||
BACKGROUND,
|
||||
true,
|
||||
2,
|
||||
text_layer,
|
||||
);
|
||||
}
|
||||
let mut x = x0;
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! window, waits until it stands in the world with the chunks around it,
|
||||
//! sends a chat message and `/list`, collects the chat it is shown, leaves
|
||||
//! and writes a JSON report (position, chunks, chat lines, whether the
|
||||
//! server kept it connected throughout).
|
||||
//! server kept it connected throughout). With `HORIZON_NET_QUIET` (lines
|
||||
//! split by `;`) it then sends them as one batch of a mod's quiet commands
|
||||
//! (`horizon_protocol::quiet`, `tools/quiet_commands_e2e.py`) and waits two
|
||||
//! seconds for any chat they bring.
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
use serde_json::json;
|
||||
@@ -23,6 +26,9 @@ impl Game<'_> {
|
||||
let mut step_at = Instant::now();
|
||||
let mut chat_at = 0;
|
||||
let mut position = None;
|
||||
let quiet: Vec<String> = std::env::var("HORIZON_NET_QUIET")
|
||||
.map(|v| v.split(';').map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
let mut chunks = 0;
|
||||
let mut error = None;
|
||||
while Instant::now() < deadline {
|
||||
@@ -71,6 +77,22 @@ impl Game<'_> {
|
||||
step_at = now;
|
||||
chat_at = shown;
|
||||
}
|
||||
2 if answered && !quiet.is_empty() => {
|
||||
for data in horizon_protocol::quiet::encode("net_harness", &quiet) {
|
||||
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
|
||||
horizon_protocol::packets::configuration::CustomPayload {
|
||||
channel: horizon_protocol::quiet::CHANNEL.into(),
|
||||
data,
|
||||
},
|
||||
));
|
||||
}
|
||||
step = 4;
|
||||
step_at = now;
|
||||
}
|
||||
4 if now - step_at > Duration::from_secs(2) => {
|
||||
step = 3;
|
||||
break;
|
||||
}
|
||||
2 if answered => {
|
||||
step = 3;
|
||||
break;
|
||||
|
||||
@@ -454,11 +454,24 @@ impl Game<'_> {
|
||||
for m in &self.mobs {
|
||||
self.portals.before.insert(m.id, m.position);
|
||||
}
|
||||
for b in &self.boats {
|
||||
self.portals.before.insert(b.id, b.position);
|
||||
}
|
||||
for c in &self.carts {
|
||||
self.portals.before.insert(c.id, c.position);
|
||||
}
|
||||
let ids: Vec<(u32, bool)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.map(|b| b.id)
|
||||
.chain(self.carts.iter().map(|c| c.id))
|
||||
.map(|id| (id, !self.vehicle_occupied(id))),
|
||||
)
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
@@ -501,8 +514,15 @@ impl Game<'_> {
|
||||
&& self.riding != Some(m.id)
|
||||
&& m.species() != Species::Wither
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items and mobs that moved this
|
||||
/// tick: the portal blocks their box swept through.
|
||||
/// Whether the player, a server player or a mob rides vehicle `id`.
|
||||
fn vehicle_occupied(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
|
||||
/// and minecarts that moved this tick: the portal blocks their box swept
|
||||
/// through.
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -519,8 +539,23 @@ impl Game<'_> {
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
|
||||
.chain(self.carts.iter().map(|c| {
|
||||
(
|
||||
c.id,
|
||||
c.position,
|
||||
super::minecarts::WIDTH,
|
||||
super::minecarts::HEIGHT,
|
||||
)
|
||||
}))
|
||||
.filter(|(id, ..)| !self.vehicle_occupied(*id))
|
||||
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
|
||||
.collect();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
@@ -678,6 +713,44 @@ impl Game<'_> {
|
||||
}
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
let plan = self.portal_plan(entry, b.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = &self.boats[i];
|
||||
let tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.boats.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let c = &self.carts[i];
|
||||
let (width, height) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.portal_plan(entry, c.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
|
||||
self.carts.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
|
||||
@@ -257,6 +257,7 @@ impl Game<'_> {
|
||||
lang::set(lang::Language::load(&[&root], code));
|
||||
self.refresh_remote_language();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
self.creative_screen.forget_items();
|
||||
}
|
||||
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
|
||||
pub(super) fn apply_ui_settings(&mut self) {
|
||||
@@ -268,6 +269,11 @@ impl Game<'_> {
|
||||
r.animated_textures = s.horizon.animated_textures;
|
||||
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
|
||||
r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa);
|
||||
r.set_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.hdr_paper_white as f32,
|
||||
s.horizon.hdr_peak as f32,
|
||||
);
|
||||
}
|
||||
// Window.updateFullscreenIfChanged: only a changed option asks the
|
||||
// window to change, so applying settings never undoes a fullscreen
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -71,6 +71,8 @@ pub(super) struct BodyPotions {
|
||||
pub(super) struct PotionState {
|
||||
pub(super) effects: PlayerEffects,
|
||||
thrown: Vec<ThrownPotion>,
|
||||
/// The area effect clouds a server tracks (game/remote_projectiles.rs).
|
||||
pub(super) clouds: Vec<super::remote_projectiles::RemoteCloud>,
|
||||
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
|
||||
/// Armor the protection table was computed for.
|
||||
protection_for: Option<Vec<Option<ItemStack>>>,
|
||||
@@ -141,14 +143,22 @@ impl PotionState {
|
||||
t.stack = stack;
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
for t in &mut self.thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
/// A server's move: thrown potions have no interpolation handler, so
|
||||
/// `moveOrInterpolateTo` puts them there at once. False when `id` isn't one.
|
||||
pub(super) fn move_remote_thrown(&mut self, id: u32, at: Vec3) -> bool {
|
||||
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
|
||||
return false;
|
||||
};
|
||||
t.position = at;
|
||||
true
|
||||
}
|
||||
/// `handleSetEntityMotion` for a thrown potion. False when `id` isn't one.
|
||||
pub(super) fn remote_thrown_motion(&mut self, id: u32, velocity: Vec3) -> bool {
|
||||
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
|
||||
return false;
|
||||
};
|
||||
t.velocity = velocity;
|
||||
true
|
||||
}
|
||||
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
@@ -484,6 +494,7 @@ impl Game<'_> {
|
||||
thrown.retain_mut(|t| {
|
||||
t.age += 1;
|
||||
t.previous = t.position;
|
||||
self.thrown_potion_motion(t);
|
||||
let next = Vec3::new(
|
||||
t.position.x + t.velocity.x,
|
||||
t.position.y + t.velocity.y,
|
||||
@@ -520,18 +531,6 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
// ThrowableItemProjectile: drag 0.99 then gravity 0.05.
|
||||
// ThrownExperienceBottle.getDefaultGravity is 0.07.
|
||||
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
|
||||
0.07
|
||||
} else {
|
||||
0.05
|
||||
};
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x * 0.99,
|
||||
t.velocity.y * 0.99 - gravity,
|
||||
t.velocity.z * 0.99,
|
||||
);
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
@@ -539,6 +538,60 @@ impl Game<'_> {
|
||||
self.splash(&stack, at);
|
||||
}
|
||||
}
|
||||
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
|
||||
/// come before the move: gravity 0.05 (0.07 for an experience bottle),
|
||||
/// then drag 0.99 (0.8 in water).
|
||||
fn thrown_potion_motion(&self, t: &mut ThrownPotion) {
|
||||
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
|
||||
0.07
|
||||
} else {
|
||||
0.05
|
||||
};
|
||||
let at = t.position;
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
let inertia = if water { 0.8 } else { 0.99 };
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x * inertia,
|
||||
(t.velocity.y - gravity) * inertia,
|
||||
t.velocity.z * inertia,
|
||||
);
|
||||
}
|
||||
/// `ThrowableProjectile.tick` on a server's client: it falls and moves
|
||||
/// by itself between the server's updates and stops at a block; only
|
||||
/// the server breaks it.
|
||||
pub(super) fn step_remote_thrown(&mut self) {
|
||||
let mut thrown = std::mem::take(&mut self.potions.thrown);
|
||||
for t in &mut thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
self.thrown_potion_motion(t);
|
||||
let next = Vec3::new(
|
||||
t.position.x + t.velocity.x,
|
||||
t.position.y + t.velocity.y,
|
||||
t.position.z + t.velocity.z,
|
||||
);
|
||||
let state = self.world.block(
|
||||
next.x.floor() as i32,
|
||||
next.y.floor() as i32,
|
||||
next.z.floor() as i32,
|
||||
);
|
||||
if self.res.facts().get(state).full_collision {
|
||||
t.velocity = Vec3::ZERO;
|
||||
} else {
|
||||
t.position = next;
|
||||
}
|
||||
}
|
||||
self.potions.thrown = thrown;
|
||||
}
|
||||
/// `ThrownItemRenderer` for thrown splash and lingering potions and
|
||||
/// experience bottles: the item (GROUND) facing the camera at scale 1,
|
||||
/// lit at the light probe (the eye, 0.85 of the 0.25 height).
|
||||
|
||||
@@ -109,6 +109,8 @@ enum Received {
|
||||
pub(super) struct Remote {
|
||||
pub session: Session,
|
||||
pub brand: String,
|
||||
/// The server announced it runs mods' quiet commands.
|
||||
pub quiet_commands: bool,
|
||||
pub(super) builtin: Arc<Builtin>,
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
@@ -419,6 +421,7 @@ impl Game<'_> {
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
quiet_commands: false,
|
||||
builtin: Arc::new(builtin),
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
@@ -839,7 +842,15 @@ impl Game<'_> {
|
||||
remote.brand = brand;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => self.set_registries(registries),
|
||||
Event::QuietCommands => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.quiet_commands = true;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
self.boss_overlay.reset();
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
// singleplayer" (no ServerData), so the world just closes.
|
||||
Event::Transfer { host, port } => {
|
||||
@@ -1258,6 +1269,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.res.mob_types().get(kind).cloned()
|
||||
};
|
||||
if kind == "minecraft:ender_dragon" {
|
||||
self.spawn_remote_dragon(id, position, yaw);
|
||||
}
|
||||
if kind == "minecraft:experience_orb" {
|
||||
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
@@ -1364,13 +1378,17 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| {
|
||||
p.id == id as u32
|
||||
&& matches!(
|
||||
p.kind,
|
||||
horizon_client::projectiles::ProjectileKind::LargeFireball { .. }
|
||||
)
|
||||
}) {
|
||||
// Projectiles keep Entity's NO_OP InterpolationHandler too: the
|
||||
// server's position replaces theirs at once and their own tick
|
||||
// flies them on (`tick_remote_projectiles`). Eased towards it
|
||||
// instead, they only moved when an update came, every 10 to 20
|
||||
// ticks.
|
||||
if let Some(p) = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter_mut()
|
||||
.find(|p| p.id == id as u32)
|
||||
{
|
||||
if path.is_some() {
|
||||
p.position = Vec3::new(position[0], position[1], position[2]);
|
||||
}
|
||||
@@ -1380,6 +1398,22 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if path.is_some()
|
||||
&& (self.potions.move_remote_thrown(
|
||||
id as u32,
|
||||
Vec3::new(position[0], position[1], position[2]),
|
||||
) || self.end.move_remote_eye(id as u32, position))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
|
||||
// the server's position replaces its own at once.
|
||||
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
if path.is_some() {
|
||||
b.position = Vec3::new(position[0], position[1], position[2]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
@@ -1426,6 +1460,8 @@ impl Game<'_> {
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.dropped.retain(|d| d.id != id as u32);
|
||||
// Before the crystals go: the dragon's beam index follows them.
|
||||
self.remove_remote_dragon(id);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_projectile(id as u32);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -1661,19 +1697,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::BlockChangedAck(p) => self.end_predictions(p.sequence),
|
||||
// ClientLevel.blockEvent: BlockEntity.triggerEvent (chest lids,
|
||||
// shulker boxes, bells) when the block is still the one it names.
|
||||
// Level.blockEvent on the client level: the block's triggerEvent
|
||||
// runs on the client's copy of whatever is at pos (a piston's
|
||||
// moveBlocks builds the moving block entities it animates, a note
|
||||
// block plays); what it doesn't handle reaches the client copy of
|
||||
// the block entity (chest lids, shulker boxes).
|
||||
C::BlockEvent(p) => {
|
||||
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
|
||||
let block = &self.res.facts().get(state).name;
|
||||
let named = self.remote.as_ref().and_then(|r| {
|
||||
r.builtin
|
||||
.get("minecraft:block")?
|
||||
.name(p.block)
|
||||
.map(str::to_owned)
|
||||
let pos = horizon_core::position::BlockPos::new(p.pos[0], p.pos[1], p.pos[2]);
|
||||
let triggered = self.with_level(|b, level, ticks| {
|
||||
b.client_block_event(level, ticks, pos, i32::from(p.b0), i32::from(p.b1))
|
||||
});
|
||||
if named.as_deref() == Some(&**block) {
|
||||
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
|
||||
self.sync_moving_pistons();
|
||||
self.process_level_events()?;
|
||||
if !triggered {
|
||||
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
|
||||
let block = &self.res.facts().get(state).name;
|
||||
let named = self.remote.as_ref().and_then(|r| {
|
||||
r.builtin
|
||||
.get("minecraft:block")?
|
||||
.name(p.block)
|
||||
.map(str::to_owned)
|
||||
});
|
||||
if named.as_deref() == Some(&**block) {
|
||||
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
|
||||
}
|
||||
}
|
||||
}
|
||||
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
|
||||
@@ -1958,6 +2005,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.remote_object_data(p.id, &p.values);
|
||||
self.remote_projectile_data(p.id, &p.values);
|
||||
self.remote_dragon_data(p.id, &p.values);
|
||||
}
|
||||
// ClientPacketListener.handleSetEquipment: what a mob or another
|
||||
// player holds and wears (EquipmentSlot ordinals).
|
||||
@@ -2039,6 +2087,8 @@ impl Game<'_> {
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
} else {
|
||||
self.remote_dragon_hurt(p.id);
|
||||
}
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
@@ -2048,9 +2098,14 @@ impl Game<'_> {
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
} else {
|
||||
self.remote_dragon_hurt(p.id);
|
||||
}
|
||||
}
|
||||
C::EntityEvent(p) => {
|
||||
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
|
||||
self.remote_firework_explosion(p.id);
|
||||
}
|
||||
// LocalPlayer.setPermissions also refreshes an open World Options screen.
|
||||
if (24..=28).contains(&p.event)
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -2064,6 +2119,11 @@ impl Game<'_> {
|
||||
{
|
||||
horizon_client::mobs::rabbit::hop_event(m);
|
||||
}
|
||||
if p.event == 19
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
horizon_client::mobs::aquatic::squid_cycle_event(m);
|
||||
}
|
||||
// EntityEvent.DEATH: start the death animation.
|
||||
if p.event == 3
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
@@ -2936,8 +2996,12 @@ impl Game<'_> {
|
||||
/// entities whose own tick moves them on a client: dropped items, falling
|
||||
/// blocks and primed TNT.
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
self.remote_dragon_motion(id, velocity);
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
// Squid.aiStep derives its body pitch and roll from network motion.
|
||||
m.velocity = v;
|
||||
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.velocity = v;
|
||||
@@ -2950,12 +3014,16 @@ impl Game<'_> {
|
||||
.find(|p| p.id == id as u32)
|
||||
{
|
||||
p.velocity = v;
|
||||
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
b.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.pending_items.get_mut(&id))
|
||||
{
|
||||
p.1 = v;
|
||||
} else if !self.potions.remote_thrown_motion(id as u32, v) {
|
||||
self.end.remote_eye_motion(id as u32, velocity);
|
||||
}
|
||||
}
|
||||
fn apply_remote_health(&mut self) {
|
||||
@@ -3062,6 +3130,12 @@ impl Game<'_> {
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
|
||||
// for a server's client (multiplayer and the default single player),
|
||||
// so the entries advance here or the slot stays shaded until the
|
||||
// server says the entry ended. `onCooldownEnded` is the plain
|
||||
// ItemCooldowns' no-op on a client.
|
||||
self.cooldowns.tick();
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
// is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
@@ -3099,8 +3173,14 @@ impl Game<'_> {
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
self.tick_remote_entities(running);
|
||||
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells.
|
||||
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
|
||||
// and the moving pistons a block event's client-side moveBlocks made
|
||||
// (their progress advances between the server's block updates).
|
||||
self.tick_block_entity_anims();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
});
|
||||
self.sync_moving_pistons();
|
||||
self.tick_hand();
|
||||
self.tick_particles();
|
||||
self.tick_client_ambient();
|
||||
@@ -3425,6 +3505,13 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Creeper {
|
||||
horizon_client::mobs::hostile::creeper_client_tick(m);
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
horizon_client::mobs::Species::Guardian
|
||||
| horizon_client::mobs::Species::ElderGuardian
|
||||
) {
|
||||
horizon_client::mobs::guardian::client_tick(m);
|
||||
}
|
||||
if m.hurt_time > 0 {
|
||||
m.hurt_time -= 1;
|
||||
}
|
||||
@@ -3484,6 +3571,8 @@ impl Game<'_> {
|
||||
} else if m.alive() {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
// Squid.aiStep follows LivingEntity's interpolation and head turn.
|
||||
horizon_client::mobs::aquatic::squid_client_step(m);
|
||||
// WalkAnimationState.update from the horizontal distance moved.
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0);
|
||||
@@ -3492,6 +3581,9 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Rabbit {
|
||||
horizon_client::mobs::rabbit::post_tick(m);
|
||||
}
|
||||
if m.species() == horizon_client::mobs::Species::Bat {
|
||||
horizon_client::mobs::bat::setup_animation_states(m);
|
||||
}
|
||||
}
|
||||
for o in self.orbs.iter_mut().filter(|_| running) {
|
||||
o.previous = o.position;
|
||||
@@ -3520,6 +3612,18 @@ impl Game<'_> {
|
||||
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
|
||||
}
|
||||
if running {
|
||||
// The dragon: LivingEntity's interpolation, inside its aiStep.
|
||||
let dragon_lerp = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.and_then(|d| lerps.get_mut(&(d.id as i32)))
|
||||
.filter(|l| l.steps > 0)
|
||||
.map(|l| {
|
||||
let steps = l.steps;
|
||||
l.steps -= 1;
|
||||
(l.target, l.rotation.map(|r| r.0), steps)
|
||||
});
|
||||
self.tick_remote_dragon(dragon_lerp);
|
||||
// Boats, minecarts, armor stands, projectiles and the rest
|
||||
// interpolate like mobs.
|
||||
let mut step = |id: u32, at: Vec3| match lerps.get_mut(&(id as i32)) {
|
||||
@@ -3682,6 +3786,15 @@ impl Game<'_> {
|
||||
|
||||
/// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock.
|
||||
fn remote_mining(&mut self, start: bool) {
|
||||
if !self
|
||||
.res
|
||||
.items()
|
||||
.can_destroy_block(self.held(), self.creative)
|
||||
{
|
||||
self.stop_remote_mining();
|
||||
self.mining = None;
|
||||
return;
|
||||
}
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -4513,6 +4626,7 @@ impl Game<'_> {
|
||||
}
|
||||
KeyCode::KeyE => {
|
||||
// The inventory opens without telling the server.
|
||||
self.recipe_ui.reset_screen();
|
||||
self.screen = if self.creative {
|
||||
Screen::Creative
|
||||
} else {
|
||||
@@ -4779,6 +4893,7 @@ impl Game<'_> {
|
||||
self.remote_close_local();
|
||||
}
|
||||
fn remote_close_local(&mut self) {
|
||||
self.recipe_ui.reset_screen();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = None;
|
||||
r.menu_title = None;
|
||||
@@ -4814,16 +4929,20 @@ impl Game<'_> {
|
||||
if self.open_menu_name(p.container) != "minecraft:merchant" {
|
||||
return;
|
||||
}
|
||||
let cost = |g: &Self, c: &play::NetCost| horizon_client::merchant::ItemCost {
|
||||
stack: ItemStack::new(&g.item_name(c.item), c.count.max(0) as u32),
|
||||
let cost = |g: &Self, c: &play::NetCost| {
|
||||
g.to_stack(&Some(c.clone().into()))
|
||||
.map(|stack| horizon_client::merchant::ItemCost { stack })
|
||||
};
|
||||
let offers: Vec<_> = p
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let mut offer = horizon_client::merchant::MerchantOffer {
|
||||
base_cost_a: cost(self, &o.cost_a),
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(self, b)),
|
||||
base_cost_a: cost(self, &o.cost_a)?,
|
||||
cost_b: match &o.cost_b {
|
||||
Some(b) => Some(cost(self, b)?),
|
||||
None => None,
|
||||
},
|
||||
result: self.to_stack(&Some(o.result.clone()))?,
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
@@ -4849,6 +4968,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// ClientboundOpenScreenPacket for the menus Horizon draws.
|
||||
fn remote_open_screen(&mut self, p: play::OpenScreen) {
|
||||
// A new RecipeBookComponent owns fresh ghost slots and an overlay,
|
||||
// even when the server replaces a menu without a close packet.
|
||||
self.recipe_ui.reset_screen();
|
||||
let menu = self
|
||||
.remote
|
||||
.as_ref()
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, tame};
|
||||
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_protocol::registry::Builtin;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
/// `builtin` names the ids of built-in registries.
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
@@ -46,6 +46,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
_ => {}
|
||||
}
|
||||
match (species, *index, value) {
|
||||
(Species::Guardian | Species::ElderGuardian, 16, Value::Bool(moving)) => {
|
||||
m.extra.flag2 = *moving;
|
||||
}
|
||||
(Species::Guardian | Species::ElderGuardian, 17, Value::Int(target)) => {
|
||||
// Guardian.onSyncedDataUpdated resets the visual charge clock.
|
||||
m.extra.counter2 = 0;
|
||||
m.extra.flag = *target != 0;
|
||||
m.ai.target = (*target != 0).then_some(if *target == local_entity {
|
||||
Who::Player
|
||||
} else {
|
||||
Who::Mob(*target as u32)
|
||||
});
|
||||
}
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0.
|
||||
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
(Species::Sheep, 18, Value::Byte(wool)) => {
|
||||
m.extra.color = (*wool & 15) as u8;
|
||||
@@ -114,7 +129,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values, &r.builtin);
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +141,10 @@ pub(super) fn client_defaults(m: &mut Mob) {
|
||||
if matches!(m.species(), Species::Wolf | Species::Cat) {
|
||||
m.extra.color = tame::RED;
|
||||
}
|
||||
if m.species() == Species::Bat {
|
||||
// Bat's constructor sets resting only on the server; the data default is 0.
|
||||
bat::set_resting(m, false);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -147,7 +166,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
super::apply(m, values, &builtin());
|
||||
super::apply(m, values, &builtin(), 99);
|
||||
}
|
||||
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
@@ -158,6 +177,49 @@ mod tests {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
|
||||
use horizon_client::mobs::guardian::{beam, client_tick};
|
||||
for (kind, duration) in [("guardian", 80), ("elder_guardian", 60)] {
|
||||
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
|
||||
return;
|
||||
};
|
||||
assert!(beam(&m, 0.0).is_none());
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(16, s::BOOLEAN, Value::Bool(true)),
|
||||
(17, s::INT, Value::Int(99)),
|
||||
],
|
||||
);
|
||||
assert!(m.extra.flag2);
|
||||
assert_eq!(m.ai.target, Some(Who::Player));
|
||||
assert_eq!(beam(&m, 0.0), Some((0.0, 0.0)));
|
||||
for _ in 0..duration + 10 {
|
||||
client_tick(&mut m);
|
||||
}
|
||||
assert_eq!(beam(&m, 0.0), Some((duration as f32, 1.0)));
|
||||
// Another player or a mob retains its network entity id.
|
||||
apply(&mut m, &[(17, s::INT, Value::Int(42))]);
|
||||
assert_eq!(m.ai.target, Some(Who::Mob(42)));
|
||||
assert_eq!(m.extra.counter2, 0);
|
||||
client_tick(&mut m);
|
||||
assert_eq!(m.extra.counter2, 1);
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(16, s::BOOLEAN, Value::Bool(false)),
|
||||
(17, s::INT, Value::Int(0)),
|
||||
],
|
||||
);
|
||||
client_tick(&mut m);
|
||||
assert!(!m.extra.flag2);
|
||||
assert_eq!(m.ai.target, None);
|
||||
assert_eq!(m.extra.counter2, 0);
|
||||
assert!(beam(&m, 0.0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
|
||||
let Some(mut m) = mob("minecraft:sheep") else {
|
||||
@@ -174,6 +236,69 @@ mod tests {
|
||||
assert!(!calf.is_baby());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bat_flags_select_continuous_client_clocks_without_changing_motion() {
|
||||
use horizon_client::mobs::{MobTypes, anim, keyframes::Animation};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::json;
|
||||
use std::{collections::HashMap, fs, sync::Arc};
|
||||
|
||||
// Original minimal resources keep this regression runnable without vanilla data.
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-bat-animation-{}", std::process::id()));
|
||||
fs::create_dir(&root).unwrap();
|
||||
fs::write(root.join("entity-types.json"), r#"{"minecraft:bat": {}}"#).unwrap();
|
||||
fs::write(
|
||||
root.join("entity-models.json"),
|
||||
r#"{"minecraft:bat#main": {"children": {"right_wing": {}, "head": {}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut types = MobTypes::load(&root).unwrap();
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
let position = Vec3::new(1.0, 2.25, 3.0);
|
||||
let velocity = Vec3::new(0.2, 0.3, 0.4);
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(types.types.remove("minecraft:bat").unwrap()),
|
||||
position,
|
||||
0.0,
|
||||
);
|
||||
assert!(bat::resting(&m));
|
||||
client_defaults(&mut m);
|
||||
assert!(!bat::resting(&m));
|
||||
m.velocity = velocity;
|
||||
m.age = 1;
|
||||
bat::setup_animation_states(&mut m);
|
||||
m.age = 4;
|
||||
apply(&mut m, &[(16, s::BYTE, Value::Byte(2))]);
|
||||
bat::setup_animation_states(&mut m);
|
||||
assert_eq!(m.anim.states[0].started, Some(1.0));
|
||||
|
||||
let curve = Animation::parse(&json!({"length": 1, "looping": true,
|
||||
"bones": {"right_wing": [{"target": "rotation", "keyframes":
|
||||
[[0,[0,0,0],[0,0,0],"linear"],[1,[0,1,0],[0,1,0],"linear"]]}]}}));
|
||||
let animations = HashMap::from([("BatAnimation.BAT_FLYING".into(), curve)]);
|
||||
let wing = |partial| {
|
||||
let mut poses = anim::Poses::new(&m.kind.model);
|
||||
anim::setup(&m, &anim::state(&m, partial), &mut poses, &animations);
|
||||
poses.get("right_wing")[4]
|
||||
};
|
||||
assert!(wing(0.5) > wing(0.0));
|
||||
|
||||
m.age = 5;
|
||||
apply(&mut m, &[(16, s::BYTE, Value::Byte(3))]);
|
||||
bat::setup_animation_states(&mut m);
|
||||
assert!(bat::resting(&m));
|
||||
assert_eq!(m.anim.states[0].started, None);
|
||||
assert_eq!(m.anim.states[1].started, Some(5.0));
|
||||
m.age = 7;
|
||||
apply(&mut m, &[(16, s::BYTE, Value::Byte(0))]);
|
||||
bat::setup_animation_states(&mut m);
|
||||
assert_eq!(m.anim.states[0].started, Some(7.0));
|
||||
assert_eq!(m.anim.states[1].started, None);
|
||||
assert_eq!((m.position, m.velocity), (position, velocity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wolf_takes_its_owner_flags_and_collar() {
|
||||
let Some(mut m) = mob("minecraft:wolf") else {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `handleSetEquipment`): the server simulates them, the client places them
|
||||
//! where it says and interpolates their moves like a mob's.
|
||||
use super::Game;
|
||||
use horizon_client::entities::{ArmorStand, ItemFrame, Painting};
|
||||
use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use std::sync::Arc;
|
||||
@@ -57,6 +57,16 @@ impl Game<'_> {
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
});
|
||||
} else if kind == "minecraft:cushion" {
|
||||
// BlockAttachedEntity.pos: `BlockPos.containing` of the spawn.
|
||||
self.cushions.push(Cushion {
|
||||
id,
|
||||
position: at,
|
||||
pos: block,
|
||||
yaw: super::cushions::snap_yaw(yaw),
|
||||
color: 0,
|
||||
check_interval: 0,
|
||||
});
|
||||
} else if kind == "minecraft:lightning_bolt" {
|
||||
self.spawn_remote_bolt(id, position);
|
||||
} else if kind == "minecraft:painting" {
|
||||
@@ -152,6 +162,11 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) {
|
||||
// Cushion.DATA_COLOR (DyeColor, a byte-backed id).
|
||||
if let (8, Value::Id(color)) = (index, value) {
|
||||
c.color = (*color).clamp(0, 15) as u8;
|
||||
}
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
// VehicleEntity's hurt, direction and damage; the paddles.
|
||||
match (index, value) {
|
||||
@@ -267,6 +282,12 @@ impl Game<'_> {
|
||||
out.push(json!({"id": a.id, "kind": "minecraft:armor_stand", "position": at(a.position), "yaw": a.yaw,
|
||||
"small": a.small, "arms": a.show_arms, "no_base_plate": a.no_base_plate, "equipment": items}));
|
||||
}
|
||||
for c in &self.cushions {
|
||||
out.push(
|
||||
json!({"id": c.id, "kind": "minecraft:cushion", "position": at(c.position),
|
||||
"yaw": c.yaw, "color": c.color_name()}),
|
||||
);
|
||||
}
|
||||
for f in &self.item_frames {
|
||||
out.push(
|
||||
json!({"id": f.id, "kind": f.kind(), "position": f.pos, "facing": f.facing,
|
||||
@@ -286,6 +307,7 @@ impl Game<'_> {
|
||||
self.carts.clear();
|
||||
self.boats.clear();
|
||||
self.armor_stands.clear();
|
||||
self.cushions.clear();
|
||||
self.item_frames.clear();
|
||||
self.paintings.clear();
|
||||
}
|
||||
@@ -295,6 +317,7 @@ impl Game<'_> {
|
||||
self.carts.retain(|c| c.id != id);
|
||||
self.boats.retain(|b| b.id != id);
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
self.cushions.retain(|c| c.id != id);
|
||||
self.item_frames.retain(|f| f.id != id);
|
||||
self.paintings.retain(|p| p.id != id);
|
||||
self.bolts.retain(|b| b.id != id);
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// An `AreaEffectCloud` a server tracks: what its client tick reads.
|
||||
pub(super) struct RemoteCloud {
|
||||
id: u32,
|
||||
at: [f64; 3],
|
||||
radius: f32,
|
||||
waiting: bool,
|
||||
particle: ParticleOptions,
|
||||
}
|
||||
|
||||
/// `AreaEffectCloud.DEFAULT_PARTICLE`: a white entity effect.
|
||||
fn default_cloud_particle() -> ParticleOptions {
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
|
||||
}
|
||||
|
||||
/// The projectile a type id names, as Horizon models it.
|
||||
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
Some(match kind {
|
||||
@@ -37,6 +52,32 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `AbstractArrow.tick` on a client: water drag before the move, air drag
|
||||
/// after it, then gravity; a block it reaches holds it (`inGround`).
|
||||
fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
if p.stuck {
|
||||
return;
|
||||
}
|
||||
let water = w.in_water(p.position);
|
||||
if water {
|
||||
p.velocity = p.velocity.scale(0.6);
|
||||
}
|
||||
let to = p.position.add_vector(p.velocity);
|
||||
if let Some((at, _, _)) = w.clip(p.position, to) {
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.stuck = true;
|
||||
return;
|
||||
}
|
||||
p.position = to;
|
||||
if !water {
|
||||
p.velocity = p.velocity.scale(0.99);
|
||||
}
|
||||
p.velocity.y -= 0.05;
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `handleAddEntity` for these types; false when `kind` isn't one.
|
||||
pub(super) fn spawn_remote_projectile(
|
||||
@@ -92,6 +133,16 @@ impl Game<'_> {
|
||||
| "minecraft:experience_bottle" => {
|
||||
self.potions.add_remote_thrown(id, kind, at, velocity);
|
||||
}
|
||||
"minecraft:dragon_fireball" => self
|
||||
.dragon_fireballs
|
||||
.push(super::dragon::DragonFireball::remote(id, at, velocity)),
|
||||
"minecraft:area_effect_cloud" => self.potions.clouds.push(RemoteCloud {
|
||||
id,
|
||||
at: position,
|
||||
radius: 3.0,
|
||||
waiting: false,
|
||||
particle: default_cloud_particle(),
|
||||
}),
|
||||
"minecraft:eye_of_ender" => {
|
||||
self.end
|
||||
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
|
||||
@@ -125,6 +176,31 @@ impl Game<'_> {
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
self.remote_hook_data(id, values);
|
||||
if let Some(at) = self
|
||||
.potions
|
||||
.clouds
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| c.at)
|
||||
{
|
||||
// AreaEffectCloud.DATA_RADIUS, DATA_WAITING and DATA_PARTICLE.
|
||||
for (index, _, value) in values {
|
||||
let particle = match (index, value) {
|
||||
(10, Value::Particle(p)) => self.received_particle(p, at),
|
||||
_ => None,
|
||||
};
|
||||
let Some(c) = self.potions.clouds.iter_mut().find(|c| c.id == id) else {
|
||||
return;
|
||||
};
|
||||
match (index, value) {
|
||||
(8, Value::Float(r)) => c.radius = *r,
|
||||
(9, Value::Bool(w)) => c.waiting = *w,
|
||||
(10, _) => c.particle = particle.unwrap_or_else(default_cloud_particle),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.rockets.iter().any(|r| r.id == id) {
|
||||
for (index, _, value) in values {
|
||||
match (index, value) {
|
||||
@@ -246,29 +322,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.falling = falling;
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
if matches!(p.kind, ProjectileKind::LargeFireball { .. }) {
|
||||
// AbstractHurtingProjectile.tick advances on the client between
|
||||
// the tracker updates (ten ticks apart for a ghast's fireball).
|
||||
let at = p.position;
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
p.apply_hurting_inertia(water);
|
||||
p.position = p.position.add_vector(p.velocity);
|
||||
} else {
|
||||
p.position = step(p.id, p.position);
|
||||
// Projectiles tick on the client too and fly on between the tracker's
|
||||
// updates (10 to 20 ticks apart); a block stops them there, and only
|
||||
// the server decides what they hit.
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
{
|
||||
let w = self.mob_world_base(&[]);
|
||||
let world = super::creatures::ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
};
|
||||
for p in &mut projectiles {
|
||||
match p.kind {
|
||||
ProjectileKind::Arrow { .. } => remote_arrow_tick(p, &world),
|
||||
// ShulkerBullet.tick: only the server steers it.
|
||||
ProjectileKind::ShulkerBullet { .. } => {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
p.position = p.position.add_vector(p.velocity);
|
||||
}
|
||||
_ => {
|
||||
if !matches!(p.tick(&world).1, Hit::None) {
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.creatures.projectiles = projectiles;
|
||||
for t in &mut self.tridents {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
@@ -302,12 +383,78 @@ impl Game<'_> {
|
||||
c.time += 1;
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.potions.step_remote_thrown(&mut step);
|
||||
self.step_remote_thrown();
|
||||
self.step_remote_hooks(&mut step);
|
||||
self.end.step_remote_eyes(|id, at| {
|
||||
let at = step(id, Vec3::new(at[0], at[1], at[2]));
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
self.end.step_remote_eyes();
|
||||
self.tick_remote_clouds();
|
||||
// The dragon's fireballs fly on between the tracker's updates, with
|
||||
// their dragon_breath trail (`AbstractHurtingProjectile.getTrailParticle`).
|
||||
let trail = ParticleOptions::simple(ParticleType::DragonBreath);
|
||||
for b in &mut self.dragon_fireballs {
|
||||
let at = b.position;
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
b.remote_step(water);
|
||||
let p = b.position;
|
||||
self.particles.add(&trail, [p.x, p.y + 0.5, p.z], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
|
||||
/// particles near the middle; then about one per square block of the
|
||||
/// disc, drifting (an entity effect stays still).
|
||||
fn tick_remote_clouds(&mut self) {
|
||||
for c in &self.potions.clouds {
|
||||
let r = self.particles.random();
|
||||
if c.waiting && r.next_boolean() {
|
||||
continue;
|
||||
}
|
||||
let (count, spread) = if c.waiting {
|
||||
(2, 0.2)
|
||||
} else {
|
||||
(
|
||||
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
|
||||
c.radius,
|
||||
)
|
||||
};
|
||||
for _ in 0..count {
|
||||
let r = self.particles.random();
|
||||
let angle = r.next_float() * std::f32::consts::TAU;
|
||||
let distance = r.next_float().sqrt() * spread;
|
||||
let at = [
|
||||
c.at[0] + f64::from(angle.cos() * distance),
|
||||
c.at[1],
|
||||
c.at[2] + f64::from(angle.sin() * distance),
|
||||
];
|
||||
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
|
||||
let default = c.waiting && r.next_boolean();
|
||||
let options = if default {
|
||||
default_cloud_particle()
|
||||
} else {
|
||||
c.particle.clone()
|
||||
};
|
||||
(options, [0.0; 3])
|
||||
} else if c.waiting {
|
||||
(c.particle.clone(), [0.0; 3])
|
||||
} else {
|
||||
let v = [
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
f64::from(0.01f32),
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
];
|
||||
(c.particle.clone(), v)
|
||||
};
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
@@ -318,6 +465,8 @@ impl Game<'_> {
|
||||
self.rockets.retain(|r| r.id != id);
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.potions.clouds.retain(|c| c.id != id);
|
||||
self.dragon_fireballs.retain(|b| b.id != id);
|
||||
self.end.remove_remote_eye(id);
|
||||
self.remove_remote_hook(id);
|
||||
}
|
||||
@@ -330,7 +479,11 @@ impl Game<'_> {
|
||||
self.tridents.clear();
|
||||
self.rockets.clear();
|
||||
self.crystals.clear();
|
||||
// The dragon a server sent goes with its crystals.
|
||||
self.dragon = None;
|
||||
self.potions.clear_thrown();
|
||||
self.potions.clouds.clear();
|
||||
self.dragon_fireballs.clear();
|
||||
self.end.clear_remote_eyes();
|
||||
self.clear_remote_hooks();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ impl Game<'_> {
|
||||
let p = a.position;
|
||||
hits.push((a.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if let Some((i, d, _)) = self.cushion_hit() {
|
||||
let c = &self.cushions[i];
|
||||
let p = c.position;
|
||||
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if let Some((i, d)) = self.boat_hit() {
|
||||
let p = self.boats[i].position;
|
||||
hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z]));
|
||||
@@ -79,6 +84,8 @@ impl Game<'_> {
|
||||
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
}
|
||||
// EnderDragonPart.isPickable: the server's dragon hurts by part.
|
||||
hits.extend(self.remote_dragon_part_hit(eye, dir, reach));
|
||||
}
|
||||
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let point = at(distance);
|
||||
@@ -138,7 +145,10 @@ impl Game<'_> {
|
||||
self.position_rider();
|
||||
return true;
|
||||
}
|
||||
let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let seat = if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
|
||||
// The seat does not move; the rider sits on the cushion.
|
||||
Some(c.seat())
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
// AbstractBoat.tick on the controlling client: steer and move.
|
||||
let mut b = self.boats[i].clone();
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
|
||||
@@ -179,6 +179,7 @@ impl Game<'_> {
|
||||
if keys.is_empty() {
|
||||
self.prune_entity_memory();
|
||||
self.save_raids();
|
||||
self.save.save_forced_chunks(&self.streaming.forced)?;
|
||||
self.save_game_metadata()?;
|
||||
save.stage = Stage::Writes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Server mods in the game (`docs/mods.md`, "Server mods"): the game that
|
||||
//! simulates the world (in-frame single player, the server thread, a LAN
|
||||
//! host, the dedicated server) runs them. At the end of each tick it tells
|
||||
//! them who joined and left, then what players said, broke and placed since
|
||||
//! the last one, then ticks them; a player's command reaches them at once.
|
||||
//! Their lines go to the players they name. With no server mod on, each hook
|
||||
//! here is one check.
|
||||
use super::{Game, lan::LanHost};
|
||||
#[cfg(not(feature = "mods"))]
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// Who did something a server mod hears about, resolved at the tick's end.
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) enum Who {
|
||||
/// The window's own player, in-frame.
|
||||
Local,
|
||||
/// A connection's player (the server thread's owner is one).
|
||||
Conn(u32),
|
||||
Uuid(u128),
|
||||
}
|
||||
|
||||
/// What players did during a tick, for the server mods at its end.
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) enum ModEvent {
|
||||
Chat(Who, String),
|
||||
Block {
|
||||
who: Who,
|
||||
pos: [i32; 3],
|
||||
state: String,
|
||||
placed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_server_mods(&mut self) {}
|
||||
pub(super) fn mod_block_event(&mut self, _pos: [i32; 3], _state: BlockStateId, _placed: bool) {}
|
||||
pub(super) fn mod_player_chat(&mut self, _who: Who, _text: &str) {}
|
||||
pub(super) fn local_server_mod_command(&mut self, _line: &str) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn guest_server_mod_command(
|
||||
&mut self,
|
||||
_host: &mut LanHost,
|
||||
_conn: u32,
|
||||
_line: &str,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
mod run {
|
||||
use super::{Game, LanHost, ModEvent, Who};
|
||||
use horizon_client::{
|
||||
command::args::GameMode as Mode,
|
||||
lan::data::component_nbt,
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// `UUID.toString()`.
|
||||
fn uuid_string(u: u128) -> String {
|
||||
let h = format!("{u:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..]
|
||||
)
|
||||
}
|
||||
|
||||
fn mode(id: i32) -> GameMode {
|
||||
match id {
|
||||
1 => GameMode::Creative,
|
||||
2 => GameMode::Adventure,
|
||||
3 => GameMode::Spectator,
|
||||
_ => GameMode::Survival,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players on this game's server: the window's own player
|
||||
/// in-frame, then every connection's. `host` is the hub when it is
|
||||
/// out of `self.lan`.
|
||||
pub(in crate::game) fn server_players(&self, host: Option<&LanHost>) -> Vec<ServerPlayer> {
|
||||
let mut out = Vec::new();
|
||||
if self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
out.push(ServerPlayer {
|
||||
id: self.player_uuid_string(),
|
||||
name: crate::game::chat::PLAYER_NAME.to_owned(),
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
z: p.position.z,
|
||||
dimension: self.dim.id().to_owned(),
|
||||
game_mode: match self.mode {
|
||||
Mode::Survival => GameMode::Survival,
|
||||
Mode::Creative => GameMode::Creative,
|
||||
Mode::Adventure => GameMode::Adventure,
|
||||
Mode::Spectator => GameMode::Spectator,
|
||||
},
|
||||
health: p.health.health(),
|
||||
});
|
||||
}
|
||||
let guests = host
|
||||
.or(self.lan.as_ref())
|
||||
.into_iter()
|
||||
.flat_map(|h| &h.hub.guests);
|
||||
out.extend(guests.filter(|g| g.placed).map(|g| ServerPlayer {
|
||||
id: uuid_string(g.uuid),
|
||||
name: g.name.clone(),
|
||||
x: g.position[0],
|
||||
y: g.position[1],
|
||||
z: g.position[2],
|
||||
dimension: g.dimension.clone(),
|
||||
game_mode: mode(g.game_mode),
|
||||
health: g.health,
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether what players do now reaches server mods: one is running
|
||||
/// and this game simulates the world.
|
||||
fn hears_players(&self) -> bool {
|
||||
self.mods.server && self.remote.is_none()
|
||||
}
|
||||
|
||||
/// The tick's end: joins and leaves, the tick's events, the tick hook.
|
||||
pub(in crate::game) fn tick_server_mods(&mut self) {
|
||||
if !self.options.execution_role.is_client() {
|
||||
// A server has no frames to check its dev mods in.
|
||||
self.reload_dev_mods();
|
||||
}
|
||||
if !self.mods.server {
|
||||
return;
|
||||
}
|
||||
let players = self.server_players(None);
|
||||
let snapshot = self.mod_snapshot_with(players.clone());
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
let before = std::mem::take(&mut self.mods.players);
|
||||
for p in before
|
||||
.iter()
|
||||
.filter(|p| !players.iter().any(|q| q.id == p.id))
|
||||
{
|
||||
self.mods.mods.player_left(p);
|
||||
}
|
||||
for p in players
|
||||
.iter()
|
||||
.filter(|p| !before.iter().any(|q| q.id == p.id))
|
||||
{
|
||||
self.mods.mods.player_joined(p);
|
||||
}
|
||||
for event in std::mem::take(&mut self.mods.events) {
|
||||
let who = |w: &Who| {
|
||||
let id = match w {
|
||||
Who::Local => self.player_uuid_string(),
|
||||
Who::Uuid(u) => uuid_string(*u),
|
||||
Who::Conn(c) => {
|
||||
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
|
||||
let g = guests.into_iter().find(|g| g.conn == *c);
|
||||
g.map_or_else(String::new, |g| uuid_string(g.uuid))
|
||||
}
|
||||
};
|
||||
players.iter().chain(&before).find(|p| p.id == id).cloned()
|
||||
};
|
||||
match event {
|
||||
ModEvent::Chat(w, text) => {
|
||||
if let Some(p) = who(&w) {
|
||||
self.mods.mods.player_chat(&p, &text);
|
||||
}
|
||||
}
|
||||
ModEvent::Block {
|
||||
who: w,
|
||||
pos,
|
||||
state,
|
||||
placed,
|
||||
} => {
|
||||
let Some(p) = who(&w) else { continue };
|
||||
let pos = BlockPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
};
|
||||
if placed {
|
||||
self.mods.mods.block_placed(&p, pos, &state);
|
||||
} else {
|
||||
self.mods.mods.block_broken(&p, pos, &state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mods.mods.server_tick(self.ticks);
|
||||
self.mods.players = players;
|
||||
self.show_mod_output();
|
||||
}
|
||||
|
||||
/// A player broke (or placed) `state` at `pos`: the player acting now.
|
||||
pub(in crate::game) fn mod_block_event(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
placed: bool,
|
||||
) {
|
||||
if !self.hears_players() {
|
||||
return;
|
||||
}
|
||||
let who = self.lan_acting.map_or(Who::Local, Who::Conn);
|
||||
let state = self.state_string(state);
|
||||
self.mods.events.push(ModEvent::Block {
|
||||
who,
|
||||
pos,
|
||||
state,
|
||||
placed,
|
||||
});
|
||||
}
|
||||
|
||||
/// A chat message a player sent.
|
||||
pub(in crate::game) fn mod_player_chat(&mut self, who: Who, text: &str) {
|
||||
if self.hears_players() {
|
||||
self.mods.events.push(ModEvent::Chat(who, text.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// `/line` typed by the window's own player in-frame: true when a
|
||||
/// server mod owns it.
|
||||
pub(in crate::game) fn local_server_mod_command(&mut self, line: &str) -> bool {
|
||||
if !self.hears_players() {
|
||||
return false;
|
||||
}
|
||||
let players = self.server_players(None);
|
||||
let Some(me) = players.first().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let snapshot = self.mod_snapshot_with(players);
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
let owned = self.mods.mods.server_command(&me, line);
|
||||
self.show_mod_output();
|
||||
owned
|
||||
}
|
||||
|
||||
/// `/line` typed by a connection's player: true when a server mod
|
||||
/// owns it, whatever the player's permission level.
|
||||
pub(in crate::game) fn guest_server_mod_command(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
line: &str,
|
||||
) -> bool {
|
||||
if !self.hears_players() {
|
||||
return false;
|
||||
}
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return false;
|
||||
};
|
||||
let id = uuid_string(g.uuid);
|
||||
let players = self.server_players(Some(host));
|
||||
let Some(me) = players.iter().find(|p| p.id == id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let snapshot = self.mod_snapshot_with(players);
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
if !self.mods.mods.server_command(&me, line) {
|
||||
return false;
|
||||
}
|
||||
self.route_mod_output(Some(host));
|
||||
true
|
||||
}
|
||||
|
||||
/// Sends a server mod's line to the player with this id: the
|
||||
/// window's own chat for the local player, a system message for a
|
||||
/// connection's. `host` is the hub when it is out of `self.lan`.
|
||||
pub(in crate::game) fn tell_server_player(
|
||||
&mut self,
|
||||
host: Option<&mut LanHost>,
|
||||
id: &str,
|
||||
line: Text,
|
||||
) {
|
||||
let local = self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& self.player.is_some();
|
||||
if local && self.player_uuid_string() == id {
|
||||
self.add_mod_chat_line(line);
|
||||
return;
|
||||
}
|
||||
let Some(host) = host.or(self.lan.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let to = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && uuid_string(g.uuid) == id);
|
||||
if let Some(conn) = to.map(|g| g.conn) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a server mod's output: `Tell` and `Reply` to their player,
|
||||
/// `Broadcast` to everyone and the log.
|
||||
pub(in crate::game) fn route_server_output(
|
||||
&mut self,
|
||||
mut host: Option<&mut LanHost>,
|
||||
output: Output,
|
||||
) {
|
||||
let named = |name: &str, text: String| {
|
||||
let mut line = Text::literal(format!("[{name}] ")).with_color(text::GRAY);
|
||||
line.push(Text::literal(text));
|
||||
line
|
||||
};
|
||||
match output {
|
||||
Output::Tell { name, player, text } => {
|
||||
self.tell_server_player(host, &player, named(&name, text));
|
||||
}
|
||||
Output::Reply {
|
||||
player,
|
||||
text,
|
||||
error,
|
||||
} => {
|
||||
let line = Text::literal(text);
|
||||
let line = if error {
|
||||
line.with_color(text::RED)
|
||||
} else {
|
||||
line
|
||||
};
|
||||
self.tell_server_player(host, &player, line);
|
||||
}
|
||||
Output::Broadcast { name, text } => {
|
||||
let plain = format!("[{name}] {text}");
|
||||
if self.options.dedicated.is_some() {
|
||||
super::super::dedicated::log("INFO", &plain);
|
||||
} else {
|
||||
log::info!("{plain}");
|
||||
}
|
||||
for p in self.server_players(host.as_deref()) {
|
||||
let line = named(&name, text.clone());
|
||||
self.tell_server_player(host.as_deref_mut(), &p.id, line);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use super::respawn_anchor::RespawnConfig;
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A trip under way: the player is in the destination's level, whose
|
||||
/// chunks are loading around `target`.
|
||||
@@ -103,8 +104,35 @@ impl Game<'_> {
|
||||
if let Some(pos) = self.nether_portal_inside(at) {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
}
|
||||
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
|
||||
// poem rolls, then PERFORM_RESPAWN takes it to its respawn point.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.won_game {
|
||||
if std::mem::take(&mut play.respawn_requested) {
|
||||
play.won_game = false;
|
||||
self.start_server_trip(host, conn, Dim::Overworld, None);
|
||||
self.portals.player.cooldown = 10;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
if end_portal {
|
||||
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
|
||||
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
|
||||
g.seen_credits = true;
|
||||
}
|
||||
host.play.entry(conn).or_default().won_game = true;
|
||||
// ClientboundGameEventPacket.WIN_GAME.
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::GameEvent(play::GameEvent {
|
||||
event: 4,
|
||||
param: 0.0,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
|
||||
@@ -6,9 +6,9 @@ use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
signs::wood_type,
|
||||
ui::EditBox,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -18,7 +18,16 @@ pub(super) struct SignEditor {
|
||||
pub pos: (i32, i32, i32),
|
||||
pub front: bool,
|
||||
pub line: usize,
|
||||
pub opened: Instant,
|
||||
/// The line being edited (`AbstractSignEditScreen`'s text field).
|
||||
edit: EditBox,
|
||||
}
|
||||
|
||||
fn sign_box() -> EditBox {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 90.0, 9.0);
|
||||
edit.bordered = false;
|
||||
edit.max_length = 384;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -28,8 +37,9 @@ impl Game<'_> {
|
||||
pos,
|
||||
front,
|
||||
line: 0,
|
||||
opened: Instant::now(),
|
||||
edit: sign_box(),
|
||||
});
|
||||
self.sign_load();
|
||||
self.keys.clear();
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
@@ -140,97 +150,154 @@ impl Game<'_> {
|
||||
/// A key and the text it typed on the sign being edited (the keyboard,
|
||||
/// or a controller's on-screen keyboard).
|
||||
pub(super) fn sign_input(&mut self, key: Option<KeyCode>, typed: Option<&str>) {
|
||||
let Some(edit) = self.sign_edit.as_mut() else {
|
||||
if self.sign_edit.is_none() {
|
||||
return;
|
||||
};
|
||||
let front = edit.front;
|
||||
let id = sign_line_id(front, edit.line);
|
||||
}
|
||||
match key {
|
||||
Some(KeyCode::Escape) => self.close_sign_editor(),
|
||||
Some(KeyCode::ArrowUp) => edit.line = (edit.line + 3) & 3,
|
||||
Some(KeyCode::ArrowUp) => self.sign_move_line(3),
|
||||
Some(KeyCode::ArrowDown | KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
edit.line = (edit.line + 1) & 3
|
||||
self.sign_move_line(1)
|
||||
}
|
||||
Some(KeyCode::Backspace) => {
|
||||
let (line, key) = (edit.line, edit.pos);
|
||||
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
|
||||
let text = if front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
let value = &mut text.lines[line];
|
||||
if !self.text_selection.take(id, value) {
|
||||
value.pop();
|
||||
}
|
||||
}
|
||||
Some(
|
||||
key @ (KeyCode::ArrowLeft
|
||||
| KeyCode::ArrowRight
|
||||
| KeyCode::Home
|
||||
| KeyCode::End
|
||||
| KeyCode::Backspace
|
||||
| KeyCode::Delete),
|
||||
) => {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
self.sign_change(|edit| {
|
||||
edit.key(key, ctrl, shift);
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let Some(typed) = typed else {
|
||||
return;
|
||||
};
|
||||
let line = edit.line;
|
||||
let atlas = &self.gui.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s);
|
||||
let Some(key) = self.sign_edit.as_ref().map(|e| e.pos) else {
|
||||
return;
|
||||
};
|
||||
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
|
||||
let max = sign.max_line_width();
|
||||
let text = if front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
self.text_selection.take(id, &mut text.lines[line]);
|
||||
for ch in typed.chars().filter(|c| !c.is_control()) {
|
||||
let mut next = text.lines[line].clone();
|
||||
next.push(ch);
|
||||
// TextFieldHelper filter: font.width(s) <= maxTextLineWidth.
|
||||
if width(&next) <= max {
|
||||
text.lines[line] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sign_change(|edit| edit.insert_text(typed));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line: a paste
|
||||
/// (formatting and carriage returns removed) is taken whole or not at
|
||||
/// all, as the line must stay within the sign's width.
|
||||
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
/// Up or down a line: the box writes the line it leaves and loads the next.
|
||||
fn sign_move_line(&mut self, by: usize) {
|
||||
self.sign_write();
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.line = (edit.line + by) & 3;
|
||||
}
|
||||
self.sign_load();
|
||||
}
|
||||
/// The edited line's text.
|
||||
fn sign_current(&self) -> Option<String> {
|
||||
let edit = self.sign_edit.as_ref()?;
|
||||
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
|
||||
return None;
|
||||
};
|
||||
let side = if edit.front { &sign.front } else { &sign.back };
|
||||
Some(side.lines[edit.line].clone())
|
||||
}
|
||||
/// `EditBox.setValue` from the line now being edited.
|
||||
fn sign_load(&mut self) {
|
||||
let Some(text) = self.sign_current() else {
|
||||
return;
|
||||
};
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.edit.set_value(&text);
|
||||
}
|
||||
}
|
||||
/// The box's text onto the sign, when it still fits the line.
|
||||
fn sign_write(&mut self) {
|
||||
let Some(edit) = self.sign_edit.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (key, front, line) = (edit.pos, edit.front, edit.line);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
|
||||
let (pos, front, line, value) = (edit.pos, edit.front, edit.line, edit.edit.value.clone());
|
||||
if !self.sign_fits(&value) {
|
||||
return;
|
||||
}
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return;
|
||||
};
|
||||
let max = sign.max_line_width();
|
||||
let text = if front {
|
||||
let side = if front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
self.text_selection.apply(
|
||||
sign_line_id(front, line),
|
||||
&mut text.lines[line],
|
||||
shortcut,
|
||||
&mut self.clipboard,
|
||||
|value, pasted| {
|
||||
let next = format!(
|
||||
"{value}{}",
|
||||
horizon_client::clipboard::helper_clipboard_contents(pasted)
|
||||
);
|
||||
HudBatch::text_width(atlas, &next) <= max && {
|
||||
*value = next;
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
side.lines[line] = value;
|
||||
}
|
||||
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20).
|
||||
/// TextFieldHelper: `font.width(s) <= maxTextLineWidth`.
|
||||
fn sign_fits(&self, value: &str) -> bool {
|
||||
let Some(edit) = self.sign_edit.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) else {
|
||||
return false;
|
||||
};
|
||||
HudBatch::text_width(&self.gui.atlas, value) <= sign.max_line_width()
|
||||
}
|
||||
/// Edit the line, then keep the result only when it still fits.
|
||||
fn sign_change(&mut self, f: impl FnOnce(&mut EditBox)) {
|
||||
let before = self
|
||||
.sign_edit
|
||||
.as_ref()
|
||||
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
f(&mut edit.edit);
|
||||
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
|
||||
if clean != edit.edit.value {
|
||||
let n = horizon_client::ui::char_len(&clean);
|
||||
edit.edit.value = clean;
|
||||
edit.edit.cursor = edit.edit.cursor.min(n);
|
||||
edit.edit.highlight = edit.edit.highlight.min(n);
|
||||
}
|
||||
}
|
||||
self.sign_keep(before);
|
||||
}
|
||||
/// Write the box when the line still fits; otherwise put the old text back.
|
||||
fn sign_keep(&mut self, before: Option<(String, usize, usize)>) {
|
||||
let value = self
|
||||
.sign_edit
|
||||
.as_ref()
|
||||
.map(|e| e.edit.value.clone())
|
||||
.unwrap_or_default();
|
||||
if self.sign_fits(&value) {
|
||||
self.sign_write();
|
||||
} else if let (Some(edit), Some((value, cursor, highlight))) =
|
||||
(self.sign_edit.as_mut(), before)
|
||||
{
|
||||
edit.edit.value = value;
|
||||
edit.edit.cursor = cursor;
|
||||
edit.edit.highlight = highlight;
|
||||
}
|
||||
}
|
||||
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line. Paste
|
||||
/// reads `getClipboardContents` before `insertText`. A paste that would
|
||||
/// pass the sign's width is refused whole.
|
||||
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
if shortcut == horizon_client::clipboard::EditShortcut::Paste {
|
||||
let raw = horizon_client::clipboard::Clipboard::get(&mut self.clipboard);
|
||||
let text = horizon_client::clipboard::helper_clipboard_contents(&raw);
|
||||
self.sign_change(|edit| edit.insert_text(&text));
|
||||
return;
|
||||
}
|
||||
let before = self
|
||||
.sign_edit
|
||||
.as_ref()
|
||||
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.edit.shortcut(shortcut, &mut self.clipboard);
|
||||
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
|
||||
if clean != edit.edit.value {
|
||||
let n = horizon_client::ui::char_len(&clean);
|
||||
edit.edit.value = clean;
|
||||
edit.edit.cursor = edit.edit.cursor.min(n);
|
||||
edit.edit.highlight = edit.edit.highlight.min(n);
|
||||
}
|
||||
}
|
||||
self.sign_keep(before);
|
||||
}
|
||||
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20),
|
||||
/// or a click on a line (`EditBox.onClick`).
|
||||
pub(super) fn sign_edit_click(&mut self, size: [u32; 2]) {
|
||||
let b = HudBatch::new(size);
|
||||
let (x, y) = (b.width / 2.0 - 100.0, b.height / 4.0 + 144.0);
|
||||
@@ -238,7 +305,56 @@ impl Game<'_> {
|
||||
if mx >= x && mx < x + 200.0 && my >= y && my < y + 20.0 {
|
||||
self.audio.play_button_click();
|
||||
self.close_sign_editor();
|
||||
return;
|
||||
}
|
||||
let Some((cx, top, line_height, max_width, lines)) = self.sign_lines(b.width) else {
|
||||
return;
|
||||
};
|
||||
let shift = self.has_shift_down();
|
||||
for i in 0..lines.len() {
|
||||
let ly = top + i as f32 * line_height;
|
||||
let left = cx - max_width / 2.0;
|
||||
if mx < left || mx >= left + max_width || my < ly || my >= ly + line_height {
|
||||
continue;
|
||||
}
|
||||
let current = self.sign_edit.as_ref().map(|e| e.line);
|
||||
if current != Some(i) {
|
||||
self.sign_move_line((i + 4 - current.unwrap_or(0)) & 3);
|
||||
}
|
||||
let value = self
|
||||
.sign_edit
|
||||
.as_ref()
|
||||
.map(|e| e.edit.value.clone())
|
||||
.unwrap_or_default();
|
||||
let lw = HudBatch::text_width(&self.gui.atlas, &value);
|
||||
let text_x = cx - (lw as i32 / 2) as f32;
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.edit.x = text_x;
|
||||
edit.edit.y = ly;
|
||||
edit.edit.width = max_width;
|
||||
edit.edit.click(&self.gui.atlas, mx, shift, false);
|
||||
}
|
||||
self.sign_write();
|
||||
return;
|
||||
}
|
||||
}
|
||||
/// Centre x, the first line's y, the line height, the width limit and the four lines.
|
||||
fn sign_lines(&self, width: f32) -> Option<(f32, f32, f32, f32, Vec<String>)> {
|
||||
let edit = self.sign_edit.as_ref()?;
|
||||
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
|
||||
return None;
|
||||
};
|
||||
let text = if edit.front { &sign.front } else { &sign.back };
|
||||
let line_height = sign.line_height();
|
||||
let y = if sign.hanging { 125.0 } else { 90.0 };
|
||||
let top = y - (4.0 * line_height / 2.0).floor();
|
||||
Some((
|
||||
width / 2.0,
|
||||
top,
|
||||
line_height,
|
||||
sign.max_line_width(),
|
||||
text.lines.to_vec(),
|
||||
))
|
||||
}
|
||||
pub(super) fn draw_sign_editor(&self, batch: &mut HudBatch) {
|
||||
let Some(edit) = &self.sign_edit else { return };
|
||||
@@ -304,23 +420,48 @@ impl Game<'_> {
|
||||
let color = rgba((color >> 16) as u8, (color >> 8) as u8, color as u8, 255);
|
||||
let line_height = sign.line_height();
|
||||
let midpoint = (4.0 * line_height / 2.0).floor();
|
||||
let blink = (edit.opened.elapsed().as_millis() / 300) % 2 == 0;
|
||||
for (i, line) in text.lines.iter().enumerate() {
|
||||
let lw = HudBatch::text_width(atlas, line);
|
||||
let shown = if i == edit.line {
|
||||
edit.edit.value.as_str()
|
||||
} else {
|
||||
line.as_str()
|
||||
};
|
||||
let lw = HudBatch::text_width(atlas, shown);
|
||||
let x = cx - (lw as i32 / 2) as f32;
|
||||
let ly = y + i as f32 * line_height - midpoint;
|
||||
batch.text(atlas, line, x, ly, color, false);
|
||||
if i == edit.line
|
||||
&& self
|
||||
.text_selection
|
||||
.covers(sign_line_id(edit.front, i), line)
|
||||
{
|
||||
// AbstractSignEditScreen: the selection from its start to its end.
|
||||
batch.text_highlight(atlas, (x, ly, lw, line_height), true);
|
||||
batch.text(atlas, shown, x, ly, color, false);
|
||||
if i != edit.line {
|
||||
continue;
|
||||
}
|
||||
if i == edit.line && blink {
|
||||
// TextCursorUtils.extractAppendCursor: an underscore after the text.
|
||||
batch.text(atlas, "_", x + lw, ly, color, false);
|
||||
let value = &edit.edit.value;
|
||||
if edit.edit.cursor != edit.edit.highlight {
|
||||
let (a, b) = (
|
||||
edit.edit.cursor.min(edit.edit.highlight),
|
||||
edit.edit.cursor.max(edit.edit.highlight),
|
||||
);
|
||||
let pre: String = value.chars().take(a).collect();
|
||||
let sel: String = value.chars().skip(a).take(b - a).collect();
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(
|
||||
x + HudBatch::text_width(atlas, &pre),
|
||||
ly,
|
||||
HudBatch::text_width(atlas, &sel),
|
||||
line_height,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if edit.edit.cursor_visible() {
|
||||
let pre: String = value.chars().take(edit.edit.cursor).collect();
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + HudBatch::text_width(atlas, &pre),
|
||||
ly,
|
||||
1.0,
|
||||
line_height,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
let (bx, by) = (w / 2.0 - 100.0, h / 4.0 + 144.0);
|
||||
@@ -344,8 +485,3 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign line being edited, for Select All.
|
||||
fn sign_line_id(front: bool, line: usize) -> horizon_client::clipboard::FieldId {
|
||||
(if front { "sign.front" } else { "sign.back" }, line)
|
||||
}
|
||||
@@ -398,8 +398,12 @@ impl GameLevel<'_> {
|
||||
}
|
||||
}
|
||||
PreRemove::Campfire => {
|
||||
for _ in 0..4 {
|
||||
self.drop_item_stack(pos, None);
|
||||
let items = match &entity {
|
||||
Some(BlockEntity::Campfire(c)) => c.items.clone(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
for stack in items {
|
||||
self.drop_item_stack(pos, stack);
|
||||
}
|
||||
}
|
||||
PreRemove::Jukebox => {
|
||||
|
||||
@@ -135,8 +135,10 @@ pub(super) struct Station {
|
||||
pub(super) offers: EnchantOffers,
|
||||
bookcases: i32,
|
||||
pub(super) anvil: AnvilResult,
|
||||
/// Anvil rename box text.
|
||||
/// Anvil rename box text (`AnvilMenu.itemName`).
|
||||
pub(super) name: String,
|
||||
/// The box itself (`AnvilScreen`'s `EditBox` of 50, unbordered).
|
||||
pub(super) rename: horizon_client::ui::EditBox,
|
||||
/// Stonecutter: the menu's recipe list (horizon_client::menu), its
|
||||
/// `selectedRecipeIndex` and the screen's first shown index.
|
||||
pub(super) stone: horizon_client::menu::Stonecutter,
|
||||
@@ -146,8 +148,6 @@ pub(super) struct Station {
|
||||
anvil_last: Option<ItemStack>,
|
||||
/// Anvil on a server: the name last sent (the client menu's `itemName`).
|
||||
pub(super) anvil_sent: Option<String>,
|
||||
/// When the screen opened: the rename box has the focus from then.
|
||||
opened: std::time::Instant,
|
||||
pub(super) last_sound: u64,
|
||||
/// Villager trading state.
|
||||
pub(super) merchant: Option<Box<super::trading::MerchantState>>,
|
||||
@@ -169,12 +169,12 @@ impl Station {
|
||||
bookcases: 0,
|
||||
anvil: AnvilResult::default(),
|
||||
name: String::new(),
|
||||
rename: anvil_box(),
|
||||
stone: Default::default(),
|
||||
selected: None,
|
||||
start: 0,
|
||||
anvil_last: None,
|
||||
anvil_sent: None,
|
||||
opened: std::time::Instant::now(),
|
||||
last_sound: 0,
|
||||
merchant: None,
|
||||
beacon: [None; 2],
|
||||
@@ -868,6 +868,9 @@ impl Game<'_> {
|
||||
if kind == Kind::Stonecutter && self.stonecutter_click(local) {
|
||||
return true;
|
||||
}
|
||||
if kind == Kind::Anvil && !right && self.anvil_name_click(ox, oy) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return true;
|
||||
@@ -894,6 +897,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
let name = s.name.clone();
|
||||
s.rename.set_value(&name);
|
||||
}
|
||||
self.station_changed();
|
||||
}
|
||||
@@ -960,21 +965,29 @@ impl Game<'_> {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let cleared = self.text_selection.take(ANVIL_NAME, &mut s.name);
|
||||
if backspace {
|
||||
if !cleared {
|
||||
s.name.pop();
|
||||
}
|
||||
s.rename.delete_chars(-1);
|
||||
} else if let Some(t) = text {
|
||||
for c in t.chars().filter(|c| !c.is_control()) {
|
||||
if s.name.chars().count() < ANVIL_NAME_CHARS {
|
||||
s.name.push(c);
|
||||
}
|
||||
}
|
||||
s.rename.insert_text(t);
|
||||
}
|
||||
s.name.clone_from(&s.rename.value);
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
/// `AnvilScreen.keyPressed`: the rename box handles the key.
|
||||
pub(super) fn station_key(&mut self, key: winit::keyboard::KeyCode) -> bool {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if !s.rename.key(key, ctrl, shift) {
|
||||
return false;
|
||||
}
|
||||
s.name.clone_from(&s.rename.value);
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
true
|
||||
}
|
||||
/// Ctrl+A/C/V/X in the anvil's rename box (an `EditBox` of 50) or the
|
||||
/// recipe book's search box.
|
||||
pub(super) fn station_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
@@ -985,18 +998,52 @@ impl Game<'_> {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
ANVIL_NAME,
|
||||
&mut s.name,
|
||||
shortcut,
|
||||
ANVIL_NAME_CHARS,
|
||||
) {
|
||||
if s.rename.shortcut(shortcut, &mut self.clipboard) {
|
||||
s.name.clone_from(&s.rename.value);
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
}
|
||||
/// A click in the anvil's name box (`EditBox.onClick`).
|
||||
fn anvil_name_click(&mut self, ox: f32, oy: f32) -> bool {
|
||||
let open = self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == Kind::Anvil && s.inputs[0].is_some());
|
||||
if !open {
|
||||
return false;
|
||||
}
|
||||
let (x, y, w, h) = (ox + 62.0, oy + 24.0, 103.0, 12.0);
|
||||
let (mx, my) = self.cursor;
|
||||
if mx < x || my < y || mx >= x + w || my >= y + h {
|
||||
return false;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
s.rename.x = x;
|
||||
s.rename.y = y;
|
||||
s.rename.width = w;
|
||||
s.rename.click(&self.gui.atlas, mx, shift, false);
|
||||
true
|
||||
}
|
||||
/// AnvilScreen's name box, drawn after the station picture.
|
||||
fn draw_anvil_name(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let edit = &mut s.rename;
|
||||
edit.x = ox + 62.0;
|
||||
edit.y = oy + 24.0;
|
||||
edit.width = 103.0;
|
||||
edit.height = 12.0;
|
||||
edit.bordered = false;
|
||||
edit.focused = true;
|
||||
edit.text_color = u32::MAX;
|
||||
edit.max_length = ANVIL_NAME_CHARS;
|
||||
edit.draw(batch, &self.gui.atlas, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drawing
|
||||
|
||||
@@ -1094,6 +1141,10 @@ impl Game<'_> {
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
let mut anvil_name = false;
|
||||
// Keep item tooltips as stacks until drawing: text alone loses the
|
||||
// bundle contents component (including in the player's inventory).
|
||||
let mut tooltip_stack: Option<ItemStack> = None;
|
||||
{
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(
|
||||
@@ -1428,18 +1479,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
if s.inputs[0].is_some() {
|
||||
// AnvilScreen's name box: white, 103 wide, no inverted highlight.
|
||||
horizon_client::ui::append_field(
|
||||
batch,
|
||||
atlas,
|
||||
&s.name,
|
||||
(ox + 62.0, oy + 24.0, 103.0),
|
||||
(u32::MAX, true),
|
||||
ANVIL_NAME_CHARS,
|
||||
self.text_selection.covers(ANVIL_NAME, &s.name),
|
||||
false,
|
||||
s.opened.elapsed().as_millis(),
|
||||
);
|
||||
// Drawn once this borrow of the station ends.
|
||||
anvil_name = true;
|
||||
}
|
||||
let cost = s.anvil.cost;
|
||||
if cost > 0 {
|
||||
@@ -1579,13 +1620,16 @@ impl Game<'_> {
|
||||
);
|
||||
self.draw_item(batch, &result, ox + x, oy + y);
|
||||
if hover {
|
||||
tooltip = Some(self.stack_tooltip(&result));
|
||||
tooltip_stack = Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if anvil_name {
|
||||
self.draw_anvil_name(batch, ox, oy);
|
||||
}
|
||||
let drag = self.drag_view();
|
||||
for i in self.station_slots().unwrap_or_default() {
|
||||
let Some((sx, sy)) = self.slot_position_any(i) else {
|
||||
@@ -1606,13 +1650,16 @@ impl Game<'_> {
|
||||
);
|
||||
if self.carried.is_none()
|
||||
&& tooltip.is_none()
|
||||
&& tooltip_stack.is_none()
|
||||
&& let Some(stack) = self.slot_mut(slot).clone()
|
||||
{
|
||||
tooltip = Some(self.stack_tooltip(&stack));
|
||||
tooltip_stack = Some(stack);
|
||||
}
|
||||
}
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = tooltip_stack {
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(lines) = tooltip {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
@@ -1656,6 +1703,8 @@ impl Game<'_> {
|
||||
};
|
||||
let name = if let Some(n) = stack.custom_name() {
|
||||
n.to_owned()
|
||||
} else if let Some(n) = stack.item_name() {
|
||||
n
|
||||
} else if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
|
||||
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
|
||||
@@ -1682,7 +1731,9 @@ impl Game<'_> {
|
||||
// ItemContainerContents.addToTooltip.
|
||||
let hover_name = |s: &ItemStack| {
|
||||
s.custom_name()
|
||||
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| s.item_name())
|
||||
.unwrap_or_else(|| self.pack.names.display_item(&s.item))
|
||||
};
|
||||
if display.shows("minecraft:container") {
|
||||
for text in
|
||||
@@ -1907,32 +1958,65 @@ impl Game<'_> {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lines: Vec<_> = lines
|
||||
let lines = Self::resolved(lines);
|
||||
let atlas = &self.gui.atlas;
|
||||
let (x, y, w, h) =
|
||||
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), 0.0);
|
||||
Self::tooltip_box(batch, atlas, x, y, w, h);
|
||||
Self::tooltip_text(batch, atlas, &lines, x, y);
|
||||
}
|
||||
/// A tooltip's lines with their legacy codes resolved and their colours.
|
||||
fn resolved<T: Clone + Into<horizon_client::text::Text>>(
|
||||
lines: &[(T, u32)],
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|(text, color)| {
|
||||
let text: horizon_client::text::Text = text.clone().into();
|
||||
(text.resolve_legacy(), *color)
|
||||
})
|
||||
.collect();
|
||||
let atlas = &self.gui.atlas;
|
||||
.collect()
|
||||
}
|
||||
/// The tooltip's text's top-left corner and the box's size: beside the
|
||||
/// cursor, kept on screen, `extra` pixels taller than the text.
|
||||
fn tooltip_geometry(
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
lines: &[(horizon_client::text::Text, u32)],
|
||||
(x, y): (f32, f32),
|
||||
(width, height): (f32, f32),
|
||||
extra: f32,
|
||||
) -> (f32, f32, f32, f32) {
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|(t, _)| atlas.font.width_text(t))
|
||||
.fold(0.0, f32::max);
|
||||
let h = if lines.len() == 1 {
|
||||
.fold(0.0, f32::max)
|
||||
.max(if extra > 0.0 { 96.0 } else { 0.0 });
|
||||
let text_h = if lines.len() == 1 {
|
||||
8.0
|
||||
} else {
|
||||
8.0 + 2.0 + (lines.len() - 1) as f32 * 10.0
|
||||
};
|
||||
let h = text_h + extra;
|
||||
let mut x = x + 12.0;
|
||||
let mut y = y - 12.0;
|
||||
if x + w > batch.width - 4.0 {
|
||||
if x + w > width - 4.0 {
|
||||
x = (x - 28.0 - w).max(4.0);
|
||||
}
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
if y + h + 3.0 > height {
|
||||
y = height - h - 3.0;
|
||||
}
|
||||
y = y.max(4.0);
|
||||
(x, y, w, h)
|
||||
}
|
||||
/// The box and its frame sprites.
|
||||
fn tooltip_box(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) {
|
||||
batch.fill(atlas, x - 3.0, y - 4.0, w + 6.0, 1.0, rgba(16, 0, 16, 240));
|
||||
batch.fill(
|
||||
atlas,
|
||||
@@ -1968,12 +2052,137 @@ impl Game<'_> {
|
||||
h + 4.0,
|
||||
rgba(60, 0, 190, 80),
|
||||
);
|
||||
}
|
||||
fn tooltip_text(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
lines: &[(horizon_client::text::Text, u32)],
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let mut ty = y;
|
||||
for (i, (text, color)) in lines.iter().enumerate() {
|
||||
batch.text_styled(atlas, text, x, ty, *color, true);
|
||||
ty += if i == 0 { 12.0 } else { 10.0 };
|
||||
}
|
||||
}
|
||||
/// A stack's tooltip: its lines, and under them a bundle's contents
|
||||
/// (`ClientBundleTooltip`: the 4 wide grid, the `+N` of what it leaves
|
||||
/// out, and the weight bar).
|
||||
pub(super) fn draw_stack_tooltip(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let lines = Self::resolved(&self.stack_tooltip(stack));
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The component's height does not depend on where it is drawn, so a
|
||||
// probe at the origin gives the box its extra height.
|
||||
let probe = horizon_client::bundle::tooltip_layout(self.res.items(), stack, 0.0, 0.0, 96.0);
|
||||
let extra = probe.as_ref().map_or(0.0, |l| l.height + 2.0);
|
||||
let atlas = &self.gui.atlas;
|
||||
let (x, y, w, h) =
|
||||
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), extra);
|
||||
Self::tooltip_box(batch, atlas, x, y, w, h);
|
||||
let text_h = h - extra;
|
||||
Self::tooltip_text(batch, atlas, &lines, x, y);
|
||||
let Some(layout) =
|
||||
horizon_client::bundle::tooltip_layout(self.res.items(), stack, x, y + text_h + 2.0, w)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.draw_bundle_tooltip(batch, &layout);
|
||||
}
|
||||
/// `ClientBundleTooltip.extractImage`: the slots, the count past them and
|
||||
/// the progress bar.
|
||||
fn draw_bundle_tooltip(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
layout: &horizon_client::bundle::TooltipLayout,
|
||||
) {
|
||||
for (at, stack) in &layout.slots {
|
||||
// Each draw takes the atlas on its own: the item's needs `&mut self`.
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
"gui/sprites/container/bundle/slot_background",
|
||||
at[0],
|
||||
at[1],
|
||||
24.0,
|
||||
24.0,
|
||||
u32::MAX,
|
||||
);
|
||||
let stack = stack.clone();
|
||||
let [x, y] = *at;
|
||||
self.draw_item(batch, &stack, x + 4.0, y + 4.0);
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if let Some((at, hidden)) = layout.overflow {
|
||||
let label = horizon_client::text::Text::literal(format!("+{hidden}"));
|
||||
let w = atlas.font.width_text(&label);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&label,
|
||||
at[0] + 12.0 - (w / 2.0).floor(),
|
||||
at[1] + 10.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if let Some((at, description)) = layout
|
||||
.empty_description
|
||||
.zip(Some("item.minecraft.bundle.empty.description"))
|
||||
{
|
||||
let text = horizon_client::text::Text::translate(description, &[]);
|
||||
let mut ty = at[1];
|
||||
for line in atlas.font.split_lines(&text, layout.width) {
|
||||
batch.text_styled(atlas, &line.0, at[0], ty, 0xFFAA_AAAA, false);
|
||||
ty += 9.0;
|
||||
}
|
||||
}
|
||||
let [bx, by] = layout.bar;
|
||||
let fill_sprite = if layout.full {
|
||||
"gui/sprites/container/bundle/bundle_progressbar_full"
|
||||
} else {
|
||||
"gui/sprites/container/bundle/bundle_progressbar_fill"
|
||||
};
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
fill_sprite,
|
||||
bx + 1.0,
|
||||
by,
|
||||
layout.fill,
|
||||
13.0,
|
||||
u32::MAX,
|
||||
);
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/container/bundle/bundle_progressbar_border",
|
||||
bx,
|
||||
by,
|
||||
96.0,
|
||||
13.0,
|
||||
u32::MAX,
|
||||
);
|
||||
if let Some(key) = layout.label {
|
||||
let label = horizon_client::text::Text::translate(key, &[]);
|
||||
let w = atlas.font.width_text(&label);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&label,
|
||||
bx + 48.0 - (w / 2.0).floor(),
|
||||
by + 3.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Hovered-slot tooltip on the plain inventory, crafting and container screens.
|
||||
pub(super) fn draw_slot_tooltip(&mut self, batch: &mut HudBatch) {
|
||||
if self.carried.is_some() {
|
||||
@@ -1984,8 +2193,7 @@ impl Game<'_> {
|
||||
};
|
||||
let stack = self.slot_view(slot);
|
||||
if let Some(stack) = stack {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(text) = self.crafter_tooltip(slot) {
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
@@ -2041,5 +2249,13 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The anvil's rename box (`AnvilScreen`: `setMaxLength(50)`).
|
||||
const ANVIL_NAME: horizon_client::clipboard::FieldId = ("anvil.name", 0);
|
||||
const ANVIL_NAME_CHARS: usize = 50;
|
||||
|
||||
fn anvil_box() -> horizon_client::ui::EditBox {
|
||||
let mut edit = horizon_client::ui::EditBox::new(0.0, 0.0, 103.0, 12.0);
|
||||
edit.bordered = false;
|
||||
edit.max_length = ANVIL_NAME_CHARS;
|
||||
edit.text_color = u32::MAX;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
@@ -210,6 +210,7 @@ impl Game<'_> {
|
||||
if bg != 0 {
|
||||
batch.fill(
|
||||
atlas,
|
||||
// submitTextBackground(-1, -1, width, height): corners, not a size.
|
||||
-1.0,
|
||||
-1.0,
|
||||
width + 1.0,
|
||||
|
||||
@@ -803,8 +803,7 @@ impl Game<'_> {
|
||||
text.split('\n').map(|l| (l.to_owned(), u32::MAX)).collect();
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(stack) = tooltip {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ pub(super) fn make_streaming(
|
||||
res.threads(),
|
||||
)
|
||||
};
|
||||
streaming.forced = save.load_forced_chunks();
|
||||
streaming.radius = settings.render_distance;
|
||||
streaming.biome_blend = settings.biome_blend;
|
||||
streaming.smooth_lighting = settings.smooth_lighting;
|
||||
|
||||
Loaded 100 of 420 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user