mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266c4576a5 |
No files matched your search
@@ -1,76 +0,0 @@
|
||||
---
|
||||
name: vanilla-parity
|
||||
description: Fix a Horizon bug or gap so it behaves 1:1 like vanilla 26.3, starting from the port ledger (tools/port_ledger.py) instead of grepping. Use for any issue, Discord report or PR that makes Horizon match vanilla: "X doesn't work like in Java", a missing sound, particle, animation, mob behaviour, block rule, screen or packet.
|
||||
---
|
||||
|
||||
# Vanilla parity with the port ledger
|
||||
|
||||
The port ledger pairs every vanilla method with the Rust fn that ports it (or says it is missing, or needs
|
||||
no port) and lists where a port isn't wired like vanilla. Ask it first; grep only for what it can't answer.
|
||||
Full reference: `docs/port-ledger.md`.
|
||||
|
||||
## 0. Have a fresh ledger
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py build # ~20 s; again after pulling main
|
||||
```
|
||||
|
||||
It needs the vanilla jar (the launcher's `versions/26.3/26.3.jar`, or `--jar`) and/or the lab's decompiled
|
||||
sources (`python3 tools/lab.py decompile`, or `--sources DIR`). If `build` says it has no vanilla code, run
|
||||
the lab first; don't guess vanilla from memory.
|
||||
|
||||
## 1. From the report to the code
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py issue 3236 # a GitHub issue number
|
||||
python3 tools/port_ledger.py issue "mooshroom stays red after lightning" # or the report's text
|
||||
```
|
||||
|
||||
It ranks the vanilla classes the report names and lists, per class, its Rust home files, where each method is
|
||||
ported, and the known gaps (`missing` rows from the verdict map, seam/client/drift gaps). Often the cause is
|
||||
already written there ("Type is not saved"). Then drill in:
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py where MushroomCow.readAdditionalSaveData # ports, callers, callees, gaps
|
||||
python3 tools/port_ledger.py class MushroomCow # every method: where, or missing
|
||||
python3 tools/port_ledger.py what crates/horizon-client/src/mobs/farm.rs:120 # which vanilla method a fn ports
|
||||
```
|
||||
|
||||
A ledger answer is a lead, not a verdict: about 1 in 12 map rows is wrong. Read the vanilla method (path and
|
||||
line are in the output) and the Rust fn before changing anything.
|
||||
|
||||
## 2. Port it the vanilla way
|
||||
|
||||
- Translate the vanilla method, don't reinvent it: same order of operations, same constants, the same random
|
||||
draws in the same order (`horizon_core::random`), Java `Math` through `horizon_core::jmath`.
|
||||
- Open the fn's doc with the vanilla method it ports: `` /// `MushroomCow.readAdditionalSaveData`: ... ``.
|
||||
That one line is what makes the next agent find it.
|
||||
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
|
||||
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
|
||||
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
|
||||
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
|
||||
usually the same bug on the other path.
|
||||
|
||||
## 3. Before the PR
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py check --base origin/main
|
||||
```
|
||||
|
||||
It lists the ports your branch touches with their open gaps, citations to add, stale citations, and (against
|
||||
the base) the gaps the branch closes and opens. Close what belongs to your fix, cite what you ported, leave
|
||||
no new stale citation. If you ported a method the verdict map called `missing`, change its row in
|
||||
`tools/port_map/*.tsv` to `port` with the fn (`file.rs::Owner::fn`) and run
|
||||
`python3 tools/port_ledger.py map-check tools/port_map/<file>.tsv`.
|
||||
|
||||
Then the usual: the harness for the area (`docs/architecture.md`), `cargo check`/tests per `AGENTS.md`, and
|
||||
a small PR that says which vanilla method it now matches.
|
||||
|
||||
## Finding work
|
||||
|
||||
```sh
|
||||
python3 tools/port_ledger.py gaps --kind seam # ported on two paths, wired on one: the best leads
|
||||
python3 tools/port_ledger.py gaps --kind client # client-only effects the server path never reaches
|
||||
python3 tools/port_ledger.py gaps --kind drift # a port missing a sound, particle, item or constant
|
||||
rg -n "player:" tools/port_map/*.tsv # missing behaviour a player would see
|
||||
```
|
||||
@@ -16,14 +16,12 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
# Every head the pull request had, not only its last: a run-parity label added on an older head left that
|
||||
# run queued after the merge (#3104, 2026-10-29). Its branch in its own repository names its runs (another
|
||||
# fork can have a branch of the same name).
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh api -X GET "repos/$REPO/actions/runs" -f branch="$BRANCH" -f event=pull_request -f status="$status" \
|
||||
-f per_page=100 --jq ".workflow_runs[] | select(.head_repository.full_name == \"$HEAD_REPO\") | .id"
|
||||
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
|
||||
--json databaseId,event,headSha \
|
||||
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
|
||||
done | sort -u | while read -r id; do
|
||||
gh run cancel -R "$REPO" "$id" || true
|
||||
done
|
||||
@@ -37,10 +37,9 @@ permissions:
|
||||
jobs:
|
||||
exporters:
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
|
||||
# goes to the Mac mini's nightly runner (macOS + horizon-nightly: ludovics-mac-mini-nightly, beside the one that
|
||||
# runs pull requests' parity jobs, so a nightly never waits behind their queue), never the release runner
|
||||
# (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
|
||||
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]') }}
|
||||
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
|
||||
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
|
||||
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -104,7 +103,7 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
|
||||
@@ -5,15 +5,11 @@ name: run-parity
|
||||
# skipped `verify`, and GitHub counts a skipped required check as passed.
|
||||
on:
|
||||
pull_request:
|
||||
# A push starts a run only to cancel the old head's (see concurrency): the job runs on the label alone.
|
||||
types: [labeled, synchronize]
|
||||
types: [labeled]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
# The run-parity label and pushes share one group per pull request, so the newest run cancels the one before: a
|
||||
# pushed pull request's run tested a head the merge would no longer land. Any other label gets a group of its own:
|
||||
# in the shared one, adding needs-proof cancelled the parity run queued seconds before (run 40766180150).
|
||||
group: run-parity-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-parity' && format('-{0}', github.run_id) || '' }}
|
||||
group: run-parity-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
|
||||
@@ -35,12 +35,11 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
|
||||
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
@@ -100,9 +99,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
- Start parity work from the port ledger, not grep: `python3 tools/port_ledger.py issue N` (or `where
|
||||
Class.method`, `what file:line`), and `check --base origin/main` before the PR. How:
|
||||
[.claude/skills/vanilla-parity/SKILL.md](.claude/skills/vanilla-parity/SKILL.md), [docs/port-ledger.md](docs/port-ledger.md).
|
||||
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
|
||||
- Bytecode queries (callers through lambdas, every overload):
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
|
||||
@@ -114,8 +110,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
|
||||
| Subsystem | Files |
|
||||
|
||||
Generated
+2
-43
@@ -1378,6 +1378,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"flate2",
|
||||
"gilrs",
|
||||
"glam",
|
||||
@@ -1385,7 +1386,6 @@ dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
@@ -1412,6 +1412,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
@@ -1440,48 +1441,6 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"flate2",
|
||||
"glam",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-command"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -9,7 +9,6 @@ members = [
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,15 +23,15 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -48,7 +48,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -139,11 +139,6 @@ average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
|
||||
them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
|
||||
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
|
||||
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
|
||||
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
|
||||
|
||||
+30
-111
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -104,49 +104,37 @@ More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native mods, built by the launcher
|
||||
|
||||
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods that run on the server
|
||||
|
||||
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Screens, input and drawing for mods
|
||||
|
||||
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
|
||||
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
|
||||
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Tools for people who make mods
|
||||
|
||||
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
|
||||
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/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: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
|
||||
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -256,20 +244,6 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Minecraft Java instances in the launcher
|
||||
|
||||
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
|
||||
|
||||
More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
@@ -294,16 +268,6 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
|
||||
|
||||
</details>
|
||||
|
||||
#### Every vanilla method, tracked
|
||||
|
||||
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
|
||||
|
||||
</details>
|
||||
|
||||
## Next
|
||||
|
||||
### Performance
|
||||
@@ -480,16 +444,6 @@ A player's idea (#horizon-ideas). `horizon-server` already runs headless; left:
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
@@ -498,9 +452,9 @@ Mods that can see and change blocks, read inventories and items, and find and af
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
|
||||
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
|
||||
More: [docs/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -514,6 +468,16 @@ Block entities and menus: ticking machines with slots, server menu logic and the
|
||||
|
||||
</details>
|
||||
|
||||
#### Lua and other languages for mods
|
||||
|
||||
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
@@ -572,13 +536,15 @@ More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
### Launcher
|
||||
|
||||
#### A new launcher design
|
||||
#### Microsoft sign-in (coming soon)
|
||||
|
||||
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
|
||||
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
|
||||
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
|
||||
|
||||
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -742,18 +708,6 @@ Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.
|
||||
|
||||
More: [#3023](https://github.com/horizon-rust/horizon/issues/3023)
|
||||
|
||||
</details>
|
||||
|
||||
#### Online-mode checks for Play with Friends
|
||||
|
||||
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
|
||||
@@ -766,18 +720,6 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Scripts for mods
|
||||
|
||||
Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
@@ -790,13 +732,13 @@ A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/
|
||||
|
||||
#### Native versions of popular client mods
|
||||
|
||||
Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.
|
||||
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.
|
||||
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
|
||||
|
||||
More: [docs/extras.md](docs/extras.md) · [docs/native-mods.md](docs/native-mods.md)
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -842,16 +784,6 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
|
||||
|
||||
</details>
|
||||
|
||||
#### Splitscreen
|
||||
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
@@ -902,7 +834,7 @@ More graphics options: multisample anti-aliasing, anisotropic filtering and an e
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -930,18 +862,6 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Windows ARM64
|
||||
|
||||
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.
|
||||
|
||||
More: [docs/release-flow.md](docs/release-flow.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
@@ -1048,7 +968,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **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/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
|
||||
@@ -11,18 +11,13 @@ pub fn parse_snbt(input: &str) -> Result<(Value, usize), String> {
|
||||
let mut p = Parser {
|
||||
s: input.as_bytes(),
|
||||
i: 0,
|
||||
depth: 0,
|
||||
};
|
||||
let v = p.value()?;
|
||||
Ok((v, p.i))
|
||||
}
|
||||
/// Nesting deeper than NBT's own limit (`NbtAccounter`'s 512) is refused
|
||||
/// rather than overflowing the stack.
|
||||
const SNBT_MAX_DEPTH: usize = 512;
|
||||
struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
}
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
@@ -46,22 +41,57 @@ impl Parser<'_> {
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b'{') => {
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
self.i += 1;
|
||||
let mut map = Map::new();
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
loop {
|
||||
self.ws();
|
||||
let key = self.string()?;
|
||||
self.expect(b':')?;
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
_ => return Err("expected ',' or '}'".into()),
|
||||
}
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.object();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'[') => {
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
self.i += 1;
|
||||
let mut list = Vec::new();
|
||||
self.ws();
|
||||
// Typed arrays [B;...], [I;...], [L;...].
|
||||
if matches!(
|
||||
self.s.get(self.i..self.i + 2),
|
||||
Some([b'B' | b'I' | b'L', b';'])
|
||||
) {
|
||||
self.i += 2;
|
||||
}
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
loop {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
_ => return Err("expected ',' or ']'".into()),
|
||||
}
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.array();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
|
||||
Some(_) => {
|
||||
@@ -75,20 +105,10 @@ impl Parser<'_> {
|
||||
_ => {
|
||||
let digits = word
|
||||
.trim_end_matches(['b', 'B', 's', 'S', 'l', 'L', 'f', 'F', 'd', 'D']);
|
||||
// Only digits (and an exponent) make a number: `nan`
|
||||
// or `Infinity`, which Rust's parse would take, stays
|
||||
// a string.
|
||||
if digits
|
||||
.bytes()
|
||||
.any(|c| c.is_ascii_alphabetic() && c != b'e' && c != b'E')
|
||||
{
|
||||
Value::String(word)
|
||||
} else {
|
||||
match (digits.parse::<i64>(), digits.parse::<f64>()) {
|
||||
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
|
||||
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
|
||||
_ => Value::String(word),
|
||||
}
|
||||
match (digits.parse::<i64>(), digits.parse::<f64>()) {
|
||||
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
|
||||
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
|
||||
_ => Value::String(word),
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -96,59 +116,6 @@ impl Parser<'_> {
|
||||
None => Err("expected a value".into()),
|
||||
}
|
||||
}
|
||||
fn object(&mut self) -> Result<Value, String> {
|
||||
self.i += 1;
|
||||
let mut map = Map::new();
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
loop {
|
||||
self.ws();
|
||||
let key = self.string()?;
|
||||
self.expect(b':')?;
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
_ => return Err("expected ',' or '}'".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn array(&mut self) -> Result<Value, String> {
|
||||
self.i += 1;
|
||||
let mut list = Vec::new();
|
||||
self.ws();
|
||||
// Typed arrays [B;...], [I;...], [L;...].
|
||||
if matches!(
|
||||
self.s.get(self.i..self.i + 2),
|
||||
Some([b'B' | b'I' | b'L', b';'])
|
||||
) {
|
||||
self.i += 2;
|
||||
}
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
loop {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
_ => return Err("expected ',' or ']'".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn unquoted(&mut self) -> String {
|
||||
let start = self.i;
|
||||
while let Some(c) = self.peek() {
|
||||
@@ -384,22 +351,4 @@ mod tests {
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.spans[0].style.font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snbt_nesting_is_bounded() {
|
||||
let nest = |n: usize| format!("{}1{}", "[".repeat(n), "]".repeat(n));
|
||||
assert!(parse_snbt(&nest(512)).is_ok());
|
||||
assert!(parse_snbt(&nest(513)).is_err());
|
||||
assert!(parse_snbt(&nest(100_000)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_words_that_look_numeric_stay_strings() {
|
||||
let (v, _) = parse_snbt("{text:nan}").unwrap();
|
||||
assert_eq!(v["text"], Value::String("nan".into()));
|
||||
let (v, _) = parse_snbt("{text:Infinity}").unwrap();
|
||||
assert_eq!(v["text"], Value::String("Infinity".into()));
|
||||
let (v, _) = parse_snbt("{n:1.5}").unwrap();
|
||||
assert_eq!(v["n"], Value::from(1.5));
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,6 @@ static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
/// A source of resource files: the prepared root plus packs, highest priority last.
|
||||
pub trait ResourceSource {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>>;
|
||||
/// Whether `path` is there, without reading it where the source can tell.
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.read(path).is_some()
|
||||
}
|
||||
/// The namespaces under `assets/` (resource packs may add their own).
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
vec!["minecraft".into()]
|
||||
@@ -72,9 +68,6 @@ impl ResourceSource for std::path::PathBuf {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
std::fs::read(self.join(path)).ok()
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.join(path).is_file()
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
std::fs::read_dir(self.join("assets"))
|
||||
.into_iter()
|
||||
@@ -197,7 +190,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
|
||||
for (code, info) in value["language"].as_object().into_iter().flatten() {
|
||||
// Only languages whose file exists somewhere are selectable.
|
||||
let file = format!("assets/minecraft/lang/{code}.json");
|
||||
if code != DEFAULT && !sources.iter().any(|s| s.exists(&file)) {
|
||||
if code != DEFAULT && !sources.iter().any(|s| s.read(&file).is_some()) {
|
||||
continue;
|
||||
}
|
||||
found.insert(
|
||||
@@ -291,26 +284,4 @@ mod tests {
|
||||
);
|
||||
assert_eq!(langs[1].label(), "Français (France)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languages_are_listed_without_reading_their_files() {
|
||||
struct NoLangReads;
|
||||
impl ResourceSource for NoLangReads {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
assert!(!path.contains("/lang/"), "read {path}");
|
||||
(path == "assets/pack.mcmeta").then(|| {
|
||||
br#"{"language":{"fr_fr":{"name":"French"},"de_de":{"name":"German"}}}"#
|
||||
.to_vec()
|
||||
})
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
path == "assets/minecraft/lang/fr_fr.json"
|
||||
}
|
||||
}
|
||||
let langs = available(&[&NoLangReads]);
|
||||
assert_eq!(
|
||||
langs.iter().map(|l| l.code.as_str()).collect::<Vec<_>>(),
|
||||
["en_us", "fr_fr"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -403,7 +403,31 @@ impl Text {
|
||||
continue;
|
||||
}
|
||||
let Some(code) = chars.next() else { break };
|
||||
let next = legacy_format(&style, code, &span.style);
|
||||
let code = code.to_ascii_lowercase();
|
||||
let next = if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
|
||||
// Style.applyLegacyFormat: a colour clears the decorations.
|
||||
Style {
|
||||
color: Some(c.2),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underlined: Some(false),
|
||||
strikethrough: Some(false),
|
||||
obfuscated: Some(false),
|
||||
..style.clone()
|
||||
}
|
||||
} else {
|
||||
let mut s = style.clone();
|
||||
match code {
|
||||
'k' => s.obfuscated = Some(true),
|
||||
'l' => s.bold = Some(true),
|
||||
'm' => s.strikethrough = Some(true),
|
||||
'n' => s.underlined = Some(true),
|
||||
'o' => s.italic = Some(true),
|
||||
'r' => s = span.style.clone(),
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
};
|
||||
if next != style {
|
||||
if !current.is_empty() {
|
||||
out.spans.push(Span {
|
||||
@@ -564,35 +588,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Style.applyLegacyFormat` for the code after a `§`, `§r` going back to
|
||||
/// `reset` (`StringDecomposer.iterateFormatted`); an unknown code changes nothing.
|
||||
pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
|
||||
let code = code.to_ascii_lowercase();
|
||||
if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
|
||||
// A colour clears the decorations.
|
||||
return Style {
|
||||
color: Some(c.2),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underlined: Some(false),
|
||||
strikethrough: Some(false),
|
||||
obfuscated: Some(false),
|
||||
..style.clone()
|
||||
};
|
||||
}
|
||||
let mut s = style.clone();
|
||||
match code {
|
||||
'k' => s.obfuscated = Some(true),
|
||||
'l' => s.bold = Some(true),
|
||||
'm' => s.strikethrough = Some(true),
|
||||
'n' => s.underlined = Some(true),
|
||||
'o' => s.italic = Some(true),
|
||||
'r' => s = reset.clone(),
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
|
||||
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
|
||||
/// shown verbatim.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
glam = "0.30"
|
||||
log = "0.4"
|
||||
miniz_oxide = "0.8"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
png = "0.18"
|
||||
pollster = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
sha2 = "0.10.9"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
# Sound output: horizon-client's `audio` feature.
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -1,131 +0,0 @@
|
||||
//! The sunrise/sunset fan (`SkyRenderer.renderSunriseAndSunset`) as the sky
|
||||
//! shader tests it. The fan's 16 triangles depend only on the sunrise alpha
|
||||
//! and the horizon the sun is on, so their vertices are built here once a
|
||||
//! frame instead of in every sky pixel (#3143: with the per-pixel build, four
|
||||
//! `sin`/`cos` per triangle, the sky pass took 10.2 ms at dawn at 1080p on an
|
||||
//! Intel UHD 730 against 0.6 ms by day; now 1.8 ms).
|
||||
//!
|
||||
//! Möller-Trumbore from the camera reduces to triple products: with apex A
|
||||
//! and rim vertices r0, r1, a ray d hits at barycentrics proportional to
|
||||
//! (`[d, r0, r1]`, `[A, d, r1]`, `[A, r0, d]`), each a dot of d with a
|
||||
//! constant normal, and their sum S is the negated determinant. A hit needs
|
||||
//! S on the side of `[A, r0, r1]`, so only the triangles whose rim normals'
|
||||
//! dots change sign there are tested in full.
|
||||
use glam::Vec3;
|
||||
|
||||
pub const TRIANGLES: usize = 16;
|
||||
|
||||
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
|
||||
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
|
||||
/// Then per rim vertex: xyz `r × A`.
|
||||
pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
let mut out = [[0.0; 4]; 2 * TRIANGLES];
|
||||
for i in 0..TRIANGLES {
|
||||
let (r0, r1) = (rim[i], rim[(i + 1) % TRIANGLES]);
|
||||
out[i] = r0.cross(r1).extend(apex.dot(r0.cross(r1))).to_array();
|
||||
out[TRIANGLES + i] = r0.cross(apex).extend(0.0).to_array();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The fan's apex and rim in camera space, as vanilla builds them: the
|
||||
/// fan turned to the horizon the sun sets on (or rises from), its rim
|
||||
/// tilted by the alpha.
|
||||
fn vertices(alpha: f32, sun: Vec3) -> (Vec3, [Vec3; TRIANGLES]) {
|
||||
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
|
||||
let flip = if -sun.z < 0.0 { 180f32 } else { 0.0 };
|
||||
let (s, c) = (flip + 90.0).to_radians().sin_cos();
|
||||
let rotate_x90 = |v: Vec3| Vec3::new(v.x, -v.z, v.y);
|
||||
let apex = rotate_x90(Vec3::new(-100.0 * s, 100.0 * c, 0.0));
|
||||
let rim = std::array::from_fn(|i| {
|
||||
let a = i as f32 * std::f32::consts::TAU / TRIANGLES as f32;
|
||||
let l = Vec3::new(a.sin() * 120.0, a.cos() * 120.0, -a.cos() * 40.0 * alpha);
|
||||
rotate_x90(Vec3::new(l.x * c - l.y * s, l.x * s + l.y * c, l.z))
|
||||
});
|
||||
(apex, rim)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// terrain.wgsl's `sunrise_weight` over the normals.
|
||||
fn weight(normals: &[[f32; 4]; 2 * TRIANGLES], d: Vec3) -> f32 {
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let mut best = 0.0f32;
|
||||
let mut k0 = d.dot(n(TRIANGLES));
|
||||
for i in 0..TRIANGLES {
|
||||
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
|
||||
let w = normals[i][3];
|
||||
if k0 * w <= 0.0 && k1 * w >= 0.0 {
|
||||
let apex = d.dot(n(i));
|
||||
let s = apex + k1 - k0;
|
||||
if s.abs() >= 1e-6
|
||||
&& s * w > 0.0
|
||||
&& apex * s >= 0.0
|
||||
&& k1 * s >= 0.0
|
||||
&& k0 * s <= 0.0
|
||||
{
|
||||
best = best.max(apex / s);
|
||||
}
|
||||
}
|
||||
k0 = k1;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
|
||||
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
let mut best = 0.0f32;
|
||||
for i in 0..TRIANGLES {
|
||||
let e1 = rim[i] - apex;
|
||||
let e2 = rim[(i + 1) % TRIANGLES] - apex;
|
||||
let pv = d.cross(e2);
|
||||
let det = e1.dot(pv);
|
||||
if det.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let tv = -apex;
|
||||
let u = tv.dot(pv) / det;
|
||||
let qv = tv.cross(e1);
|
||||
let v = d.dot(qv) / det;
|
||||
let t = e2.dot(qv) / det;
|
||||
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
|
||||
best = best.max(1.0 - u - v);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
|
||||
let mut hits = 0;
|
||||
for (alpha, sun_z) in [(1.0f32, -0.2f32), (0.6, 0.3), (0.05, -0.9), (0.9, 0.05)] {
|
||||
let sun = Vec3::new(0.0, (1.0 - sun_z * sun_z).sqrt(), sun_z);
|
||||
let normals = normals(alpha, sun);
|
||||
for yaw in 0..180 {
|
||||
for pitch in -45..=45 {
|
||||
let (yaw, pitch) = (
|
||||
(yaw as f32 * 2.0).to_radians(),
|
||||
(pitch as f32 * 2.0).to_radians(),
|
||||
);
|
||||
let d = Vec3::new(
|
||||
yaw.cos() * pitch.cos(),
|
||||
pitch.sin(),
|
||||
yaw.sin() * pitch.cos(),
|
||||
);
|
||||
let (got, want) = (weight(&normals, d), reference(alpha, sun, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"{alpha} {sun_z} {d}: {got} vs {want}"
|
||||
);
|
||||
hits += usize::from(want > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The fan covers part of the sky, not none of it.
|
||||
assert!(hits > 1000, "{hits}");
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
//! Resolution scale, FXAA and TAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With none on,
|
||||
//! the world renders straight to the screen.
|
||||
//!
|
||||
//! TAA jitters the world's projection by a sub-pixel offset each frame and blends
|
||||
//! the frame into a history reprojected through the depth buffer: its motion
|
||||
//! vectors are the camera's, so what moves on its own leans on the neighbourhood
|
||||
//! clamp alone.
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec2};
|
||||
|
||||
/// The TAA history format: 8 bits would band as frames accumulate.
|
||||
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
color: wgpu::TextureView,
|
||||
depth: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
taa: Option<History>,
|
||||
}
|
||||
|
||||
/// TAA's two history textures at the output size (the world's, times the upscale), written
|
||||
/// in turn: `resolve[i]` writes `history[i]` from the world and `history[1 - i]`; `show[i]`
|
||||
/// copies `history[i]` to the screen.
|
||||
struct History {
|
||||
size: [u32; 2],
|
||||
history: [wgpu::TextureView; 2],
|
||||
resolve: [wgpu::BindGroup; 2],
|
||||
show: [wgpu::BindGroup; 2],
|
||||
}
|
||||
|
||||
/// `world_post.wgsl`'s `Taa`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct TaaUniform {
|
||||
/// This frame's unjittered clip space to the previous frame's.
|
||||
reproject: [[f32; 4]; 4],
|
||||
/// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
|
||||
jitter: [f32; 4],
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
pub taa: bool,
|
||||
/// Output pixels per rendered pixel while TAA is on (1: native): the world renders
|
||||
/// this much smaller and TAA rebuilds it at the resolution scale's size.
|
||||
pub upscale: f32,
|
||||
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
|
||||
taa_on: bool,
|
||||
target: Option<WorldTarget>,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
taa_uniform: wgpu::Buffer,
|
||||
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
|
||||
taa_frame: u32,
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
taa_previous: Option<(DMat4, DVec3)>,
|
||||
taa_this_frame: TaaUniform,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../world_post.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon post bindings"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
|
||||
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
layout,
|
||||
taa_layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
taa: false,
|
||||
upscale: 1.0,
|
||||
taa_on: false,
|
||||
target: None,
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
let upscale = if self.taa_on { self.upscale } else { 1.0 };
|
||||
(
|
||||
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
|
||||
self.taa_on = on && self.taa;
|
||||
if !self.taa_on {
|
||||
self.taa_previous = None;
|
||||
return Mat4::IDENTITY;
|
||||
}
|
||||
self.taa_frame = self.taa_frame.wrapping_add(1);
|
||||
let (size, _) = self.sizes(window);
|
||||
// Halton (2, 3) over 8 frames per output pixel (FSR's count), in rendered
|
||||
// pixels around the centre.
|
||||
let phases = (8.0 * self.upscale * self.upscale).ceil() as u32;
|
||||
let i = self.taa_frame % phases + 1;
|
||||
let offset = Vec2::new(halton(i, 2), halton(i, 3)) - 0.5;
|
||||
let jitter = offset * 2.0 / Vec2::new(size[0] as f32, size[1] as f32);
|
||||
let current = view_proj.as_dmat4();
|
||||
let (reproject, reset) = match self.taa_previous {
|
||||
Some((previous, previous_camera)) => (
|
||||
reprojection(previous, previous_camera, current, camera),
|
||||
0.0,
|
||||
),
|
||||
None => (DMat4::IDENTITY, 1.0),
|
||||
};
|
||||
self.taa_previous = Some((current, camera));
|
||||
self.taa_this_frame = TaaUniform {
|
||||
reproject: reproject.as_mat4().to_cols_array_2d(),
|
||||
jitter: [jitter.x, jitter.y, reset, 0.0],
|
||||
};
|
||||
Mat4::from_translation(jitter.extend(0.0))
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
always: bool,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
|
||||
{
|
||||
let make_tex = |label, size: [u32; 2], format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let sampled =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = make_tex("Scaled world", size, self.format, sampled)
|
||||
.create_view(&Default::default());
|
||||
// TEXTURE_BINDING: occlusion culling and TAA read it.
|
||||
let depth = make_tex(
|
||||
"Scaled world depth",
|
||||
size,
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
sampled,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
let taa = history_size.map(|output| {
|
||||
let history = [0, 1].map(|_| {
|
||||
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
|
||||
.create_view(&Default::default())
|
||||
});
|
||||
let resolve = [0, 1].map(|i| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: &self.taa_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&history[1 - i]),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.taa_uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let show = [0, 1].map(|i| self.bind(device, &history[i]));
|
||||
History {
|
||||
size: output,
|
||||
history,
|
||||
resolve,
|
||||
show,
|
||||
}
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
color,
|
||||
depth,
|
||||
group,
|
||||
taa,
|
||||
});
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The size of the world's own target, when it has one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
}
|
||||
|
||||
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
|
||||
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the scaled world onto `screen`, through TAA when this frame's `jitter` had it on.
|
||||
pub fn finish(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
let Some(target) = &self.target else { return };
|
||||
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(
|
||||
&self.taa_uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&self.taa_this_frame),
|
||||
);
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.taa_pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
self.finish_with(encoder, screen, &taa.show[i]);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
pub fn finish_with(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
group: &wgpu::BindGroup,
|
||||
) {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: screen,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(if self.antialias {
|
||||
&self.fxaa
|
||||
} else {
|
||||
&self.copy
|
||||
});
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// This frame's clip space to the previous frame's, for camera-relative projections:
|
||||
/// a point `camera - previous_camera` further from the previous camera than from this one.
|
||||
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
|
||||
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
|
||||
}
|
||||
|
||||
/// The radical inverse of `i` in `base`: a low-discrepancy sequence in 0..1.
|
||||
fn halton(mut i: u32, base: u32) -> f32 {
|
||||
let (mut f, mut r) = (1.0, 0.0);
|
||||
while i > 0 {
|
||||
f /= base as f32;
|
||||
r += f * (i % base) as f32;
|
||||
i /= base;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reprojection_finds_where_a_block_was_last_frame() {
|
||||
let projection = DMat4::perspective_rh(1.2, 16.0 / 9.0, 1024.0, 0.05);
|
||||
let previous = projection * DMat4::from_rotation_y(0.3);
|
||||
let current = projection * DMat4::from_rotation_y(0.35);
|
||||
let (previous_camera, camera) = (DVec3::new(10.0, 64.0, 5.0), DVec3::new(10.5, 64.2, 4.0));
|
||||
let block = DVec3::new(14.0, 66.0, -8.0);
|
||||
let clip = |m: DMat4, eye: DVec3| m * (block - eye).extend(1.0);
|
||||
let now = clip(current, camera);
|
||||
let then = reprojection(previous, previous_camera, current, camera) * (now / now.w);
|
||||
let expected = clip(previous, previous_camera);
|
||||
assert!((then.truncate() / then.w - expected.truncate() / expected.w).length() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_inside_the_pixel() {
|
||||
for i in 1..=8 {
|
||||
let (x, y) = (halton(i, 2), halton(i, 3));
|
||||
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
|
||||
}
|
||||
assert_eq!(
|
||||
[halton(1, 2), halton(2, 2), halton(3, 2)],
|
||||
[0.5, 0.25, 0.75]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer,
|
||||
//! the HUD, menus and settings. Gameplay (horizon-client) builds on it; nothing here reaches gameplay.
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod assets;
|
||||
pub mod atlas_sources;
|
||||
pub mod audio;
|
||||
pub mod auth;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_render;
|
||||
pub mod border_render;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
pub mod camera;
|
||||
pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
pub mod containers;
|
||||
pub mod crash;
|
||||
pub mod datapack;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod environment;
|
||||
pub mod extras;
|
||||
pub mod fast_hash;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
pub mod graphics_ui;
|
||||
pub mod hud;
|
||||
pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu_blur;
|
||||
pub mod mesher;
|
||||
pub mod minimap;
|
||||
pub mod mipmap;
|
||||
pub mod mod_list;
|
||||
pub mod model_library;
|
||||
pub mod models;
|
||||
pub mod options_file;
|
||||
pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
pub mod overlay;
|
||||
pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod settings;
|
||||
pub mod shader_pack_list;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod spans;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod testgen;
|
||||
pub mod title_edition;
|
||||
pub mod trims;
|
||||
pub mod ui;
|
||||
pub mod vault;
|
||||
pub mod weather;
|
||||
pub mod widgets;
|
||||
pub mod world;
|
||||
pub mod worldborder;
|
||||
pub mod zip;
|
||||
pub use horizon_client_core::Result;
|
||||
pub use horizon_client_core::block_entity_nbt;
|
||||
pub use horizon_client_core::bossbar;
|
||||
pub use horizon_client_core::component;
|
||||
pub use horizon_client_core::fastutil_order;
|
||||
pub use horizon_client_core::gamerules;
|
||||
pub use horizon_client_core::lang;
|
||||
pub use horizon_client_core::nbt_path;
|
||||
pub use horizon_client_core::scoreboard;
|
||||
pub use horizon_client_core::snbt;
|
||||
pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
pub use horizon_items::bundle;
|
||||
pub use horizon_items::cooldowns;
|
||||
pub use horizon_items::enchant;
|
||||
pub use horizon_items::item_data;
|
||||
pub use horizon_items::items;
|
||||
pub use horizon_items::random_sequences;
|
||||
@@ -1,345 +0,0 @@
|
||||
//! Decoded block sprites kept on disk between launches (M-11): reading and
|
||||
//! decoding ~1300 small PNGs is most of `Sprites::load`. One file per resource
|
||||
//! root, valid only while the `textures` trees still have the same paths,
|
||||
//! sizes and modification times and the format version is the same; anything
|
||||
//! else (missing, corrupt, stale, unwritable) means the normal decoding path.
|
||||
use crate::mipmap::Strategy;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"HSPR";
|
||||
/// Bump when `Decoded` or the decoding in `Sprites::decode` changes.
|
||||
const VERSION: u32 = 1;
|
||||
const FILE: &str = "horizon-sprites.cache";
|
||||
|
||||
/// What decoding one texture gives, before it takes a layer.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Decoded {
|
||||
pub side: u32,
|
||||
pub frames: Vec<Vec<u8>>,
|
||||
/// The `.mcmeta` `animation` value, as text.
|
||||
pub animation: Option<String>,
|
||||
pub strategy: Strategy,
|
||||
pub alpha_cutoff_bias: f32,
|
||||
pub has_transparent: bool,
|
||||
pub opaque: bool,
|
||||
pub translucent: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct SpriteCache {
|
||||
path: PathBuf,
|
||||
fingerprint: u64,
|
||||
entries: HashMap<String, Decoded>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
/// Paths, sizes and modification times of every file under `assets/*/textures`,
|
||||
/// summed so the directory order does not matter.
|
||||
fn fingerprint(root: &Path) -> Option<u64> {
|
||||
fn walk(dir: &Path, base: &Path, sum: &mut u64) -> Option<()> {
|
||||
for entry in fs::read_dir(dir).ok()? {
|
||||
let entry = entry.ok()?;
|
||||
let meta = entry.metadata().ok()?;
|
||||
if meta.is_dir() {
|
||||
walk(&entry.path(), base, sum)?;
|
||||
continue;
|
||||
}
|
||||
let mut hasher = DefaultHasher::new();
|
||||
entry.path().strip_prefix(base).ok()?.hash(&mut hasher);
|
||||
meta.len().hash(&mut hasher);
|
||||
meta.modified().ok()?.hash(&mut hasher);
|
||||
*sum = sum.wrapping_add(hasher.finish());
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
let mut sum = 0;
|
||||
for namespace in fs::read_dir(root.join("assets")).ok()? {
|
||||
let textures = namespace.ok()?.path().join("textures");
|
||||
if textures.is_dir() {
|
||||
walk(&textures, root, &mut sum)?;
|
||||
}
|
||||
}
|
||||
Some(sum)
|
||||
}
|
||||
|
||||
impl SpriteCache {
|
||||
/// The cache of `root`: its stored entries when still valid, else empty.
|
||||
/// `None` when the textures cannot be fingerprinted (no caching then).
|
||||
pub fn open(root: &Path) -> Option<Self> {
|
||||
let fingerprint = fingerprint(root)?;
|
||||
let path = root.join(FILE);
|
||||
let entries = fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|bytes| parse(&bytes, fingerprint))
|
||||
.unwrap_or_default();
|
||||
Some(Self {
|
||||
path,
|
||||
fingerprint,
|
||||
entries,
|
||||
dirty: false,
|
||||
})
|
||||
}
|
||||
pub fn get(&self, name: &str) -> Option<&Decoded> {
|
||||
self.entries.get(name)
|
||||
}
|
||||
pub fn insert(&mut self, name: &str, decoded: Decoded) {
|
||||
self.entries.insert(name.to_owned(), decoded);
|
||||
self.dirty = true;
|
||||
}
|
||||
/// Write new entries; a failure only costs the next launch the decoding.
|
||||
pub fn save(&mut self) {
|
||||
if !self.dirty {
|
||||
return;
|
||||
}
|
||||
self.dirty = false;
|
||||
let temporary = self
|
||||
.path
|
||||
.with_extension(format!("{}.tmp", std::process::id()));
|
||||
if fs::write(&temporary, serialize(self.fingerprint, &self.entries)).is_ok()
|
||||
&& fs::rename(&temporary, &self.path).is_err()
|
||||
{
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize(fingerprint: u64, entries: &HashMap<String, Decoded>) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.extend_from_slice(&VERSION.to_le_bytes());
|
||||
out.extend_from_slice(&fingerprint.to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
|
||||
for (name, d) in entries {
|
||||
put_bytes(&mut out, name.as_bytes());
|
||||
out.extend_from_slice(&d.side.to_le_bytes());
|
||||
out.extend_from_slice(&(d.frames.len() as u32).to_le_bytes());
|
||||
for frame in &d.frames {
|
||||
put_bytes(&mut out, frame);
|
||||
}
|
||||
put_bytes(&mut out, d.animation.as_deref().unwrap_or("").as_bytes());
|
||||
out.push(u8::from(d.animation.is_some()));
|
||||
out.push(d.strategy as u8);
|
||||
out.extend_from_slice(&d.alpha_cutoff_bias.to_bits().to_le_bytes());
|
||||
out.push(
|
||||
u8::from(d.has_transparent) | u8::from(d.opaque) << 1 | u8::from(d.translucent) << 2,
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
struct Reader<'a>(&'a [u8]);
|
||||
impl<'a> Reader<'a> {
|
||||
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
|
||||
let (head, tail) = self.0.split_at_checked(n)?;
|
||||
self.0 = tail;
|
||||
Some(head)
|
||||
}
|
||||
fn u32(&mut self) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
|
||||
}
|
||||
fn bytes(&mut self) -> Option<&'a [u8]> {
|
||||
let n = self.u32()? as usize;
|
||||
self.take(n)
|
||||
}
|
||||
}
|
||||
fn parse(bytes: &[u8], fingerprint: u64) -> Option<HashMap<String, Decoded>> {
|
||||
let mut r = Reader(bytes);
|
||||
if r.take(4)? != MAGIC || r.u32()? != VERSION {
|
||||
return None;
|
||||
}
|
||||
if u64::from_le_bytes(r.take(8)?.try_into().ok()?) != fingerprint {
|
||||
return None;
|
||||
}
|
||||
let count = r.u32()?;
|
||||
let mut entries = HashMap::new();
|
||||
for _ in 0..count {
|
||||
let name = String::from_utf8(r.bytes()?.to_vec()).ok()?;
|
||||
let side = r.u32()?;
|
||||
let frame_count = r.u32()?;
|
||||
let mut frames = Vec::new();
|
||||
for _ in 0..frame_count {
|
||||
frames.push(r.bytes()?.to_vec());
|
||||
}
|
||||
let animation = String::from_utf8(r.bytes()?.to_vec()).ok()?;
|
||||
let has_animation = r.take(1)?[0] != 0;
|
||||
let strategy = match r.take(1)?[0] {
|
||||
0 => Strategy::Auto,
|
||||
1 => Strategy::Mean,
|
||||
2 => Strategy::Cutout,
|
||||
3 => Strategy::StrictCutout,
|
||||
4 => Strategy::DarkCutout,
|
||||
_ => return None,
|
||||
};
|
||||
let alpha_cutoff_bias = f32::from_bits(r.u32()?);
|
||||
let flags = r.take(1)?[0];
|
||||
let frame_len = (side as usize).checked_mul(side as usize)?.checked_mul(4)?;
|
||||
if frames.is_empty() || frames.iter().any(|f| f.len() != frame_len) {
|
||||
return None;
|
||||
}
|
||||
entries.insert(
|
||||
name,
|
||||
Decoded {
|
||||
side,
|
||||
frames,
|
||||
animation: has_animation.then_some(animation),
|
||||
strategy,
|
||||
alpha_cutoff_bias,
|
||||
has_transparent: flags & 1 != 0,
|
||||
opaque: flags & 2 != 0,
|
||||
translucent: flags & 4 != 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
r.0.is_empty().then_some(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(blue: u8, animation: Option<&str>) -> Decoded {
|
||||
Decoded {
|
||||
side: 2,
|
||||
frames: vec![vec![blue; 16], vec![blue + 1; 16]],
|
||||
animation: animation.map(str::to_owned),
|
||||
strategy: Strategy::Cutout,
|
||||
alpha_cutoff_bias: 0.25,
|
||||
has_transparent: true,
|
||||
opaque: false,
|
||||
translucent: true,
|
||||
}
|
||||
}
|
||||
fn scratch(tag: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-cache-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("assets/minecraft/textures/block")).unwrap();
|
||||
dir
|
||||
}
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let mut entries = HashMap::new();
|
||||
entries.insert("a:b".to_owned(), sample(1, Some("{\"frametime\":2}")));
|
||||
entries.insert("a:c".to_owned(), sample(9, None));
|
||||
let back = parse(&serialize(7, &entries), 7).unwrap();
|
||||
for (name, d) in &entries {
|
||||
let e = &back[name];
|
||||
assert_eq!(
|
||||
(d.side, &d.frames, &d.animation),
|
||||
(e.side, &e.frames, &e.animation)
|
||||
);
|
||||
assert_eq!(d.alpha_cutoff_bias, e.alpha_cutoff_bias);
|
||||
assert_eq!(
|
||||
(d.has_transparent, d.opaque, d.translucent, d.strategy as u8),
|
||||
(e.has_transparent, e.opaque, e.translucent, e.strategy as u8)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn wrong_fingerprint_truncation_and_garbage_are_misses() {
|
||||
let mut entries = HashMap::new();
|
||||
entries.insert("a:b".to_owned(), sample(1, None));
|
||||
let bytes = serialize(7, &entries);
|
||||
assert!(parse(&bytes, 8).is_none());
|
||||
for cut in 0..bytes.len() {
|
||||
assert!(parse(&bytes[..cut], 7).is_none(), "cut {cut}");
|
||||
}
|
||||
assert!(parse(b"garbage", 7).is_none());
|
||||
let mut extra = bytes.clone();
|
||||
extra.push(0);
|
||||
assert!(parse(&extra, 7).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn changed_texture_invalidates_and_corrupt_file_is_ignored() {
|
||||
let root = scratch("invalidate");
|
||||
let png = root.join("assets/minecraft/textures/block/x.png");
|
||||
fs::write(&png, b"one").unwrap();
|
||||
let mut cache = SpriteCache::open(&root).unwrap();
|
||||
cache.insert("minecraft:block/x", sample(1, None));
|
||||
cache.save();
|
||||
assert!(
|
||||
SpriteCache::open(&root)
|
||||
.unwrap()
|
||||
.get("minecraft:block/x")
|
||||
.is_some()
|
||||
);
|
||||
// Same length, new content and time: still stale.
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
fs::write(&png, b"two").unwrap();
|
||||
assert!(
|
||||
SpriteCache::open(&root)
|
||||
.unwrap()
|
||||
.get("minecraft:block/x")
|
||||
.is_none()
|
||||
);
|
||||
// A new file also invalidates.
|
||||
let mut cache = SpriteCache::open(&root).unwrap();
|
||||
cache.insert("minecraft:block/x", sample(1, None));
|
||||
cache.save();
|
||||
fs::write(root.join("assets/minecraft/textures/block/y.png"), b"y").unwrap();
|
||||
assert!(
|
||||
SpriteCache::open(&root)
|
||||
.unwrap()
|
||||
.get("minecraft:block/x")
|
||||
.is_none()
|
||||
);
|
||||
// Corrupt cache file: empty, no error.
|
||||
fs::write(root.join(FILE), b"HSPR junk").unwrap();
|
||||
assert!(
|
||||
SpriteCache::open(&root)
|
||||
.unwrap()
|
||||
.get("minecraft:block/x")
|
||||
.is_none()
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
fn write_png(path: &Path, rgba: [u8; 4]) {
|
||||
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = rgba.iter().copied().cycle().take(16 * 16 * 4).collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
}
|
||||
fn sprite_pixels(root: &Path) -> Vec<u8> {
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
sprites.insert(root, "minecraft:block/x").unwrap();
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
sprites.pixels
|
||||
}
|
||||
#[test]
|
||||
fn altered_png_is_not_served_stale_and_cache_matches_decoding() {
|
||||
let root = scratch("stale");
|
||||
let png = root.join("assets/minecraft/textures/block/x.png");
|
||||
write_png(&png, [255, 0, 0, 255]);
|
||||
let decoded = sprite_pixels(&root);
|
||||
assert!(root.join(FILE).exists());
|
||||
assert_eq!(
|
||||
sprite_pixels(&root),
|
||||
decoded,
|
||||
"cache hit differs from decoding"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
write_png(&png, [0, 255, 0, 255]);
|
||||
let fresh = sprite_pixels(&root);
|
||||
assert_eq!(&fresh[..4], &[0, 255, 0, 255]);
|
||||
// Corrupt and deleted caches fall back to decoding.
|
||||
fs::write(root.join(FILE), b"HSPR\x01\0\0\0 junk").unwrap();
|
||||
assert_eq!(sprite_pixels(&root), fresh);
|
||||
fs::remove_file(root.join(FILE)).unwrap();
|
||||
assert_eq!(sprite_pixels(&root), fresh);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
// Horizon's world post pass: the world, rendered at the resolution scale,
|
||||
// copied (bilinear) or anti-aliased with FXAA onto the screen before the
|
||||
// hand and HUD. Only used when a non-vanilla setting asks for it.
|
||||
@group(0) @binding(0) var world: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_sampler: sampler;
|
||||
|
||||
struct PostOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex fn vs_post(@builtin(vertex_index) i: u32) -> PostOut {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: PostOut;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment fn fs_copy(in: PostOut) -> @location(0) vec4<f32> {
|
||||
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
|
||||
return textureSampleLevel(world, world_sampler, in.uv, 0.0);
|
||||
}
|
||||
|
||||
fn luma(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3(0.299, 0.587, 0.114));
|
||||
}
|
||||
|
||||
// FXAA 3.11 (console quality): blend along the local edge direction.
|
||||
@fragment fn fs_fxaa(in: PostOut) -> @location(0) vec4<f32> {
|
||||
let texel = 1.0 / vec2<f32>(textureDimensions(world));
|
||||
let centre = textureSampleLevel(world, world_sampler, in.uv, 0.0);
|
||||
let rgb_m = centre.rgb;
|
||||
let nw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, -0.5) * texel, 0.0).rgb);
|
||||
let ne = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, -0.5) * texel, 0.0).rgb);
|
||||
let sw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, 0.5) * texel, 0.0).rgb);
|
||||
let se = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, 0.5) * texel, 0.0).rgb);
|
||||
let m = luma(rgb_m);
|
||||
let lo = min(m, min(min(nw, ne), min(sw, se)));
|
||||
let hi = max(m, max(max(nw, ne), max(sw, se)));
|
||||
if hi - lo < max(0.0312, hi * 0.125) {
|
||||
return centre;
|
||||
}
|
||||
var dir = vec2(-((nw + ne) - (sw + se)), (nw + sw) - (ne + se));
|
||||
let reduce = max((nw + ne + sw + se) * 0.25 * 0.125, 1.0 / 128.0);
|
||||
let scale = 1.0 / (min(abs(dir.x), abs(dir.y)) + reduce);
|
||||
dir = clamp(dir * scale, vec2(-8.0), vec2(8.0)) * texel;
|
||||
let a = 0.5 * (textureSampleLevel(world, world_sampler, in.uv + dir * (1.0 / 3.0 - 0.5), 0.0).rgb
|
||||
+ textureSampleLevel(world, world_sampler, in.uv + dir * (2.0 / 3.0 - 0.5), 0.0).rgb);
|
||||
let b = a * 0.5 + 0.25 * (textureSampleLevel(world, world_sampler, in.uv - dir * 0.5, 0.0).rgb
|
||||
+ textureSampleLevel(world, world_sampler, in.uv + dir * 0.5, 0.0).rgb);
|
||||
let lb = luma(b);
|
||||
if lb < lo || lb > hi {
|
||||
return vec4(a, centre.a);
|
||||
}
|
||||
return vec4(b, centre.a);
|
||||
}
|
||||
|
||||
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
|
||||
// depth buffer through the camera's motion and clamped to this frame's 3x3
|
||||
// neighbourhood so that what moved or appeared does not ghost.
|
||||
struct Taa {
|
||||
reproject: mat4x4<f32>,
|
||||
// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
|
||||
jitter: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var history: texture_2d<f32>;
|
||||
@group(0) @binding(3) var depth: texture_depth_2d;
|
||||
@group(0) @binding(4) var<uniform> taa: Taa;
|
||||
|
||||
// Catmull-Rom over the history in 5 bilinear taps: sharper than one under motion.
|
||||
fn sample_history(uv: vec2<f32>) -> vec3<f32> {
|
||||
let size = vec2<f32>(textureDimensions(history));
|
||||
let pos = uv * size;
|
||||
let centre = floor(pos - 0.5) + 0.5;
|
||||
let f = pos - centre;
|
||||
let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
let w3 = f * f * (-0.5 + 0.5 * f);
|
||||
let w12 = w1 + w2;
|
||||
let t0 = (centre - 1.0) / size;
|
||||
let t3 = (centre + 2.0) / size;
|
||||
let t12 = (centre + w2 / w12) / size;
|
||||
let c = textureSampleLevel(history, world_sampler, vec2(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, t12, 0.0).rgb * (w12.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
|
||||
let weight = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y;
|
||||
return max(c / weight, vec3(0.0));
|
||||
}
|
||||
|
||||
// It runs at the history's size, which upscaling makes larger than the world's.
|
||||
// The history is unjittered: each output pixel takes the world pixel whose
|
||||
// jittered sample lands nearest its centre, weighted by how near it lands.
|
||||
@fragment fn fs_taa(in: PostOut) -> @location(0) vec4<f32> {
|
||||
let size = vec2<f32>(textureDimensions(world));
|
||||
// The jitter moved the scene by `jitter` in clip space: where this pixel's
|
||||
// point was drawn, in world pixels.
|
||||
let drawn = (in.uv + taa.jitter.xy * vec2(0.5, -0.5)) * size;
|
||||
let last = vec2<i32>(size) - 1;
|
||||
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
|
||||
let centre = textureLoad(world, p, 0);
|
||||
var lo = centre.rgb;
|
||||
var hi = centre.rgb;
|
||||
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
|
||||
// the edges of what is in front from trailing.
|
||||
var near = 0.0;
|
||||
for (var y = -1; y <= 1; y++) {
|
||||
for (var x = -1; x <= 1; x++) {
|
||||
let q = clamp(p + vec2(x, y), vec2(0), last);
|
||||
let c = textureLoad(world, q, 0).rgb;
|
||||
lo = min(lo, c);
|
||||
hi = max(hi, c);
|
||||
near = max(near, textureLoad(depth, q, 0));
|
||||
}
|
||||
}
|
||||
let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||
let previous = taa.reproject * vec4(ndc, near, 1.0);
|
||||
let uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
|
||||
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
|
||||
return vec4(centre.rgb, centre.a);
|
||||
}
|
||||
let past = clamp(sample_history(uv), lo, hi);
|
||||
// How far the sample landed from this pixel's centre, in output pixels: a
|
||||
// Gaussian of it scales the new frame's share, so that frames whose sample
|
||||
// fell far away (most of them when upscaling) barely move the pixel.
|
||||
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
|
||||
let share = 0.1 * exp(-2.0 * dot(off, off));
|
||||
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
|
||||
return vec4(mix(past, centre.rgb, share), centre.a);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ description = "Horizon native development client"
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
@@ -44,6 +43,9 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
@@ -88,7 +90,7 @@ winresource = "0.1"
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
audio = ["dep:cpal"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
@@ -106,7 +108,6 @@ path = "examples/probe/main.rs"
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -302,7 +302,7 @@ fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
let name = op[0].as_str().unwrap();
|
||||
match name {
|
||||
"tick" => state.tick([0.0; 3]),
|
||||
"tick" => state.tick(),
|
||||
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
|
||||
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
|
||||
"reset" => state.reset_bob(),
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//! Double chests for tools/test_chest_pair_parity.py: reads one JSON case per
|
||||
//! line (see VanillaChestPairOracle), a chest's block state at the origin and
|
||||
//! another block state at the offset "at", and prints what
|
||||
//! `horizon_client::containers::ChestHalf` pairs them into, as the game's
|
||||
//! menus and hoppers do: "double" with the right half's x,z then the left
|
||||
//! half's, or "single".
|
||||
//!
|
||||
//! usage: chest_pair_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::{ChestHalf, is_chest_block};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
/// `minecraft:chest[facing=north,type=left]` as its name and properties.
|
||||
fn parse(state: &str) -> Option<(&str, BTreeMap<&str, &str>)> {
|
||||
let (name, props) = match state.split_once('[') {
|
||||
Some((name, rest)) => (name, rest.strip_suffix(']')?),
|
||||
None => (state, ""),
|
||||
};
|
||||
let props = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| p.split_once('='))
|
||||
.collect::<Option<_>>()?;
|
||||
Some((name, props))
|
||||
}
|
||||
|
||||
fn half<'a>(name: &'a str, props: &BTreeMap<&str, &'a str>) -> Option<ChestHalf<'a>> {
|
||||
Some(ChestHalf {
|
||||
block: name,
|
||||
kind: props.get("type")?,
|
||||
facing: props.get("facing")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let (name, props) = parse(case["state"].as_str()?)?;
|
||||
let (other_name, other_props) = parse(case["other"].as_str()?)?;
|
||||
let at = (
|
||||
i32::try_from(case["at"][0].as_i64()?).ok()?,
|
||||
i32::try_from(case["at"][1].as_i64()?).ok()?,
|
||||
);
|
||||
if !is_chest_block(name) {
|
||||
return None;
|
||||
}
|
||||
let own = half(name, &props)?;
|
||||
let paired = own.connected_step() == Some(at)
|
||||
&& half(other_name, &other_props).is_some_and(|other| own.pairs_with(&other));
|
||||
Some(if !paired {
|
||||
"single".to_owned()
|
||||
} else if own.kind == "right" {
|
||||
format!("double 0,0 {},{}", at.0, at.1)
|
||||
} else {
|
||||
format!("double {},{} 0,0", at.0, at.1)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: chest_pair_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//! Container menu titles for tools/test_container_title_parity.py: reads one
|
||||
//! JSON case per line (see VanillaContainerTitleOracle) and prints the title
|
||||
//! the server opens the container's menu with, as canonical NBT. A single
|
||||
//! block entity is placed as the game places it (`block_entity_item::place`,
|
||||
//! whose `CustomName` the game keeps in `World::block_names`), then named by
|
||||
//! `containers::display_name`; a double chest's halves by
|
||||
//! `containers::double_chest_name`.
|
||||
//!
|
||||
//! usage: container_title_fixture INPUT OUTPUT
|
||||
use horizon_client::block_entity_item::place;
|
||||
use horizon_client::block_entity_nbt::canonical;
|
||||
use horizon_client::containers::{default_name_key, display_name, double_chest_name};
|
||||
use horizon_client::save::text;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
/// ParityData.canon of a text component: sorted keys.
|
||||
fn canon(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, v)) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&k);
|
||||
out.push(':');
|
||||
canon(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Tag::List(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canon(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snbt(value: &Value) -> Option<Tag> {
|
||||
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
|
||||
}
|
||||
|
||||
/// The custom name the game keeps for a block entity saved as `value`.
|
||||
fn custom_name(value: &Value) -> Option<Option<String>> {
|
||||
let saved = canonical(&snbt(value)?)?;
|
||||
Some(text(saved.get("CustomName")))
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let title = if let Some(first) = case.get("first") {
|
||||
let first = custom_name(first)?;
|
||||
let second = custom_name(&case["second"])?;
|
||||
double_chest_name(first.as_deref(), second.as_deref())
|
||||
} else {
|
||||
let saved = canonical(&snbt(&case["nbt"])?)?;
|
||||
// BlockItem.updateBlockEntityComponents, as Game::apply_placed_item runs it.
|
||||
let placed = match case.get("patch") {
|
||||
Some(p) => match snbt(p)? {
|
||||
Tag::Compound(patch) => place(&saved, &patch, true),
|
||||
_ => return None,
|
||||
},
|
||||
None => saved,
|
||||
};
|
||||
let key = default_name_key(case["block"].as_str()?)?;
|
||||
display_name(text(placed.get("CustomName")).as_deref(), key)
|
||||
};
|
||||
let mut out = String::new();
|
||||
canon(&title, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: container_title_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -25,7 +25,6 @@ fn stack(spec: &str) -> LootStack {
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
names: Vec::new(),
|
||||
components: Vec::new(),
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
|
||||
@@ -24,11 +24,9 @@ mod breaking_fixture;
|
||||
mod camera_probe;
|
||||
mod cauldron_fixture;
|
||||
mod chat_screen_fixture;
|
||||
mod chest_pair_fixture;
|
||||
mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod container_title_fixture;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
@@ -92,7 +90,6 @@ mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod text_display_render_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -118,11 +115,9 @@ probes! {
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
chest_pair_fixture,
|
||||
cloud_fixture,
|
||||
command_parse_fixture,
|
||||
container_screen_probe,
|
||||
container_title_fixture,
|
||||
creative_search_fixture,
|
||||
emitter_particles_probe,
|
||||
enchanting_fixture,
|
||||
@@ -185,7 +180,6 @@ probes! {
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
text_display_render_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
beehive::{BeehiveExt, HiveHost},
|
||||
beehive::HiveHost,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, MIN_Y},
|
||||
@@ -67,9 +67,8 @@ struct Scenario {
|
||||
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
|
||||
hurts: Vec<(i32, usize)>,
|
||||
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
|
||||
/// (tick, mob index, None); the scenario only feeds each animal its food.
|
||||
/// `interact` uses another item on a horse: (tick, mob index, Some(item)).
|
||||
feeds: Vec<(i32, usize, Option<String>)>,
|
||||
/// (tick, mob index); the scenario only feeds each animal its food.
|
||||
feeds: Vec<(i32, usize)>,
|
||||
hand: Option<String>,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
@@ -109,14 +108,7 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
}
|
||||
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s
|
||||
.feeds
|
||||
.push((f[1].parse().unwrap(), f[2].parse().unwrap(), None)),
|
||||
"interact" => s.feeds.push((
|
||||
f[1].parse().unwrap(),
|
||||
f[2].parse().unwrap(),
|
||||
Some(f[3].to_owned()),
|
||||
)),
|
||||
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
@@ -1120,7 +1112,6 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
daylight: (world.day_time.rem_euclid(24000)) < 12000,
|
||||
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
|
||||
game_time: world.game_time,
|
||||
day_time: world.day_time,
|
||||
raining: s.rain,
|
||||
@@ -1217,22 +1208,10 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
events.append(&mut mobs[i].outbox);
|
||||
}
|
||||
for (tick, i, item) in &s.feeds {
|
||||
let (tick, i) = (*tick, *i);
|
||||
for &(tick, i) in &s.feeds {
|
||||
if tick != t || i >= mobs.len() || mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
// Horse.mobInteract's held-item branch (makeMad).
|
||||
if let Some(item) = item {
|
||||
horizon_client::mobs::equine::held_item(
|
||||
&mut mobs[i],
|
||||
item,
|
||||
false,
|
||||
item == "minecraft:golden_dandelion",
|
||||
&mut events,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
{
|
||||
|
||||
@@ -36,14 +36,11 @@ use horizon_client::{
|
||||
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
spawn_box,
|
||||
},
|
||||
spawner::{
|
||||
SpawnDataExt, Spawner, SpawnerAdded, SpawnerEvent, SpawnerExt, SpawnerLevel,
|
||||
SpawnerSpawn,
|
||||
},
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
|
||||
species::{self, Baby},
|
||||
trial_spawner::{
|
||||
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
|
||||
TrialSpawnerExt, TrialSpawnerLevel, TrialState,
|
||||
TrialSpawnerLevel, TrialState,
|
||||
},
|
||||
},
|
||||
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//! A text display's text for differential tests against vanilla's
|
||||
//! `TextDisplayRenderer.submitInner` (tools/test_text_display_render_parity.py):
|
||||
//! decodes each case's entity data from vanilla's wire bytes, lays the text out
|
||||
//! with `horizon_client::text_display` and prints the pose and every quad in
|
||||
//! text units [x, y, z, ARGB], as VanillaTextDisplayRenderOracle does.
|
||||
//!
|
||||
//! usage: text_display_render_fixture RESOURCES INPUTS OUTPUT
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
hud_layout::background_color,
|
||||
text_display::{self, Lines, TextDisplayData},
|
||||
};
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use serde_json::{Value, json};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let [_, resources, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: text_display_render_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let atlas = HudAtlas::build(std::path::Path::new(resources))?;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
|
||||
for line in std::fs::read_to_string(inputs)?.lines() {
|
||||
let input: Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap_or_default();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
return Err("not entity data".into());
|
||||
};
|
||||
let mut d = TextDisplayData::default();
|
||||
d.apply(&packet.values);
|
||||
let lines = Lines::split(&atlas.font, &d.text, d.line_width);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let default_background = background_color(
|
||||
input["chat_only"].as_bool().unwrap_or(false),
|
||||
input["background_opacity"].as_f64().unwrap_or(0.0),
|
||||
0.25,
|
||||
);
|
||||
text_display::draw(&mut batch, &atlas, &d, &lines, default_background);
|
||||
let quads: Vec<Value> = batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let corners: Vec<Value> = [0, 1, 2, 5]
|
||||
.map(|i| {
|
||||
let v = q[i];
|
||||
let [r, g, b, a] = v.color.to_le_bytes().map(u32::from);
|
||||
let argb = (a << 24 | r << 16 | g << 8 | b) as i32;
|
||||
json!([v.pos[0], v.pos[1], v.depth, argb])
|
||||
})
|
||||
.into();
|
||||
corners.into()
|
||||
})
|
||||
.collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
json!({"pose": lines.pose().to_cols_array(), "quads": quads})
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
//! M-11 gate: block assets loaded through the sprite disk cache are byte-identical
|
||||
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
|
||||
//! and writes it), loads again (reads it) and compares the sprite arrays.
|
||||
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
|
||||
use horizon_client::assets::{Assets, RenderAssets};
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::Path,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
fn digest(render: &RenderAssets) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
render.pixels.hash(&mut h);
|
||||
(
|
||||
render.sprite_size,
|
||||
render.sprite_minimum_size,
|
||||
render.texture_layers,
|
||||
)
|
||||
.hash(&mut h);
|
||||
render.sprite_layers.hash(&mut h);
|
||||
render.translucent_layers.hash(&mut h);
|
||||
format!("{:?}", render.sprite_mips).hash(&mut h);
|
||||
for animation in &render.animations {
|
||||
animation.layer.hash(&mut h);
|
||||
for frame in 0..animation.frame_count() {
|
||||
animation.pixels(frame).hash(&mut h);
|
||||
}
|
||||
for tick in 0..400 {
|
||||
format!("{:?}", animation.blend(tick)).hash(&mut h);
|
||||
}
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("usage: sprite_cache_parity ROOT")?;
|
||||
let root = Path::new(&root);
|
||||
let assets = Assets::load(root)?;
|
||||
let _ = std::fs::remove_file(root.join("horizon-sprites.cache"));
|
||||
let start = Instant::now();
|
||||
let cold = digest(&assets.reload(root)?);
|
||||
let cold_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let size = std::fs::metadata(root.join("horizon-sprites.cache"))?.len();
|
||||
let start = Instant::now();
|
||||
let warm = digest(&assets.reload(root)?);
|
||||
let warm_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
println!(
|
||||
"cold={cold:016x} ({cold_ms:.1} ms) warm={warm:016x} ({warm_ms:.1} ms) cache_bytes={size}"
|
||||
);
|
||||
if cold != warm {
|
||||
return Err("cached sprites differ from decoded sprites".into());
|
||||
}
|
||||
println!("PARITY OK");
|
||||
Ok(())
|
||||
}
|
||||
@@ -42,11 +42,11 @@ impl HeightmapState for StateFacts {
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Properties(Arc<Vec<StateFacts>>);
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[cfg(test)]
|
||||
impl Properties {
|
||||
/// Heightmap facts for tests, per state: (air, has a fluid, blocks
|
||||
/// motion, blocks motion but is not leaves). State 0 is minecraft:air.
|
||||
pub fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
|
||||
pub(crate) fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
|
||||
Self(Arc::new(
|
||||
states
|
||||
.iter()
|
||||
@@ -196,7 +196,7 @@ impl Assets {
|
||||
impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
@@ -217,7 +217,6 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
+1
-8
@@ -187,14 +187,7 @@ pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
// `file_type` comes with the listing; `Path::is_dir` is a stat per
|
||||
// entry, kept for links (which it follows).
|
||||
let dir = match e.file_type() {
|
||||
Ok(t) if t.is_symlink() => p.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => p.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().is_some_and(|x| x == "png")
|
||||
&& let Ok(rel) = p.strip_prefix(&base)
|
||||
File renamed without changes.
File renamed without changes.
@@ -83,19 +83,10 @@ fn pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bee occupant's codec and constructors, over the data in `chunk::block_entity`.
|
||||
pub trait OccupantExt: Sized {
|
||||
impl Occupant {
|
||||
/// `Occupant.of`: the bee's saved tag without the ignored tags, staying
|
||||
/// 2400 ticks with nectar, else 600.
|
||||
fn of(tag: Tag) -> Self;
|
||||
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
|
||||
fn create(ticks_in_hive: i32) -> Self;
|
||||
fn has_nectar(&self) -> bool;
|
||||
fn from_nbt(tag: &Tag) -> Option<Self>;
|
||||
fn to_nbt(&self) -> Tag;
|
||||
}
|
||||
impl OccupantExt for Occupant {
|
||||
fn of(mut tag: Tag) -> Self {
|
||||
pub fn of(mut tag: Tag) -> Self {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
strip_ignored(c);
|
||||
}
|
||||
@@ -106,7 +97,8 @@ impl OccupantExt for Occupant {
|
||||
min_ticks_in_hive: if nectar { 2400 } else { 600 },
|
||||
}
|
||||
}
|
||||
fn create(ticks_in_hive: i32) -> Self {
|
||||
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
|
||||
pub fn create(ticks_in_hive: i32) -> Self {
|
||||
Self {
|
||||
entity_data: Tag::compound([("id", Tag::String("minecraft:bee".into()))]),
|
||||
ticks_in_hive,
|
||||
@@ -136,50 +128,13 @@ impl OccupantExt for Occupant {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BeehiveBlockEntity`'s behaviour, over the data in `chunk::block_entity`.
|
||||
pub trait BeehiveExt: Sized {
|
||||
fn full(&self) -> bool;
|
||||
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
|
||||
/// and `flower_pos`.
|
||||
fn from_nbt(tag: &Tag) -> Self;
|
||||
/// `saveAdditional`.
|
||||
fn to_nbt(&self) -> Vec<(&'static str, Tag)>;
|
||||
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
|
||||
/// the bee's flower, or now and then swaps its own for it. The host then
|
||||
/// discards the bee.
|
||||
fn add_occupant(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
tag: Tag,
|
||||
bee_flower: Option<[i32; 3]>,
|
||||
host: &mut impl HiveHost,
|
||||
) -> bool;
|
||||
/// `serverTick`: the bees whose time is up come out, then the hive
|
||||
/// hums now and then while it holds any. `honey` is the hive state's
|
||||
/// honey level as the tick starts (`None` without the property);
|
||||
/// `facing` its front.
|
||||
fn server_tick(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
honey: Option<i32>,
|
||||
host: &mut impl HiveHost,
|
||||
);
|
||||
/// `releaseAllOccupants`: the ids of the bees let out.
|
||||
fn release_all(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
honey: Option<i32>,
|
||||
status: Release,
|
||||
host: &mut impl HiveHost,
|
||||
) -> Vec<u32>;
|
||||
}
|
||||
impl BeehiveExt for Beehive {
|
||||
fn full(&self) -> bool {
|
||||
impl Beehive {
|
||||
pub fn full(&self) -> bool {
|
||||
self.bees.len() == MAX_OCCUPANTS
|
||||
}
|
||||
fn from_nbt(tag: &Tag) -> Self {
|
||||
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
|
||||
/// and `flower_pos`.
|
||||
pub fn from_nbt(tag: &Tag) -> Self {
|
||||
let bees = match tag.get("bees") {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
@@ -193,7 +148,8 @@ impl BeehiveExt for Beehive {
|
||||
flower: pos(tag.get("flower_pos")),
|
||||
}
|
||||
}
|
||||
fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
|
||||
/// `saveAdditional`.
|
||||
pub fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![(
|
||||
"bees",
|
||||
Tag::List(self.bees.iter().map(Occupant::to_nbt).collect()),
|
||||
@@ -203,7 +159,10 @@ impl BeehiveExt for Beehive {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn add_occupant(
|
||||
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
|
||||
/// the bee's flower, or now and then swaps its own for it. The host then
|
||||
/// discards the bee.
|
||||
pub fn add_occupant(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
tag: Tag,
|
||||
@@ -223,7 +182,11 @@ impl BeehiveExt for Beehive {
|
||||
host.sound("block.beehive.enter", corner);
|
||||
true
|
||||
}
|
||||
fn server_tick(
|
||||
/// `serverTick`: the bees whose time is up come out, then the hive
|
||||
/// hums now and then while it holds any. `honey` is the hive state's
|
||||
/// honey level as the tick starts (`None` without the property);
|
||||
/// `facing` its front.
|
||||
pub fn server_tick(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
@@ -260,7 +223,8 @@ impl BeehiveExt for Beehive {
|
||||
host.sound("block.beehive.work", hum);
|
||||
}
|
||||
}
|
||||
fn release_all(
|
||||
/// `releaseAllOccupants`: the ids of the bees let out.
|
||||
pub fn release_all(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -14,19 +14,14 @@ pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
format_tick_duration(ticks, 20.0)
|
||||
}
|
||||
/// `StringUtil.formatTickDuration`: mm:ss, or hh:mm:ss from an hour up, every
|
||||
/// field two digits.
|
||||
pub fn format_tick_duration(ticks: i32, tick_rate: f32) -> String {
|
||||
let secs = (ticks as f32 / tick_rate).floor() as i32;
|
||||
let secs = ticks / 20;
|
||||
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
|
||||
if h > 0 {
|
||||
format!("{h:02}:{m:02}:{s:02}")
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m:02}:{s:02}")
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
}
|
||||
/// `item.minecraft.potion.effect.<name>` style translation key for a potion item.
|
||||
@@ -322,14 +317,10 @@ mod tests {
|
||||
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
|
||||
"item.minecraft.splash_potion.effect.swiftness"
|
||||
);
|
||||
assert_eq!(format_duration(9600), "08:00");
|
||||
assert_eq!(format_duration(450), "00:22");
|
||||
assert_eq!(format_duration(140), "00:07");
|
||||
assert_eq!(format_duration(1800), "01:30");
|
||||
assert_eq!(format_duration(72000), "01:00:00");
|
||||
assert_eq!(format_tick_duration(9600, 40.0), "04:00");
|
||||
assert_eq!(format_duration(9600), "8:00");
|
||||
assert_eq!(format_duration(450), "0:22");
|
||||
let t = tooltip(Some("minecraft:strong_poison"), 1.0, &|_| None);
|
||||
assert_eq!(t[0].0, "poison II (00:21)");
|
||||
assert_eq!(t[0].0, "poison II (0:21)");
|
||||
assert_eq!(POTIONS.len(), 46);
|
||||
}
|
||||
|
||||
@@ -653,57 +653,18 @@ pub fn modify_fov_based_on_death_or_fluid(
|
||||
fov
|
||||
}
|
||||
|
||||
/// `ClientAvatarState`: the walked distance, view bob and cloak of a player.
|
||||
/// `ClientAvatarState`: the walked distance and view bob of a player.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct AvatarBob {
|
||||
pub walk_dist: f32,
|
||||
pub walk_dist_o: f32,
|
||||
pub bob: f32,
|
||||
pub bob_o: f32,
|
||||
/// `xCloak`, `yCloak`, `zCloak`: a point trailing the player, which the cape leans from.
|
||||
pub cloak: [f64; 3],
|
||||
pub cloak_o: [f64; 3],
|
||||
}
|
||||
impl AvatarBob {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`)
|
||||
/// with the player's position then: `moveCloak` eases the cloak a quarter
|
||||
/// of the way there, or puts it there from 10 blocks away.
|
||||
pub fn tick(&mut self, position: [f64; 3]) {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
|
||||
pub fn tick(&mut self) {
|
||||
self.walk_dist_o = self.walk_dist;
|
||||
self.cloak_o = self.cloak;
|
||||
for i in 0..3 {
|
||||
let d = position[i] - self.cloak[i];
|
||||
if d > 10.0 || d < -10.0 {
|
||||
self.cloak[i] = position[i];
|
||||
self.cloak_o[i] = position[i];
|
||||
} else {
|
||||
self.cloak[i] += d * 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AvatarRenderer.extractCapeState`: the cape's `capeFlap`, `capeLean`
|
||||
/// and `capeLean2` (degrees) for the player at `position` (interpolated)
|
||||
/// with its body turned `body_yaw` and its `fallFlyingScale`.
|
||||
pub fn cape(
|
||||
&self,
|
||||
partial: f32,
|
||||
position: [f64; 3],
|
||||
body_yaw: f32,
|
||||
fall_flying_scale: f32,
|
||||
) -> [f32; 3] {
|
||||
let d: [f64; 3] = std::array::from_fn(|i| {
|
||||
lerp_d(f64::from(partial), self.cloak_o[i], self.cloak[i]) - position[i]
|
||||
});
|
||||
let rad = f64::from(body_yaw * DEG_TO_RAD);
|
||||
let (sin, cos) = (f64::from(trig::sin(rad)), f64::from(-trig::cos(rad)));
|
||||
let mut flap = (d[1] as f32 * 10.0).clamp(-6.0, 32.0);
|
||||
let lean = ((d[0] * sin + d[2] * cos) as f32 * 100.0 * (1.0 - fall_flying_scale))
|
||||
.clamp(0.0, 150.0);
|
||||
let lean2 = ((d[0] * cos - d[2] * sin) as f32 * 100.0).clamp(-20.0, 20.0);
|
||||
let bob = self.interpolated_bob(partial);
|
||||
let walk = lerp(partial, self.walk_dist_o, self.walk_dist);
|
||||
flap += trig::sin(f64::from(walk * 6.0)) * 32.0 * bob;
|
||||
[flap, lean, lean2]
|
||||
}
|
||||
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
|
||||
/// with the float horizontal displacement.
|
||||
@@ -834,23 +795,6 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
|
||||
/// cape leans back as the player walks forward (south, body yaw 0).
|
||||
#[test]
|
||||
fn the_cloak_trails_the_player() {
|
||||
let mut a = super::AvatarBob::default();
|
||||
a.tick([0.0, 64.0, 0.0]);
|
||||
assert_eq!((a.cloak, a.cloak_o), ([0.0, 64.0, 0.0], [0.0, 64.0, 0.0]));
|
||||
a.tick([0.0, 64.0, 1.0]);
|
||||
assert_eq!(a.cloak, [0.0, 64.0, 0.25]);
|
||||
let [flap, lean, lean2] = a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 0.0);
|
||||
assert_eq!((flap, lean, lean2), (0.0, 75.0, 0.0));
|
||||
// Falling lifts the flap, at most 32 degrees.
|
||||
assert_eq!(a.cape(1.0, [0.0, 60.0, 0.25], 0.0, 0.0)[0], 32.0);
|
||||
// Gliding takes the lean away.
|
||||
assert_eq!(a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 1.0)[1], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_far_covers_the_cloud_range() {
|
||||
// renderDistance 2 with the default Cloud Distance (128 chunks).
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
-63
@@ -230,7 +230,7 @@ pub struct Info {
|
||||
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Selector {
|
||||
pub current: Option<(Info, i64)>,
|
||||
pub(crate) current: Option<(Info, i64)>,
|
||||
}
|
||||
/// `VibrationSystem.Data`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -278,7 +278,7 @@ pub struct SpawnData {
|
||||
/// Which `SpawnData` is next: one of the potentials (the same object in
|
||||
/// vanilla, so changing it changes the potential) or its own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Next {
|
||||
pub(crate) enum Next {
|
||||
Potential(usize),
|
||||
Own(SpawnData),
|
||||
}
|
||||
@@ -288,7 +288,7 @@ pub struct Spawner {
|
||||
pub spawn_delay: i32,
|
||||
/// `SpawnPotentials`: data and weight.
|
||||
pub potentials: Vec<(SpawnData, i32)>,
|
||||
pub next: Option<Next>,
|
||||
pub(crate) next: Option<Next>,
|
||||
pub min_spawn_delay: i32,
|
||||
pub max_spawn_delay: i32,
|
||||
pub spawn_count: i32,
|
||||
@@ -301,24 +301,6 @@ pub struct Spawner {
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
}
|
||||
impl Default for Spawner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spawn_delay: 20,
|
||||
potentials: Vec::new(),
|
||||
next: None,
|
||||
min_spawn_delay: 200,
|
||||
max_spawn_delay: 800,
|
||||
spawn_count: 4,
|
||||
max_nearby_entities: 6,
|
||||
required_player_range: 16,
|
||||
spawn_range: 4,
|
||||
client_delay: 20,
|
||||
spin: 0.0,
|
||||
o_spin: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TrialSpawnerConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialConfig {
|
||||
@@ -332,26 +314,6 @@ pub struct TrialConfig {
|
||||
pub loot_tables_to_eject: Vec<(String, i32)>,
|
||||
pub items_to_drop_when_ominous: String,
|
||||
}
|
||||
impl Default for TrialConfig {
|
||||
/// `TrialSpawnerConfig.Builder`'s defaults.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spawn_range: 4,
|
||||
total_mobs: 6.0,
|
||||
simultaneous_mobs: 2.0,
|
||||
total_mobs_added_per_player: 2.0,
|
||||
simultaneous_mobs_added_per_player: 1.0,
|
||||
ticks_between_spawn: 40,
|
||||
spawn_potentials: Vec::new(),
|
||||
loot_tables_to_eject: vec![
|
||||
("minecraft:spawners/trial_chamber/consumables".into(), 1),
|
||||
("minecraft:spawners/trial_chamber/key".into(), 1),
|
||||
],
|
||||
items_to_drop_when_ominous:
|
||||
"minecraft:spawners/trial_chamber/items_to_drop_when_ominous".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
|
||||
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -385,28 +347,6 @@ pub struct TrialSpawner {
|
||||
/// and their weights (the rolled counts).
|
||||
pub dispensing: Option<Vec<ItemStack>>,
|
||||
}
|
||||
impl Default for TrialSpawner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
normal: ConfigSlot::default(),
|
||||
ominous: ConfigSlot::default(),
|
||||
target_cooldown_length: 36000,
|
||||
required_player_range: 14,
|
||||
detected_players: Vec::new(),
|
||||
current_mobs: Vec::new(),
|
||||
cooldown_ends_at: 0,
|
||||
next_mob_spawns_at: 0,
|
||||
total_mobs_spawned: 0,
|
||||
next_spawn_data: None,
|
||||
ejecting_loot_table: None,
|
||||
is_ominous: false,
|
||||
has_display: false,
|
||||
spin: 0.0,
|
||||
o_spin: 0.0,
|
||||
dispensing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `VaultConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VaultConfig {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-91
@@ -42,45 +42,6 @@ pub fn recipe_used_key(id: &str) -> String {
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
|
||||
/// translation key of its menu's title when it has no custom name. Copper
|
||||
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
|
||||
/// `ShulkerBoxBlockEntity`.
|
||||
pub fn default_name_key(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
|
||||
n if n.ends_with("copper_chest") => "container.chest",
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
"minecraft:dispenser" => "container.dispenser",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
"minecraft:hopper" => "container.hopper",
|
||||
"minecraft:furnace" => "container.furnace",
|
||||
"minecraft:smoker" => "container.smoker",
|
||||
"minecraft:blast_furnace" => "container.blast_furnace",
|
||||
"minecraft:brewing_stand" => "container.brewing",
|
||||
"minecraft:crafter" => "container.crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
|
||||
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
|
||||
/// name (`CustomName`, a literal), else its default name.
|
||||
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
|
||||
match custom {
|
||||
Some(name) => horizon_protocol::text::literal(name),
|
||||
None => horizon_protocol::text::translatable(default_key, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
|
||||
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
|
||||
/// `FIRST`), else the second's, else `container.chestDouble`.
|
||||
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
|
||||
display_name(first.or(second), "container.chestDouble")
|
||||
}
|
||||
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
@@ -417,58 +378,6 @@ impl BlockEntity {
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
|
||||
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
|
||||
/// chests, trapped chests and copper chests in every weathering state, waxed
|
||||
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
|
||||
/// `EnderChestBlock`s and always single.
|
||||
pub fn is_chest_block(name: &str) -> bool {
|
||||
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
|
||||
}
|
||||
|
||||
/// A chest block's name with its `type` and `facing` properties.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChestHalf<'a> {
|
||||
pub block: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub facing: &'a str,
|
||||
}
|
||||
|
||||
impl ChestHalf<'_> {
|
||||
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
|
||||
/// half: clockwise from the facing for a left half, counter-clockwise for
|
||||
/// a right one. None for a single chest.
|
||||
pub fn connected_step(&self) -> Option<(i32, i32)> {
|
||||
let step = |facing: &str| match facing {
|
||||
"north" => Some((0, -1)),
|
||||
"south" => Some((0, 1)),
|
||||
"west" => Some((-1, 0)),
|
||||
"east" => Some((1, 0)),
|
||||
_ => None,
|
||||
};
|
||||
let (x, z) = step(self.facing)?;
|
||||
// Turning clockwise seen from above takes (x, z) to (-z, x).
|
||||
match self.kind {
|
||||
"left" => Some((-z, x)),
|
||||
"right" => Some((z, -x)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
|
||||
/// it: `other`, the block in this half's connected direction, is its other
|
||||
/// half when it is the same block, a half of the other side and faces the
|
||||
/// same way. Copper chests in different weathering states never join
|
||||
/// (`CopperChestBlock` keeps both halves in one state instead).
|
||||
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
|
||||
is_chest_block(self.block)
|
||||
&& self.kind != "single"
|
||||
&& other.block == self.block
|
||||
&& other.kind != "single"
|
||||
&& other.kind != self.kind
|
||||
&& other.facing == self.facing
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
pub fn create_experience(
|
||||
@@ -330,7 +330,7 @@ pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
|
||||
// separator the build machine used.
|
||||
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
|
||||
let root = manifest
|
||||
.strip_suffix("crates/horizon-client-render")
|
||||
.strip_suffix("crates/horizon-client")
|
||||
.unwrap_or(&manifest);
|
||||
let mut frames: Vec<(String, Option<String>)> = Vec::new();
|
||||
for line in backtrace.lines() {
|
||||
@@ -605,7 +605,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
stack_lines(&bt, true),
|
||||
"horizon_client::crash::debug_crash_tick (crates/horizon-client-render/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
|
||||
"horizon_client::crash::debug_crash_tick (crates/horizon-client/src/crash.rs:359:9)\nwinit::dispatch (winit-0.30.13/src/event_loop.rs:642:56)\nmain\n"
|
||||
);
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -22,7 +22,6 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -129,27 +129,6 @@ fn is_animal(id: &str) -> bool {
|
||||
| "zombie_nautilus"
|
||||
)
|
||||
}
|
||||
/// The `NeutralMob`s whose anger is `Extra::anger`: they write and read
|
||||
/// `anger_end_time` and `angry_at`. The bee keeps its own anger, and a
|
||||
/// piglin's is a brain memory, not `NeutralMob`'s.
|
||||
fn persistent_anger(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::Species as S;
|
||||
matches!(
|
||||
s,
|
||||
S::Wolf | S::IronGolem | S::PolarBear | S::Enderman | S::ZombifiedPiglin
|
||||
)
|
||||
}
|
||||
/// The single player's UUID (`offline_uuid("Player")`): the entity the
|
||||
/// neutral mobs are angry at.
|
||||
fn player_uuid() -> [i32; 4] {
|
||||
let u = horizon_protocol::crypto::offline_uuid("Player");
|
||||
[
|
||||
(u >> 96) as u32 as i32,
|
||||
(u >> 64) as u32 as i32,
|
||||
(u >> 32) as u32 as i32,
|
||||
u as u32 as i32,
|
||||
]
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -180,22 +159,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
// `NeutralMob.addPersistentAngerSaveData`: the anger ends at an
|
||||
// absolute game time, and the target is the entity the mob is angry at.
|
||||
if persistent_anger(m.species()) {
|
||||
put(
|
||||
&mut tag,
|
||||
"anger_end_time",
|
||||
Tag::Long(if m.extra.anger_end > 0 {
|
||||
m.extra.anger_end
|
||||
} else {
|
||||
-1
|
||||
}),
|
||||
);
|
||||
if m.extra.angry_at == Some(crate::mobs::Who::Player) {
|
||||
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
|
||||
}
|
||||
}
|
||||
// `Entity.saveWithoutId` writes the flag only when set.
|
||||
if m.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
@@ -629,13 +592,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"minecraft:wither" => {
|
||||
map.insert("Invul".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
// IronGolem.addAdditionalSaveData.
|
||||
"minecraft:iron_golem" => {
|
||||
map.insert(
|
||||
"PlayerCreated".into(),
|
||||
Tag::Byte(i8::from(m.extra.player_created)),
|
||||
);
|
||||
}
|
||||
"minecraft:warden" => {
|
||||
if let Some(st) = &m.extra.warden {
|
||||
map.insert("listener".into(), st.vibration.to_nbt());
|
||||
@@ -759,13 +715,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
// Enchanted or trimmed gear is saved with its components.
|
||||
// Enchanted gear is saved with its `minecraft:enchantments`.
|
||||
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
|
||||
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
|
||||
&& !g.slots[index].is_empty()
|
||||
{
|
||||
let mut stack = crate::items::ItemStack::new(id, 1);
|
||||
stack.data_mut().enchantments = g.slots[index].clone();
|
||||
stack.data_mut().trim = g.trims[index].clone();
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
@@ -995,12 +950,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.legs = slot("legs");
|
||||
m.extra.feet = slot("feet");
|
||||
}
|
||||
// Their enchantments and trims.
|
||||
// Their enchantments.
|
||||
for (index, key) in crate::mobs::gear::KEYS.iter().enumerate() {
|
||||
if let Some(stack) = eq.get(key).and_then(crate::save::item_from_nbt) {
|
||||
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
|
||||
let trim = stack.data().and_then(|d| d.trim.clone());
|
||||
crate::mobs::gear::set_trim(&mut m, index, trim);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1012,17 +965,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
// Mob.readAdditionalSaveData overrides what the constructor set (copper
|
||||
// golems and elder guardians start persistent).
|
||||
m.persistent = flag(tag, "PersistenceRequired");
|
||||
// `NeutralMob.readPersistentAngerSaveData`: the anger end time and the
|
||||
// target come back with the mob.
|
||||
if persistent_anger(m.species()) {
|
||||
m.extra.anger_end = number(tag.get("anger_end_time")).unwrap_or(-1);
|
||||
if tag
|
||||
.get("angry_at")
|
||||
.is_some_and(|t| uuid_from_tag(Some(t)) == Some(player_uuid()))
|
||||
{
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
}
|
||||
}
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
@@ -1334,10 +1276,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
// IronGolem.readAdditionalSaveData: not player created when absent.
|
||||
if m.species() == crate::mobs::Species::IronGolem {
|
||||
m.extra.player_created = flag(tag, "PlayerCreated");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
@@ -1571,10 +1509,6 @@ pub fn arrow_to_nbt(
|
||||
// Projectile: LeftOwner only when set; Owner as the UUID.
|
||||
map.remove(&"LeftOwner".into());
|
||||
map.remove(&"inBlockState".into());
|
||||
map.remove(&"weapon".into());
|
||||
}
|
||||
if let Some(weapon) = &a.fired_from_weapon {
|
||||
put(&mut tag, "weapon", item_to_nbt(weapon));
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
@@ -1621,7 +1555,6 @@ pub fn arrow_from_nbt(
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
a.pickup_item = item;
|
||||
a.fired_from_weapon = tag.get("weapon").and_then(item_from_nbt);
|
||||
p.stuck = a.in_ground;
|
||||
Some((p, tag.get("inBlockState").cloned()))
|
||||
}
|
||||
@@ -1692,33 +1625,6 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `FireworkRocketEntity.readAdditionalSaveData`: Life, LifeTime (0 when
|
||||
/// absent: a bare `/summon` explodes at once), FireworksItem and
|
||||
/// ShotAtAngle, with `Projectile`'s Owner and LeftOwner.
|
||||
pub struct RocketNbt {
|
||||
pub position: Vec3,
|
||||
pub motion: Vec3,
|
||||
pub life: i32,
|
||||
pub lifetime: i32,
|
||||
pub item: Option<crate::items::ItemStack>,
|
||||
pub angled: bool,
|
||||
pub owner: Option<Who>,
|
||||
pub left_owner: bool,
|
||||
}
|
||||
pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<RocketNbt> {
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
Some(RocketNbt {
|
||||
position: position(tag)?,
|
||||
motion: motion(tag),
|
||||
life: number(tag.get("Life")).unwrap_or(0) as i32,
|
||||
lifetime: number(tag.get("LifeTime")).unwrap_or(0) as i32,
|
||||
item: tag.get("FireworksItem").and_then(item_from_nbt),
|
||||
angled: flag(tag, "ShotAtAngle"),
|
||||
owner: (player.is_some() && owner == player).then_some(Who::Player),
|
||||
left_owner: flag(tag, "LeftOwner"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn falling_block_to_nbt(
|
||||
@@ -3074,49 +2980,3 @@ mod fireball_nbt_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod neutral_anger_tests {
|
||||
use super::*;
|
||||
|
||||
/// #2784: a neutral mob's anger survives the save: the anger ends at an
|
||||
/// absolute game time and the target comes back with it
|
||||
/// (`NeutralMob.addPersistentAngerSaveData` / `readPersistentAngerSaveData`).
|
||||
#[test]
|
||||
fn a_neutral_mobs_anger_survives_the_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
types["minecraft:wolf"].clone(),
|
||||
Vec3::new(0.0, 64.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
m.extra.anger = 400;
|
||||
m.extra.anger_end = 3000;
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
|
||||
assert!(tag.get("angry_at").is_some());
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!(back.extra.anger_end, 3000);
|
||||
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
|
||||
// A mob that is no NeutralMob writes no anger.
|
||||
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None);
|
||||
assert!(tag.get("anger_end_time").is_none());
|
||||
// The anger that is left when the mob comes back.
|
||||
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
|
||||
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -173,16 +173,12 @@ pub fn passes_cobweb(
|
||||
stuck
|
||||
}
|
||||
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
hurt_mobs_with_kind(mobs, position, amount, DamageKind::FallingBlock);
|
||||
}
|
||||
|
||||
pub fn hurt_mobs_with_kind(mobs: &mut [Mob], position: Vec3, amount: f32, kind: DamageKind) {
|
||||
if amount <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let bounds = bounds(position);
|
||||
let source = MobDamage {
|
||||
kind,
|
||||
kind: DamageKind::FallingBlock,
|
||||
attacker: None,
|
||||
from: Some([position.x, position.y, position.z]),
|
||||
};
|
||||
|
||||
File renamed without changes.
@@ -203,13 +203,6 @@ pub struct FogInputs<'a> {
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
/// The camera entity is a spectator (`LavaFogEnvironment`).
|
||||
pub spectator: bool,
|
||||
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
|
||||
pub fire_resistance: bool,
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -279,11 +272,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water {
|
||||
water_vision
|
||||
} else {
|
||||
input.night_vision
|
||||
};
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
color = color.map(|c| c + (c * scale - c) * brighten);
|
||||
@@ -299,16 +288,10 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
};
|
||||
match input.fog_type {
|
||||
FogType::Lava => {
|
||||
// LavaFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else if input.fire_resistance {
|
||||
(0.0, 5.0)
|
||||
} else {
|
||||
(0.25, 1.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
fog.environmental_start = 0.25;
|
||||
fog.environmental_end = 1.0;
|
||||
fog.sky_end = 1.0;
|
||||
fog.cloud_end = 1.0;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
@@ -422,9 +405,6 @@ mod tests {
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
spectator: false,
|
||||
fire_resistance: false,
|
||||
night_vision: 0.0,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -8,7 +8,7 @@
|
||||
//! offsets), styled drawing and StringSplitter-style line wrapping.
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
text::{Span, Style, Text, legacy_format},
|
||||
text::{Style, Text},
|
||||
zip::Zip,
|
||||
};
|
||||
use ab_glyph::{Font as _, ScaleFont as _};
|
||||
@@ -966,83 +966,6 @@ impl Font {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `Font.split`: `StringSplitter.splitLines(text, width, Style.EMPTY)` over
|
||||
/// the text's parts with their `§` codes in. A code styles what follows
|
||||
/// (`§r` back to no style while measuring), and the part a line breaks in
|
||||
/// goes on in the style at the break. Lines keep their codes for drawing.
|
||||
pub fn split_formatted(&self, t: &Text, max_width: f32) -> Vec<Text> {
|
||||
let max_width = max_width.max(1.0);
|
||||
let mut parts: Vec<(Vec<char>, Style)> = t
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| !s.text.is_empty())
|
||||
.map(|s| (s.text.chars().collect(), s.style.clone()))
|
||||
.collect();
|
||||
let mut flat: Vec<char> = parts.iter().flat_map(|p| p.0.iter().copied()).collect();
|
||||
let mut out = Vec::new();
|
||||
let mut force_new_line = false;
|
||||
// One LineBreakFinder per line, over the parts left.
|
||||
while let Some((at, split_style)) = self.line_break(&parts, max_width) {
|
||||
let tail = flat[at];
|
||||
let new_line = tail == '\n';
|
||||
let skip = usize::from(new_line || tail == ' ');
|
||||
force_new_line = new_line;
|
||||
out.push(split_at(&mut parts, at, skip, &split_style));
|
||||
flat.drain(..at + skip);
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
let mut rest = Text::default();
|
||||
rest.spans = parts
|
||||
.into_iter()
|
||||
.map(|(text, style)| Span {
|
||||
text: text.into_iter().collect(),
|
||||
style,
|
||||
})
|
||||
.collect();
|
||||
out.push(rest);
|
||||
} else if force_new_line {
|
||||
out.push(Text::empty());
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `StringSplitter.LineBreakFinder` fed by `StringDecomposer.iterateFormatted`:
|
||||
/// where the parts break (a character index) and the style there.
|
||||
fn line_break(&self, parts: &[(Vec<char>, Style)], max_width: f32) -> Option<(usize, Style)> {
|
||||
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
|
||||
let mut offset = 0;
|
||||
for (contents, part_style) in parts {
|
||||
let mut style = part_style.clone();
|
||||
let mut i = 0;
|
||||
while i < contents.len() {
|
||||
let c = contents[i];
|
||||
if c == '§' {
|
||||
// A `§` that ends its part ends only that part.
|
||||
let Some(&code) = contents.get(i + 1) else {
|
||||
break;
|
||||
};
|
||||
style = legacy_format(&style, code, &Style::default());
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let at = offset + i;
|
||||
if c == '\n' {
|
||||
return Some((at, style));
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some((at, style.clone()));
|
||||
}
|
||||
let advance = self.advance(c, &style);
|
||||
width += advance;
|
||||
if had_visible && width > max_width {
|
||||
return Some(last_space.unwrap_or((at, style)));
|
||||
}
|
||||
had_visible |= advance != 0.0;
|
||||
i += 1;
|
||||
}
|
||||
offset += contents.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
|
||||
/// indented by an unstyled space; one empty line for empty text.
|
||||
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
|
||||
@@ -1136,47 +1059,6 @@ impl Font {
|
||||
}
|
||||
}
|
||||
|
||||
/// `StringSplitter.FlatComponents.splitAt`: the parts before `at` as a line,
|
||||
/// `skip` characters dropped, and the part it ends in restyled as `style`.
|
||||
fn split_at(parts: &mut Vec<(Vec<char>, Style)>, at: usize, skip: usize, style: &Style) -> Text {
|
||||
let mut line = Text::default();
|
||||
let mut push = |text: &[char], style: &Style| {
|
||||
line.spans.push(Span {
|
||||
text: text.iter().collect(),
|
||||
style: style.clone(),
|
||||
})
|
||||
};
|
||||
// Each part done is removed, so the one at hand is always the first.
|
||||
let (mut position, mut in_skip) = (at, false);
|
||||
while let Some((text, part_style)) = parts.first() {
|
||||
let size = text.len();
|
||||
if !in_skip {
|
||||
if position > size {
|
||||
push(text, part_style);
|
||||
parts.remove(0);
|
||||
position -= size;
|
||||
continue;
|
||||
}
|
||||
if position > 0 {
|
||||
push(&text[..position], part_style);
|
||||
}
|
||||
position += skip;
|
||||
in_skip = true;
|
||||
}
|
||||
if position <= size {
|
||||
if position == size {
|
||||
parts.remove(0);
|
||||
} else {
|
||||
parts[0] = (text[position..].to_vec(), style.clone());
|
||||
}
|
||||
break;
|
||||
}
|
||||
parts.remove(0);
|
||||
position -= size;
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1339,37 +1221,6 @@ mod tests {
|
||||
assert!(!font.take_patches().is_empty());
|
||||
}
|
||||
|
||||
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
|
||||
/// part only; the next part is still measured and can break.
|
||||
#[test]
|
||||
fn a_section_sign_ending_a_part_still_breaks_the_next() {
|
||||
let sheet = png(16, 8, |x, _| x < 2);
|
||||
let default = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"section-sign",
|
||||
&[
|
||||
("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 mut text = Text::default();
|
||||
text.spans = ["a§", "a a"]
|
||||
.map(|part| Span {
|
||||
text: part.into(),
|
||||
style: Style::default(),
|
||||
})
|
||||
.into();
|
||||
// 3 + 3 + 4 + 3 is past 12: the line breaks at the space.
|
||||
let lines: Vec<String> = font
|
||||
.split_formatted(&text, 12.0)
|
||||
.iter()
|
||||
.map(Text::plain)
|
||||
.collect();
|
||||
assert_eq!(lines, ["a§a", "a"]);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
+161
-297
@@ -8,7 +8,6 @@ mod allays;
|
||||
mod armor_stands;
|
||||
mod banners;
|
||||
mod beacon;
|
||||
mod beds;
|
||||
mod beehives;
|
||||
mod block_entity_anims;
|
||||
mod block_entity_ticks;
|
||||
@@ -29,7 +28,6 @@ mod chat;
|
||||
mod chiseled_bookshelf;
|
||||
mod client_ambient;
|
||||
mod clipboard;
|
||||
mod code_of_conduct;
|
||||
mod combat;
|
||||
mod command_blocks;
|
||||
mod command_harness;
|
||||
@@ -110,9 +108,12 @@ mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_sounds;
|
||||
mod mod_browser;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_browser_recipes;
|
||||
mod mod_input;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_panel;
|
||||
mod mod_recipes;
|
||||
mod mod_render;
|
||||
mod mods;
|
||||
mod mount_menu;
|
||||
@@ -122,9 +123,6 @@ mod moving_pistons;
|
||||
mod music;
|
||||
mod names;
|
||||
mod native_hooks;
|
||||
mod native_panel;
|
||||
mod native_panel_recipes;
|
||||
mod native_recipes;
|
||||
mod native_shaders;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
@@ -1410,8 +1408,6 @@ struct Game<'a> {
|
||||
server_packs: server_packs::ServerPackState,
|
||||
/// The link a chat click asks to open (`ConfirmLinkScreen`).
|
||||
link_prompt: Option<String>,
|
||||
/// A server's Code of Conduct to accept (game/code_of_conduct.rs).
|
||||
code_of_conduct: Option<code_of_conduct::Prompt>,
|
||||
dialogs: dialogs::DialogState,
|
||||
/// Single player's server while it loads its world (`Minecraft.doWorldLoad`).
|
||||
integrated_starting: Option<integrated::Starting>,
|
||||
@@ -1677,9 +1673,6 @@ struct Game<'a> {
|
||||
/// The head of the bed while sleeping; the visual timer also survives waking.
|
||||
sleeping: Option<[i32; 3]>,
|
||||
sleep_timer: u32,
|
||||
/// The beds players sleep in that this game marked `occupied`, by level
|
||||
/// (game/beds.rs).
|
||||
player_beds: Vec<(Dim, [i32; 3])>,
|
||||
/// `SleepStatus`'s active and sleeping players at the last announcement.
|
||||
sleep_seen: (i64, i64),
|
||||
dim: Dim,
|
||||
@@ -1829,10 +1822,6 @@ struct Game<'a> {
|
||||
extras: extras::ExtrasState,
|
||||
/// Horizon mods (`game/mods.rs`), none running unless turned on.
|
||||
mods: mods::ModsState,
|
||||
/// Every item and recipe, for mods (`game/native_recipes.rs`), made once.
|
||||
recipe_tables: native_recipes::RecipeTables,
|
||||
/// The item panel beside container screens (`game/native_panel.rs`).
|
||||
panel: native_panel::Panel,
|
||||
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
|
||||
native_shaders: native_shaders::NativeShaders,
|
||||
/// Meshed sections waiting for an upload budget (Horizon settings).
|
||||
@@ -2066,12 +2055,6 @@ impl Game<'_> {
|
||||
p.position.z,
|
||||
])
|
||||
}
|
||||
/// Where targeting starts and points: the eyes along the view. Every
|
||||
/// crosshair target (blocks, fluids, entities, mod raycasts) is found
|
||||
/// along this ray, so a tracked controller can stand in for it.
|
||||
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
|
||||
Some((self.eye_position()?, view_vector(self.yaw, self.pitch)))
|
||||
}
|
||||
fn reach(&self) -> f64 {
|
||||
if self.creative { 5.0 } else { 4.5 }
|
||||
}
|
||||
@@ -2146,8 +2129,8 @@ impl Game<'_> {
|
||||
point: hit.point,
|
||||
});
|
||||
}
|
||||
let (origin, dir) = self.aim()?;
|
||||
self.clip_ray(origin, dir, self.reach())
|
||||
let origin = self.eye_position()?;
|
||||
self.clip_ray(origin, view_vector(self.yaw, self.pitch), self.reach())
|
||||
}
|
||||
/// The first block outline hit within `reach` along the unit `dir`.
|
||||
fn clip_ray(&self, origin: [f64; 3], dir: [f64; 3], reach: f64) -> Option<Hit> {
|
||||
@@ -3414,32 +3397,6 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
|
||||
// EnderDragonFight.spawnNewGateway: an always-visible burst
|
||||
// and a sound at the gateway for every nearby player.
|
||||
LevelEvent::LevelEvent { id: 3000, pos, .. } => {
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
self.particles.add_with(
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::ExplosionEmitter,
|
||||
),
|
||||
at,
|
||||
[0.0; 3],
|
||||
true,
|
||||
true,
|
||||
);
|
||||
let random = self.particles.level_random();
|
||||
let pitch = (1.0 + (random.next_float() - random.next_float()) * 0.2) * 0.7;
|
||||
self.audio.play_unshared(
|
||||
"minecraft:block.end_gateway.spawn",
|
||||
Some(at),
|
||||
10.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
// LevelEventHandler 3009: a sniffer egg cracking.
|
||||
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
|
||||
self.sniffer_egg_crack_particles(pos)
|
||||
@@ -3456,14 +3413,11 @@ impl Game<'_> {
|
||||
f64::from(pos.z) + 0.5,
|
||||
]);
|
||||
let sound = match id {
|
||||
1503 => Some(("minecraft:block.end_portal_frame.fill", 1.0)),
|
||||
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
|
||||
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
|
||||
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
|
||||
1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)),
|
||||
1034 => Some(("minecraft:block.chorus_flower.death", 1.0)),
|
||||
1045 => Some(("minecraft:block.pointed_dripstone.land", 2.0)),
|
||||
1052 => Some(("minecraft:block.sulfur_spike.land", 2.0)),
|
||||
1046 => Some((
|
||||
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
|
||||
2.0,
|
||||
@@ -3499,9 +3453,7 @@ impl Game<'_> {
|
||||
let r = self.particles.level_random();
|
||||
2.6 + (r.next_float() - r.next_float()) * 0.8
|
||||
}
|
||||
1045 | 1046 | 1047 | 1052 => {
|
||||
self.particles.level_random().next_float() * 0.1 + 0.9
|
||||
}
|
||||
1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9,
|
||||
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
|
||||
self.world.random.next_float() * 0.1 + 0.9
|
||||
}
|
||||
@@ -3512,25 +3464,21 @@ impl Game<'_> {
|
||||
}
|
||||
_ => 1.0,
|
||||
};
|
||||
if matches!(
|
||||
id,
|
||||
1019 | 1021 | 1029 | 1030 | 1045 | 1052 | 1053 | 1054 | 1503
|
||||
) {
|
||||
// Clients play these sounds from their level events;
|
||||
if matches!(id, 1019 | 1021 | 1029 | 1030 | 1053 | 1054) {
|
||||
// Clients play the anvil sound from its level event;
|
||||
// capturing it also sends a duplicate sound packet.
|
||||
self.audio.play_unshared(name, c, volume, pitch);
|
||||
} else {
|
||||
self.audio.play(name, c, volume, pitch);
|
||||
}
|
||||
}
|
||||
// LevelEventHandler draws smoke after the sound.
|
||||
// LevelEventHandler 1501 and 1502 draw their smoke after the sound.
|
||||
match id {
|
||||
1501 => self.puffs_above_block(
|
||||
pos,
|
||||
horizon_client::particles::ParticleType::LargeSmoke,
|
||||
),
|
||||
1502 => self.torch_burnout_smoke(pos),
|
||||
1503 => self.end_portal_frame_smoke(pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -3556,18 +3504,7 @@ impl Game<'_> {
|
||||
LevelEvent::DropResources { pos, state } => {
|
||||
self.with_level(|_, level, _| level.drop_resources_now(pos, state));
|
||||
}
|
||||
LevelEvent::FallingBlock {
|
||||
pos,
|
||||
state,
|
||||
hurt_per_distance,
|
||||
} => {
|
||||
let mut damage = horizon_client::falling_block::Damage::falling(
|
||||
&self.res.facts().get(state).name,
|
||||
);
|
||||
if let Some(per_distance) = hurt_per_distance {
|
||||
damage.hurt_entities = true;
|
||||
damage.per_distance = per_distance;
|
||||
}
|
||||
LevelEvent::FallingBlock { pos, state } => {
|
||||
let p = Vec3::new(
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y),
|
||||
@@ -3585,7 +3522,9 @@ impl Game<'_> {
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
damage,
|
||||
damage: horizon_client::falling_block::Damage::falling(
|
||||
&self.res.facts().get(state).name,
|
||||
),
|
||||
saved: None,
|
||||
});
|
||||
}
|
||||
@@ -4043,7 +3982,7 @@ impl Game<'_> {
|
||||
hardness != 0.0
|
||||
};
|
||||
if wears && per_block > 0 && max > 0 {
|
||||
let loss = self.wear(&stack, per_block);
|
||||
let loss = self.durability_loss(&stack, per_block);
|
||||
if let Some(held) = self.inventory[self.selected].as_mut() {
|
||||
held.damage += loss;
|
||||
if held.damage >= max {
|
||||
@@ -4215,15 +4154,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Minecraft.startUseItem`: the main hand, then the off hand.
|
||||
fn use_item_as_player(&mut self) -> Result<Option<Hand>> {
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass),
|
||||
// but on a server a spectator's use on a block opens the block's
|
||||
// menu (ServerPlayerGameMode.useItemOn).
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass).
|
||||
if self.spectator() {
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
self.spectator_open_menu(hit.pos);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
// A carrot or warped fungus on a stick boosts the ridden pig or strider.
|
||||
@@ -4774,9 +4706,7 @@ impl Game<'_> {
|
||||
self.tick_crossbow(slot, ticks, &stack);
|
||||
return;
|
||||
}
|
||||
// TridentItem and EnderEyeItem: used until released (an eye's
|
||||
// getUseDuration 0 never counts down to finishUsingItem).
|
||||
if matches!(&*stack.item, "minecraft:trident" | "minecraft:ender_eye") {
|
||||
if &*stack.item == "minecraft:trident" {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
@@ -5342,7 +5272,7 @@ impl Game<'_> {
|
||||
&& !self.creative
|
||||
&& let Some(stack) = self.armor[1].clone()
|
||||
{
|
||||
let loss = self.wear(&stack, glider_wear);
|
||||
let loss = self.durability_loss(&stack, glider_wear);
|
||||
if let Some(elytra) = self.armor[1].as_mut() {
|
||||
elytra.damage += loss;
|
||||
}
|
||||
@@ -5459,10 +5389,6 @@ impl Game<'_> {
|
||||
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
|
||||
MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos),
|
||||
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
|
||||
// Entity.restituteMovementAfterCollisions: GameEvent.BOUNCE.
|
||||
MotionEvent::Bounce { .. } => {
|
||||
self.post_entity_event("minecraft:bounce", horizon_client::mobs::Who::Player)
|
||||
}
|
||||
MotionEvent::SmashLanding {
|
||||
state,
|
||||
position,
|
||||
@@ -5635,7 +5561,6 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
self.announce_sleep_status();
|
||||
self.free_left_beds();
|
||||
horizon_client::spans::time("tick_portal", || self.tick_nether_portal())?;
|
||||
if running {
|
||||
horizon_client::spans::time("tick_end", || self.tick_end())?;
|
||||
@@ -6058,9 +5983,7 @@ impl Game<'_> {
|
||||
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
// The pack prompt replaces the connecting screen (setScreen), but
|
||||
// in game it opens over the world.
|
||||
let mut batch = if (self.server_packs.prompt.is_some()
|
||||
|| self.code_of_conduct.is_some()
|
||||
|| self.dialog_open())
|
||||
let mut batch = if (self.server_packs.prompt.is_some() || self.dialog_open())
|
||||
&& self.player.is_none()
|
||||
{
|
||||
HudBatch::new(size)
|
||||
@@ -6069,7 +5992,6 @@ impl Game<'_> {
|
||||
};
|
||||
self.draw_dialog(&mut batch);
|
||||
self.draw_pack_prompt(&mut batch);
|
||||
self.draw_code_of_conduct(&mut batch);
|
||||
self.draw_link_prompt(&mut batch);
|
||||
// Gui.extractRenderState: the debug overlay comes after the screen
|
||||
// and the toasts, so it is over everything, menus included.
|
||||
@@ -6642,15 +6564,9 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
// A server's furnace menu has the title it was given.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
horizon_client::text::Text::literal(
|
||||
self.container_title(self.translate(key, fallback)),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
let title = self.container_title(self.translate(key, fallback));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
ox + (176.0 - tw) / 2.0,
|
||||
@@ -7344,13 +7260,6 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
} else if self.creative_inventory_open() {
|
||||
// CreativeModeInventoryScreen.slotClicked's THROW on the Survival Inventory tab: the
|
||||
// stack goes to the server (handleCreativeModeItemDrop), the slot follows with the
|
||||
// others (SetCreativeModeSlot).
|
||||
for stack in dropped {
|
||||
self.remote_creative_throw(stack);
|
||||
}
|
||||
}
|
||||
if kind == Kind::Loom {
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
@@ -7550,8 +7459,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(moved) = self.last_quick_moved.clone() {
|
||||
// The creative screen clicks its own menu, on a server too.
|
||||
if self.remote.is_some() && self.screen != Screen::Creative {
|
||||
if self.remote.is_some() {
|
||||
self.remote_quick_move_all(slot, &moved);
|
||||
} else {
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
@@ -8109,7 +8017,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.own_body_in_view()
|
||||
if !self.camera_type.first_person()
|
||||
&& let Some(p) = &self.player
|
||||
&& p.fire_ticks > 0
|
||||
&& !p.spectator
|
||||
@@ -8189,7 +8097,7 @@ impl Game<'_> {
|
||||
// Mobs and their projectiles (a span each).
|
||||
self.mob_geometry(camera, alpha, &mut out);
|
||||
// In first person only a shader pack's shadow pass draws the player.
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
self.mark_player(out.len(), self.camera_type.first_person());
|
||||
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
self.guest_model_geometry(camera, alpha, &mut out);
|
||||
@@ -8594,7 +8502,8 @@ impl Game<'_> {
|
||||
darken
|
||||
}
|
||||
fn mob_hit(&self) -> Option<(usize, f64)> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
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)> = None;
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
@@ -8615,11 +8524,7 @@ impl Game<'_> {
|
||||
/// strength and whether it is a critical hit): the weapon and effects
|
||||
/// scaled by the attack strength, a mace's smash, the critical hit, then
|
||||
/// enchantments.
|
||||
pub(super) fn melee_damage(
|
||||
&mut self,
|
||||
target_kind: &str,
|
||||
living: bool,
|
||||
) -> (f32, f32, bool, bool) {
|
||||
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool, bool) {
|
||||
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
// Riptide's spin replaces the attack damage attribute.
|
||||
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
|
||||
@@ -8629,19 +8534,27 @@ impl Game<'_> {
|
||||
hand_damage + self.effect_attack_bonus()
|
||||
};
|
||||
let scale = self.attack_strength_scale();
|
||||
// getEnchantedDamage(target, damage, source) - damage, taken before the
|
||||
// attack strength scales the base damage.
|
||||
let enchant_bonus = self.enchanted_damage(target_kind, damage) - damage;
|
||||
let mut amount = damage * (0.2 + scale * scale * 0.8);
|
||||
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
|
||||
amount += self.smash_damage_bonus(target_kind);
|
||||
let crit = living && self.critical_attack(scale);
|
||||
let player = self.player.as_ref();
|
||||
// Player.canCriticalAttack: falling, off the ground, not in water,
|
||||
// not blind (isMobilityRestricted), not riding, not sprinting.
|
||||
let falling = player.is_some_and(|p| {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
let crit = scale > 0.9 && falling && !restricted;
|
||||
if crit {
|
||||
amount *= 1.5;
|
||||
}
|
||||
// Enchantment damage (sharpness, smite, bane) scaled by the attack strength.
|
||||
amount += enchant_bonus * scale;
|
||||
(amount, scale, crit, enchant_bonus * scale > 0.0)
|
||||
let magic_boost = self.enchant_damage_bonus(target_kind) * scale;
|
||||
amount += magic_boost;
|
||||
(amount, scale, crit, magic_boost > 0.0)
|
||||
}
|
||||
/// `Player.getAttackStrengthScale(0.5)`.
|
||||
fn attack_strength_scale(&self) -> f32 {
|
||||
@@ -8649,14 +8562,7 @@ impl Game<'_> {
|
||||
((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `Player.isSweepAttack`: a charged, grounded sword hit below the movement limit.
|
||||
pub(super) fn sweep_attack_position(&mut self, scale: f32, crit: bool) -> Option<[f64; 3]> {
|
||||
// Player.getSpeed: the movement speed attribute as it is now (the
|
||||
// field is only refreshed by the local movement step, never for a
|
||||
// server's player).
|
||||
let p = self.player.as_mut()?;
|
||||
if let Ok(speed) = p.attribute("minecraft:movement_speed") {
|
||||
p.speed = speed as f32;
|
||||
}
|
||||
pub(super) fn sweep_attack_position(&self, scale: f32, crit: bool) -> Option<[f64; 3]> {
|
||||
let p = self.player.as_ref()?;
|
||||
let sword = self.held().is_some_and(|s| {
|
||||
self.res
|
||||
@@ -8668,116 +8574,120 @@ impl Game<'_> {
|
||||
if !p.is_sweep_attack(scale, crit, sword) {
|
||||
return None;
|
||||
}
|
||||
// Mth.sin / Mth.cos of yRot * (PI / 180), from its sine table.
|
||||
use horizon_core::geometry::trig;
|
||||
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
|
||||
let yaw = f64::from(self.yaw.to_radians());
|
||||
Some([
|
||||
p.position.x - f64::from(trig::sin(yaw)),
|
||||
p.position.x - yaw.sin(),
|
||||
p.position.y + f64::from(p.dimensions.height) * 0.5,
|
||||
p.position.z + f64::from(trig::cos(yaw)),
|
||||
p.position.z + yaw.cos(),
|
||||
])
|
||||
}
|
||||
/// `Player.doSweepAttack` after a successful hit, in vanilla's order: the
|
||||
/// sweep sound, the victims around `target` (its box, and the mob that
|
||||
/// was hit, when there is one) and then the arc.
|
||||
pub(super) fn sweep_attack(
|
||||
&mut self,
|
||||
at: [f64; 3],
|
||||
from: Option<[f64; 3]>,
|
||||
target: Option<(Aabb, mace::Spared)>,
|
||||
scale: f32,
|
||||
) {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
|
||||
if let Some((area, mob)) = target {
|
||||
self.sweep_damage(area, mob, scale);
|
||||
}
|
||||
let arc = horizon_client::particles::ParticleOptions::simple(
|
||||
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
|
||||
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
|
||||
let sweep = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
);
|
||||
self.particles.add(&arc, at, [0.0; 3]);
|
||||
self.particles.add(&sweep, at, [0.0; 3]);
|
||||
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
|
||||
// the offset is the one from the player to the particle.
|
||||
let origin = from.unwrap_or(at);
|
||||
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
|
||||
self.lan_send_particles(&arc, at, spread, 0.0, 0);
|
||||
self.lan_send_particles(&sweep, at, spread, 0.0, 0);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
|
||||
}
|
||||
/// What `Player.doSweepAttack` deals before the target's enchantment
|
||||
/// bonus and the attack strength: `1 + sweeping_damage_ratio * baseDamage`.
|
||||
pub(super) fn sweep_base_damage(&self, scale: f32) -> f32 {
|
||||
// `baseDamage` of `Player.attack` before a critical hit (a sweep has
|
||||
// none) and the enchantments: the attack damage attribute with the
|
||||
// strength and weakness effects, scaled by the attack strength.
|
||||
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
let base_damage = (hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
|
||||
// The enchantment's attribute modifier on the attribute's 0 to 1 range.
|
||||
let ratio = self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.attribute_add(
|
||||
self.inventory[self.selected].as_ref(),
|
||||
"minecraft:sweeping_damage_ratio",
|
||||
)
|
||||
.clamp(0.0, 1.0) as f32;
|
||||
1.0 + ratio * base_damage
|
||||
}
|
||||
/// The damage and knockback part of `Player.doSweepAttack` after a sweep
|
||||
/// hit on the mob at `target`: every other living mob in its box grown by
|
||||
/// (1, 0.25, 1) and closer than 3 blocks (`distanceToSqr < 9`, in creative
|
||||
/// too), but the player's allies
|
||||
/// (`isAlliedTo`), takes `1 + sweeping_damage_ratio * baseDamage` with the
|
||||
/// weapon's enchantments, scaled by the attack strength, and is pushed 0.4
|
||||
/// along the player's yaw.
|
||||
pub(super) fn sweep_damage(&mut self, target: usize, scale: f32) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
let sweep_damage = self.sweep_base_damage(scale);
|
||||
let hit_box = self.mobs[target]
|
||||
.bounds()
|
||||
.inflate(horizon_core::geometry::Vec3::new(1.0, 0.25, 1.0));
|
||||
let (uuid, allies) = (
|
||||
self.level.player_uuid.or(Some([0, 0, 0, 1])),
|
||||
self.player_allies(),
|
||||
);
|
||||
let nearby: Vec<usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, m)| {
|
||||
*i != target
|
||||
&& m.alive()
|
||||
&& !m.removed
|
||||
&& !allies.contains(&m.id)
|
||||
&& !mace::ally_of_player(m, uuid)
|
||||
&& m.bounds().intersects(hit_box)
|
||||
&& m.position.distance_squared(p.position) < 9.0
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
for i in nearby {
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let kind = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
|
||||
let damage = (sweep_damage + self.enchant_damage_bonus(&kind)) * scale;
|
||||
let source = self.attack_source(from);
|
||||
if self.hurt_by_player(i, damage, source) {
|
||||
self.mobs[i].knockback(0.4, xd, zd);
|
||||
self.last_hurt_by_player
|
||||
.insert(self.mobs[i].id, self.lan_acting);
|
||||
}
|
||||
}
|
||||
// `getEntitiesOfClass(LivingEntity.class)` takes the other players too.
|
||||
let mut sweep = self.player_sweep(sweep_damage, scale);
|
||||
sweep.hit_box = hit_box;
|
||||
self.sweep_other_players(sweep);
|
||||
}
|
||||
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
|
||||
/// and can't be hit critically): knockback when sprinting at full
|
||||
/// strength, then strong or weak, or the sweep around the vehicle's
|
||||
/// box `area`. Before `swing_arm` resets the charge.
|
||||
pub(super) fn vehicle_attack_sounds(&mut self, area: Aabb, magic: Option<i32>) {
|
||||
self.attack_effects(area, None, mace::Spared::Nothing, magic);
|
||||
}
|
||||
/// `Player.attack` after a target that is not a mob took the hit:
|
||||
/// the sprint knockback sound, a living target's critical hit (`living`:
|
||||
/// its entity id, which makes the hit critical when the player falls),
|
||||
/// the sweep around the target's box `area` and the strong or weak sound.
|
||||
pub(super) fn attack_effects(
|
||||
&mut self,
|
||||
area: Aabb,
|
||||
living: Option<i32>,
|
||||
spared: mace::Spared,
|
||||
magic: Option<i32>,
|
||||
) {
|
||||
let scale = self.attack_strength_scale();
|
||||
let full = scale > 0.9;
|
||||
/// strength, then strong or weak. Before `swing_arm` resets the charge.
|
||||
pub(super) fn vehicle_attack_sounds(&mut self) {
|
||||
let full = self.attack_strength_scale() > 0.9;
|
||||
let player = self.player.as_ref();
|
||||
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if full && player.is_some_and(|p| p.sprinting) {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0);
|
||||
}
|
||||
let crit = living.is_some() && self.critical_attack(scale);
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
if let Some(at) = sweep {
|
||||
self.sweep_attack(at, from, Some((area, spared)), scale);
|
||||
}
|
||||
if crit {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.crit", from, 1.0, 1.0);
|
||||
if let Some(id) = living {
|
||||
self.area_critical_hit(id, area, false);
|
||||
}
|
||||
} else if sweep.is_none() {
|
||||
let sound = if full {
|
||||
"minecraft:entity.player.attack.strong"
|
||||
} else {
|
||||
"minecraft:entity.player.attack.weak"
|
||||
};
|
||||
let sound = if full {
|
||||
"minecraft:entity.player.attack.strong"
|
||||
} else {
|
||||
"minecraft:entity.player.attack.weak"
|
||||
};
|
||||
if let Some(at) = self.sweep_attack_position(self.attack_strength_scale(), false) {
|
||||
self.sweep_attack(at, from);
|
||||
} else {
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
}
|
||||
// Player.magicCrit: the enchantments added damage.
|
||||
if let Some(id) = magic {
|
||||
self.area_critical_hit(id, area, true);
|
||||
}
|
||||
}
|
||||
/// `critical_hit` around the box `area` of entity `id` (`magic`: the
|
||||
/// enchanted hit instead of the crit).
|
||||
fn area_critical_hit(&mut self, id: i32, area: Aabb, magic: bool) {
|
||||
let (feet, size) = (area.bottom_center(), area.size());
|
||||
self.critical_hit(
|
||||
self.lan_acting,
|
||||
id,
|
||||
[feet.x, feet.y, feet.z],
|
||||
size.x,
|
||||
size.y,
|
||||
magic,
|
||||
);
|
||||
}
|
||||
/// `Player.canCriticalAttack` for a living target at attack strength
|
||||
/// `scale`: falling, off the ground, not in water, not blind (mobility
|
||||
/// restricted), not riding, not sprinting.
|
||||
pub(super) fn critical_attack(&self, scale: f32) -> bool {
|
||||
let player = self.player.as_ref();
|
||||
let falling = player.is_some_and(|p| {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
scale > 0.9 && falling && !restricted
|
||||
}
|
||||
/// Player.attack with the attack-strength charge, crits and knockback.
|
||||
fn attack(&mut self) -> bool {
|
||||
@@ -8803,11 +8713,16 @@ impl Game<'_> {
|
||||
if self.attack_redirectable() {
|
||||
return true;
|
||||
}
|
||||
let hand_damage = 1.0
|
||||
+ self
|
||||
.held()
|
||||
.and_then(|h| self.res.items().get(&h.item))
|
||||
.map_or(0.0, |i| i.attack_damage);
|
||||
if self.attack_frame()
|
||||
|| self.attack_armor_stand()
|
||||
|| self.attack_cushion()
|
||||
|| self.attack_boat()
|
||||
|| self.attack_cart()
|
||||
|| self.attack_boat(hand_damage)
|
||||
|| self.attack_cart(hand_damage)
|
||||
|| self.attack_dragon()
|
||||
|| self.attack_guest()
|
||||
{
|
||||
@@ -8836,7 +8751,7 @@ impl Game<'_> {
|
||||
pub(super) fn attack_mob(&mut self, index: usize) {
|
||||
let target_kind = self.mobs[index].kind.id.clone();
|
||||
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
|
||||
let (amount, scale, crit, magic) = self.melee_damage(&target_kind, true);
|
||||
let (amount, scale, crit, magic) = self.melee_damage(&target_kind);
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
|
||||
self.attack_ticks = 0;
|
||||
@@ -8948,11 +8863,8 @@ impl Game<'_> {
|
||||
};
|
||||
// Player.playServerSideSound: at the player.
|
||||
if let Some(at) = sweep {
|
||||
let target = (
|
||||
self.mobs[index].bounds(),
|
||||
mace::Spared::Mob(self.mobs[index].id),
|
||||
);
|
||||
self.sweep_attack(at, from, Some(target), scale);
|
||||
self.sweep_attack(at, from);
|
||||
self.sweep_damage(index, scale);
|
||||
} else {
|
||||
self.audio.play(sound, from, 1.0, 1.0);
|
||||
}
|
||||
@@ -8997,8 +8909,10 @@ impl Game<'_> {
|
||||
fn explode(&mut self, center: [f64; 3], radius: f32) -> Result<()> {
|
||||
let interaction = if !self.ui.rules.bool("mob_griefing") {
|
||||
explosion::BlockInteraction::Keep
|
||||
} else if self.ui.rules.bool("mob_explosion_drop_decay") {
|
||||
explosion::BlockInteraction::DestroyWithDecay
|
||||
} else {
|
||||
self.destroy_type("mob_explosion_drop_decay")
|
||||
explosion::BlockInteraction::Destroy
|
||||
};
|
||||
self.server_explode(center, radius, interaction, false)
|
||||
}
|
||||
@@ -9185,7 +9099,6 @@ impl Game<'_> {
|
||||
self.rand_sequences = rand_sequences;
|
||||
self.border = Default::default();
|
||||
self.set_mode_id(level.mode());
|
||||
self.previous_mode = level.previous_game_mode.map(game_mode::mode_from_id);
|
||||
let mut inventory = vec![None; 36];
|
||||
self.armor = [None, None, None, None];
|
||||
self.offhand = None;
|
||||
@@ -9259,7 +9172,6 @@ impl Game<'_> {
|
||||
self.player = None;
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 0;
|
||||
self.player_beds.clear();
|
||||
self.spawn = None;
|
||||
self.respawn_travel = None;
|
||||
self.respawn_config = self
|
||||
@@ -9329,8 +9241,7 @@ impl Game<'_> {
|
||||
};
|
||||
let from = self.dim;
|
||||
let destination = self.switch_level(target, player.position)?;
|
||||
let at = player.position;
|
||||
self.trigger_dimension(from, target, [at.x, at.y, at.z]);
|
||||
self.trigger_dimension(from, target);
|
||||
self.travelling = Some((player, destination));
|
||||
self.end_travel = (target == Dim::End || from == Dim::End) && self.end_portal_travel;
|
||||
Ok(())
|
||||
@@ -9557,7 +9468,6 @@ impl Game<'_> {
|
||||
self.stats.custom("leave_game", 1);
|
||||
}
|
||||
self.lan_close(false);
|
||||
self.free_player_beds()?;
|
||||
self.save_game()?;
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
|
||||
@@ -9590,7 +9500,6 @@ impl Game<'_> {
|
||||
self.player = None;
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 0;
|
||||
self.player_beds.clear();
|
||||
self.clear_entities();
|
||||
self.screen = Screen::Playing;
|
||||
self.clear_lod();
|
||||
@@ -9720,7 +9629,7 @@ impl Game<'_> {
|
||||
.is_some_and(|info| info.equip_damage_on_hurt)
|
||||
&& !(fire && self.res.items().resists_fire(&s, &name)) =>
|
||||
{
|
||||
self.wear(&s, amount)
|
||||
self.durability_loss(&s, amount)
|
||||
}
|
||||
_ => 0,
|
||||
})
|
||||
@@ -9751,7 +9660,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// Voxel traversal that also stops at fluid source blocks (bucket pick).
|
||||
fn pick_fluid(&self) -> Option<Hit> {
|
||||
let (origin, dir) = self.aim()?;
|
||||
let origin = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let mut t = 0.0;
|
||||
let mut last = origin.map(|v| v.floor() as i32);
|
||||
while t <= self.reach() {
|
||||
@@ -10035,7 +9945,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let amount = match self.held().cloned() {
|
||||
Some(held) => self.wear(&held, amount),
|
||||
Some(held) => self.durability_loss(&held, amount),
|
||||
None => amount,
|
||||
};
|
||||
let max = self
|
||||
@@ -10071,7 +9981,7 @@ impl Game<'_> {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let amount = self.wear(&held, amount);
|
||||
let amount = self.durability_loss(&held, amount);
|
||||
let max = self.res.items().get(&held.item).map_or(0, |i| i.max_damage);
|
||||
if amount == 0 || max == 0 {
|
||||
return;
|
||||
@@ -10583,7 +10493,6 @@ impl Game<'_> {
|
||||
let ammo = arrow_slot
|
||||
.and_then(|i| self.stack_in(i).cloned())
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
|
||||
let weapon = self.held().cloned();
|
||||
let (power_bonus, infinity) = self.bow_enchantments(&ammo.item);
|
||||
let kind = horizon_client::projectiles::ProjectileKind::Arrow {
|
||||
base_damage: 2.0 + power_bonus,
|
||||
@@ -10604,7 +10513,6 @@ impl Game<'_> {
|
||||
self.creative || infinity,
|
||||
));
|
||||
a.set_owner(Some(horizon_client::mobs::Who::Player));
|
||||
a.fired_from_weapon = weapon;
|
||||
}
|
||||
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
|
||||
if let Some(seconds) = self.bow_projectile_ignite()
|
||||
@@ -10612,9 +10520,10 @@ impl Game<'_> {
|
||||
{
|
||||
a.ignite_for_seconds(seconds);
|
||||
}
|
||||
let (velocity, on_ground) = self.player.as_ref().map_or((Vec3::ZERO, true), |pl| {
|
||||
(self.known_player_movement(), pl.ground.on_ground)
|
||||
});
|
||||
let (velocity, on_ground) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map_or((Vec3::ZERO, true), |pl| (pl.velocity, pl.ground.on_ground));
|
||||
arrow.shoot_from_rotation(
|
||||
&mut self.world.random,
|
||||
self.pitch,
|
||||
@@ -11083,22 +10992,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
(pos, [pos[0] - d[0], pos[1], pos[2] - d[2]])
|
||||
};
|
||||
// AbstractBedBlock.useWithoutItem: in an occupied bed a villager
|
||||
// asleep there is woken (kickVillagerOutOfBed), else someone else
|
||||
// sleeps in it; the player stays up either way.
|
||||
let head_state = self.world.block(head[0], head[1], head[2]);
|
||||
let occupied = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(head_state)
|
||||
.is_some_and(|s| s.properties.get("occupied").is_some_and(|v| v == "true"));
|
||||
if occupied {
|
||||
if !self.kick_villager_out_of_bed(head) {
|
||||
self.tell_sleeper("block.minecraft.bed.occupied", "This bed is occupied", true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.bedInRange / isReachableBedBlock: within 3 blocks
|
||||
// sideways and 2 vertically of the head or the foot.
|
||||
let reachable = |bed: [i32; 3]| {
|
||||
@@ -11203,7 +11096,6 @@ impl Game<'_> {
|
||||
self.using = None;
|
||||
self.sleeping = Some(head);
|
||||
self.sleep_timer = 0;
|
||||
self.occupy_bed(head);
|
||||
self.stats.custom("sleep_in_bed", 1);
|
||||
self.trigger_player("minecraft:slept_in_bed");
|
||||
self.stats.reset("custom", "minecraft:time_since_rest");
|
||||
@@ -11220,14 +11112,9 @@ impl Game<'_> {
|
||||
fn tick_sleep(&mut self) {
|
||||
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
if let Some(head) = self.sleeping {
|
||||
// LivingEntity.tick's checkBedExists: a sleeper whose bed is
|
||||
// gone gets up.
|
||||
let bed = self.world.block(head[0], head[1], head[2]);
|
||||
let bed_gone = !self.res.facts().get(bed).name.ends_with("_bed");
|
||||
// Vanilla has no Shift wake-up: the chat screen's capitals must
|
||||
// not get the sleeper up.
|
||||
if (self.screen != Screen::Chat && self.keys.contains(&KeyCode::ShiftLeft))
|
||||
|| bed_gone
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
@@ -11568,7 +11455,6 @@ impl Game<'_> {
|
||||
let level = &mut self.level;
|
||||
level.creative = self.creative;
|
||||
level.game_mode = mode;
|
||||
level.previous_game_mode = self.previous_mode.map(game_mode::mode_id_of);
|
||||
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
|
||||
level.difficulty =
|
||||
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
|
||||
@@ -11889,9 +11775,8 @@ impl Game<'_> {
|
||||
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
|
||||
// player who may build. Adventure players only get one with a CAN_BREAK or
|
||||
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
|
||||
// components nor opens menus for spectators, so those never show it. An open
|
||||
// screen does not hide it: vanilla checks the HUD and the game mode, not the screen.
|
||||
let outline = if !self.ui.hide_gui && self.may_build() {
|
||||
// components nor opens menus for spectators, so those never show it.
|
||||
let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
|
||||
self.pick()
|
||||
.map(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
@@ -13045,8 +12930,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
let d = scaled(raw_y) as f32;
|
||||
if self.dialog_open() {
|
||||
self.dialog_scroll(d);
|
||||
} else if self.code_of_conduct.is_some() {
|
||||
self.code_of_conduct_scroll(d);
|
||||
} else if let Some(title) = &mut self.title
|
||||
&& self.screen != Screen::Win
|
||||
{
|
||||
@@ -13122,11 +13005,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.link_prompt_click();
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.code_of_conduct.is_some() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.code_of_conduct_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. }
|
||||
if self.server_packs.prompt.is_some() =>
|
||||
{
|
||||
@@ -13389,8 +13267,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.answer_link_prompt(false);
|
||||
}
|
||||
}
|
||||
// CodeOfConductScreen.shouldCloseOnEsc is false: no key leaves the screen.
|
||||
WindowEvent::KeyboardInput { .. } if self.code_of_conduct.is_some() => {}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
|
||||
@@ -13830,14 +13706,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
// The world a backup made before its upgrade opens (EditWorldScreen's callback).
|
||||
if let Some(folder) = self.title.as_mut().and_then(|t| t.take_open_request())
|
||||
&& let Err(error) = self.open_world(&folder, None, None, false, None)
|
||||
{
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.tick_server_packs();
|
||||
self.poll_control(event_loop);
|
||||
if self.pending_save.is_some() {
|
||||
@@ -13939,7 +13807,6 @@ impl<'a> Game<'a> {
|
||||
pending_pack_reload: false,
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
code_of_conduct: None,
|
||||
dialogs: Default::default(),
|
||||
integrated_starting: None,
|
||||
world_renderer_ready: false,
|
||||
@@ -14117,7 +13984,6 @@ impl<'a> Game<'a> {
|
||||
lightning_seen: [0; 2],
|
||||
sleeping: None,
|
||||
sleep_timer: 0,
|
||||
player_beds: Vec::new(),
|
||||
sleep_seen: (0, 0),
|
||||
dim: Dim::Overworld,
|
||||
world_root: PathBuf::new(),
|
||||
@@ -14222,8 +14088,6 @@ impl<'a> Game<'a> {
|
||||
frame_graph: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
recipe_tables: Default::default(),
|
||||
panel: Default::default(),
|
||||
native_shaders: Default::default(),
|
||||
sky_darken_memo: Default::default(),
|
||||
persist: Default::default(),
|
||||
|
||||
@@ -74,22 +74,6 @@ impl Toast {
|
||||
(fully_for as f64 / (5000.0 * multiplier / n).max(1.0) % n) as usize
|
||||
}
|
||||
}
|
||||
/// Where the player's fall, lava ride and Nether trip began, the blast that
|
||||
/// last hit the player, and the effects the last tick saw (`ServerPlayer`'s
|
||||
/// fields of the same names). A guest keeps their own, as `levitation` does.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct TravelMarks {
|
||||
/// `startingToFallPosition`.
|
||||
pub(super) fall: Option<[f64; 3]>,
|
||||
/// `currentExplosionImpactPos`, and whether a wind charge made the blast.
|
||||
pub(super) blast: Option<([f64; 3], bool)>,
|
||||
/// `enteredLavaOnVehiclePosition`.
|
||||
pub(super) lava_ride: Option<[f64; 3]>,
|
||||
/// `enteredNetherPosition`.
|
||||
pub(super) nether: Option<[f64; 3]>,
|
||||
/// The (effect, amplifier) pairs `EffectsChangedTrigger` last saw.
|
||||
pub(super) effects: Vec<(&'static str, i32)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct AdvState {
|
||||
pub(super) tree: Arc<Tree>,
|
||||
@@ -105,8 +89,6 @@ pub(super) struct AdvState {
|
||||
pub(super) last_inventory: u64,
|
||||
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
|
||||
pub(super) levitation: Option<([f64; 3], u64)>,
|
||||
/// The fall, lava ride, Nether trip, blast and effects `tick_marks` follows.
|
||||
pub(super) marks: TravelMarks,
|
||||
/// While the game acts for a network player (`as_guest`): the
|
||||
/// advancements whose progress changed (`progressChanged`), the recipes
|
||||
/// unlocked and the announcements, for their client and everyone's.
|
||||
@@ -711,7 +693,6 @@ impl Game<'_> {
|
||||
if let Some((start, since)) = self.adv.levitation {
|
||||
self.trigger_levitation(start, (self.ticks - since) as i64);
|
||||
}
|
||||
self.tick_marks();
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:location", |c, w| {
|
||||
@@ -801,162 +782,6 @@ impl Game<'_> {
|
||||
&& adv::range_i(&c["duration"], duration)
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.tick`: `trackStartFallingPosition` and the end of a fall
|
||||
/// (`resetFallDistance`), `trackEnteredOrExitedLavaOnVehicle`, the way back
|
||||
/// from the Nether, and `onEffectAdded`, `onEffectUpdated` and
|
||||
/// `onEffectsRemoved`, which are seen as each tick ends.
|
||||
fn tick_marks(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let falling = p.fall_distance > 0.0;
|
||||
let dead = p.health.dead_or_dying();
|
||||
let lava = self
|
||||
.riding
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.is_some_and(|m| m.fluids.in_lava);
|
||||
match (falling, self.adv.marks.fall) {
|
||||
(true, None) => {
|
||||
self.adv.marks.fall = Some(at);
|
||||
// The blast a wind charge made is spent by the fall, and counts
|
||||
// only if the player was above where it hit.
|
||||
if let Some((impact, wind_charge)) = self.adv.marks.blast.take()
|
||||
&& wind_charge
|
||||
&& impact[1] <= at[1]
|
||||
{
|
||||
self.trigger_fall_after_explosion(impact);
|
||||
}
|
||||
}
|
||||
// `resetFallDistance`: a fall ends by landing, climbing or water.
|
||||
(false, Some(start)) => {
|
||||
self.adv.marks.fall = None;
|
||||
if !dead {
|
||||
self.trigger_distance("minecraft:fall_from_height", start);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match (lava, self.adv.marks.lava_ride) {
|
||||
(true, None) => self.adv.marks.lava_ride = Some(at),
|
||||
(true, Some(start)) => self.trigger_distance("minecraft:ride_entity_in_lava", start),
|
||||
(false, Some(_)) => self.adv.marks.lava_ride = None,
|
||||
(false, None) => {}
|
||||
}
|
||||
// Back in the Overworld from the Nether: measured from where it was entered.
|
||||
if let Some(start) = self.adv.marks.nether
|
||||
&& self.dim == Dim::Overworld
|
||||
{
|
||||
self.adv.marks.nether = None;
|
||||
self.trigger_distance("minecraft:nether_travel", start);
|
||||
}
|
||||
let effects: Vec<(&'static str, i32)> = self
|
||||
.potions
|
||||
.effects
|
||||
.list
|
||||
.iter()
|
||||
.map(|e| (e.id, e.amplifier))
|
||||
.collect();
|
||||
if effects != self.adv.marks.effects {
|
||||
self.adv.marks.effects = effects;
|
||||
self.trigger_effects_changed(None);
|
||||
}
|
||||
}
|
||||
/// `DistanceTrigger`: the player's `player` condition, `start_position`
|
||||
/// at `start`, and the `distance` from `start` to the player.
|
||||
fn trigger_distance(&mut self, name: &str, start: [f64; 3]) {
|
||||
let me = self.player_entity();
|
||||
self.trigger(name, move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::location(&c["start_position"], start, w)
|
||||
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
|
||||
});
|
||||
}
|
||||
/// `FallAfterExplosionTrigger` for a wind charge's blast that hit the
|
||||
/// player at `impact`, measured to where the player is now.
|
||||
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3]) {
|
||||
let me = self.player_entity();
|
||||
let cause = EntityView {
|
||||
kind: "minecraft:wind_charge".into(),
|
||||
position: impact,
|
||||
..EntityView::default()
|
||||
};
|
||||
self.trigger("minecraft:fall_after_explosion", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let cctx = adv::Entities {
|
||||
this: Some(&cause),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::location(&c["start_position"], impact, w)
|
||||
&& (c["distance"].is_null() || adv::distance(&c["distance"], impact, me.position))
|
||||
&& adv::conditions(&c["cause"], &cctx, w)
|
||||
});
|
||||
}
|
||||
/// `EffectsChangedTrigger`: the player's `effects`, and for an effect that
|
||||
/// `source` gave, that entity (`source`).
|
||||
pub(super) fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:effects_changed", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let effects = c["effects"].is_null()
|
||||
|| adv::entity(
|
||||
&serde_json::json!({ "minecraft:effects": c["effects"] }),
|
||||
&me,
|
||||
w,
|
||||
me.position,
|
||||
);
|
||||
let source = c["source"].is_null()
|
||||
|| source.as_ref().is_some_and(|s| {
|
||||
let sctx = adv::Entities {
|
||||
this: Some(s),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["source"], &sctx, w)
|
||||
});
|
||||
adv::conditions(&c["player"], &pctx, w) && effects && source
|
||||
});
|
||||
}
|
||||
/// `ItemDurabilityTrigger`: a player's item takes damage (`ItemStack.applyDamage`),
|
||||
/// `stack` as it is before the damage. The durability and delta ranges are not
|
||||
/// read: no advancement in the data uses them.
|
||||
pub(super) fn trigger_item_durability(&mut self, stack: &ItemStack) {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let me = self.player_entity();
|
||||
let item = item_view(stack);
|
||||
self.trigger("minecraft:item_durability_changed", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& (c["item"].is_null() || adv::item(&c["item"], &item, w))
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
|
||||
/// what the player looks at (`PlayerPredicate.lookingAt`).
|
||||
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
|
||||
@@ -1408,20 +1233,12 @@ impl Game<'_> {
|
||||
&& test("child", child.as_ref())
|
||||
});
|
||||
}
|
||||
/// `ChangeDimensionTrigger`, and `ServerPlayer.enteredNetherPosition`: the
|
||||
/// Overworld position the player went into the Nether from (`at`) is kept
|
||||
/// for the way back, and any other trip drops it.
|
||||
pub(super) fn trigger_dimension(&mut self, from: Dim, to: Dim, at: [f64; 3]) {
|
||||
let (from_id, to_id) = (from.id().to_owned(), to.id().to_owned());
|
||||
/// `ChangeDimensionTrigger`.
|
||||
pub(super) fn trigger_dimension(&mut self, from: Dim, to: Dim) {
|
||||
let (from, to) = (from.id().to_owned(), to.id().to_owned());
|
||||
self.trigger("minecraft:changed_dimension", move |c, _| {
|
||||
c["from"].as_str().is_none_or(|f| f == from_id)
|
||||
&& c["to"].as_str().is_none_or(|t| t == to_id)
|
||||
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
|
||||
});
|
||||
if from == Dim::Overworld && to == Dim::Nether {
|
||||
self.adv.marks.nether = Some(at);
|
||||
} else if !(from == Dim::Nether && to == Dim::Overworld) {
|
||||
self.adv.marks.nether = None;
|
||||
}
|
||||
}
|
||||
/// `ToastManager.SLOT_COUNT`.
|
||||
pub(super) const TOAST_SLOTS: usize = 5;
|
||||
|
||||
@@ -597,9 +597,6 @@ impl Game<'_> {
|
||||
Mode::Delete { world } => {
|
||||
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::BackupBeforeUpgrade { world } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Error { message, .. } => {
|
||||
json!({ "mode": "world-operation-error", "message": message })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! falling, `interactAt` equipment swaps by clicked height, the two-hit break
|
||||
//! of `hurtServer` (creative breaks at once), and `ArmorStandRenderer` with
|
||||
//! the default pose, the hit wobble and humanoid armor layers.
|
||||
use super::Game;
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::{
|
||||
entities::ArmorStand,
|
||||
items::ItemStack,
|
||||
@@ -153,13 +153,7 @@ impl Game<'_> {
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers(
|
||||
[
|
||||
(e.head.as_deref(), e.armor[0].as_ref()),
|
||||
(e.chest.as_deref(), e.armor[1].as_ref()),
|
||||
(e.legs.as_deref(), e.armor[2].as_ref()),
|
||||
(e.feet.as_deref(), e.armor[3].as_ref()),
|
||||
]
|
||||
.map(|(item, stack)| item.map(|i| (i, stack))),
|
||||
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
|
||||
)
|
||||
}
|
||||
/// The layers of the armour items `worn` (with their stacks when known,
|
||||
@@ -226,7 +220,6 @@ impl Game<'_> {
|
||||
.armor
|
||||
.iter()
|
||||
.chain(guests.flat_map(|g| g.armor.iter()))
|
||||
.chain(self.mobs.iter().flat_map(|m| m.extra.armor.iter()))
|
||||
.zip(
|
||||
["helmet", "chestplate", "leggings", "boots"]
|
||||
.into_iter()
|
||||
@@ -374,7 +367,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// The armor stand under the crosshair, its distance and the hit point.
|
||||
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
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, a) in self.armor_stands.iter().enumerate() {
|
||||
@@ -510,25 +504,6 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// The stand's box and id, before the hit may remove it.
|
||||
let (area, id) = (self.armor_stands[i].bounds(), self.armor_stands[i].id);
|
||||
let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true);
|
||||
let hurt = self.punch_armor_stand(i);
|
||||
if hurt {
|
||||
self.attack_effects(
|
||||
area,
|
||||
Some(id as i32),
|
||||
super::mace::Spared::Stand(id),
|
||||
magic.then_some(id as i32),
|
||||
);
|
||||
self.swing_arm();
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
|
||||
/// creative player breaks it, otherwise the first hit wobbles it and a
|
||||
/// second within five ticks breaks it.
|
||||
pub(super) fn punch_armor_stand(&mut self, i: usize) -> bool {
|
||||
let time = self.world.game_time;
|
||||
let at = self.armor_stands[i].position;
|
||||
let at = [at.x, at.y, at.z];
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
//! Beds' `occupied` (`BedBlock.OCCUPIED`): the head of a bed someone sleeps
|
||||
//! in, which the foot copies (`updateShape`). `LivingEntity.startSleeping`
|
||||
//! sets it and `stopSleeping` clears it. A villager's come with its mob
|
||||
//! events (`MobEvent::BedOccupied`); a player's bed is set when it lies
|
||||
//! down (`try_sleep`) and freed in the level's tick once nobody sleeps in it
|
||||
//! any more, whichever way they got up: waking, the Leave Bed button, death,
|
||||
//! a broken bed, or leaving the game.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::Result;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `setBlockAndUpdate(head, state.setValue(OCCUPIED, occupied))`, when
|
||||
/// the block at `head` is still a bed and not already so.
|
||||
pub(super) fn set_bed_occupied(&mut self, head: [i32; 3], occupied: bool) {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(head[0], head[1], head[2]))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let value = if occupied { "true" } else { "false" };
|
||||
if state.properties.get("occupied").is_none_or(|v| v == value) {
|
||||
return;
|
||||
}
|
||||
state.properties.insert("occupied".into(), value.into());
|
||||
let Some(id) = registry.id(&state) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = self.level_set(head, id) {
|
||||
log::warn!("could not mark the bed at {head:?} occupied={value}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.startSleeping` for the player in place (the host, or
|
||||
/// a guest during its turn): the bed it lies in is occupied.
|
||||
pub(super) fn occupy_bed(&mut self, head: [i32; 3]) {
|
||||
self.set_bed_occupied(head, true);
|
||||
if !self.player_beds.contains(&(self.dim, head)) {
|
||||
self.player_beds.push((self.dim, head));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a player in the level in place still sleeps in `head`.
|
||||
fn player_in_bed(&self, head: [i32; 3]) -> bool {
|
||||
self.sleeping == Some(head)
|
||||
|| self.lan.as_ref().is_some_and(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| g.bed == Some(head) && g.dimension == self.dim.id())
|
||||
})
|
||||
}
|
||||
|
||||
/// `LivingEntity.stopSleeping` for the players who got up: the beds of
|
||||
/// this level nobody sleeps in any more are free again. Runs in each
|
||||
/// level's tick, after the players' packets and sleep ticks, never inside
|
||||
/// a guest's turn (where the hub is taken out of `Game`).
|
||||
pub(super) fn free_left_beds(&mut self) {
|
||||
let dim = self.dim;
|
||||
let left: Vec<[i32; 3]> = self
|
||||
.player_beds
|
||||
.iter()
|
||||
.filter(|(d, head)| *d == dim && !self.player_in_bed(*head))
|
||||
.map(|(_, head)| *head)
|
||||
.collect();
|
||||
for head in left {
|
||||
self.player_beds.retain(|b| *b != (dim, head));
|
||||
self.set_bed_occupied(head, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The levels with a bed a player marked occupied: they tick until the
|
||||
/// bed is freed.
|
||||
pub(super) fn player_bed_levels(&self) -> impl Iterator<Item = Dim> + '_ {
|
||||
self.player_beds.iter().map(|(d, _)| *d)
|
||||
}
|
||||
|
||||
/// `PlayerList.removeAll` before the server's last save: every player
|
||||
/// leaves (`ServerPlayer.disconnect` stops its sleep), so no bed is
|
||||
/// saved occupied by a player who is not in it when the world opens
|
||||
/// again. Each bed is freed in its own level.
|
||||
pub(super) fn free_player_beds(&mut self) -> Result<()> {
|
||||
if self.player_beds.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let back = self.dim;
|
||||
for (dim, head) in std::mem::take(&mut self.player_beds) {
|
||||
self.enter_level(None, dim)?;
|
||||
// The in-frame game has only the level it is in.
|
||||
if self.dim == dim {
|
||||
self.set_bed_occupied(head, false);
|
||||
}
|
||||
}
|
||||
self.enter_level(None, back)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
beehive::{self, Beehive, BeehiveExt, HiveHost, Release},
|
||||
beehive::{self, Beehive, HiveHost, Release},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Who, bee},
|
||||
|
||||
@@ -658,30 +658,16 @@ impl Game<'_> {
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(boat) = self.boat_to_place(stack) else {
|
||||
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
self.next_entity += 1;
|
||||
let key = (
|
||||
boat.position.x.floor() as i32 >> 4,
|
||||
boat.position.z.floor() as i32 >> 4,
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The boat `BoatItem.use` would place for `stack` (its SUCCESS), or
|
||||
/// None when it aims at nothing (PASS) or the spot is taken (FAIL).
|
||||
pub(super) fn boat_to_place(&self, stack: &ItemStack) -> Option<Boat> {
|
||||
let (wood, chest) = any_boat_wood(&stack.item)?;
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return None;
|
||||
return false;
|
||||
}
|
||||
let (eye, dir) = self.aim()?;
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return false;
|
||||
};
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
@@ -721,7 +707,7 @@ impl Game<'_> {
|
||||
}
|
||||
(Some(b), None) => b.0,
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return None,
|
||||
(None, None) => return false,
|
||||
};
|
||||
let mut boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
@@ -745,12 +731,21 @@ impl Game<'_> {
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return None;
|
||||
return false;
|
||||
}
|
||||
Some(boat)
|
||||
self.next_entity += 1;
|
||||
let key = (hit[0].floor() as i32 >> 4, hit[2].floor() as i32 >> 4);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
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)> = None;
|
||||
for (i, b) in self.boats.iter().enumerate() {
|
||||
@@ -816,19 +811,15 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` from the player's attack.
|
||||
pub(super) fn attack_boat(&mut self) -> bool {
|
||||
pub(super) fn attack_boat(&mut self, damage: f32) -> bool {
|
||||
let Some((i, d)) = self.boat_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
let area = self.boats[i].bounds();
|
||||
// Player.attack's full damage against the boat's entity type.
|
||||
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
|
||||
let (damage, _, _, magic) = self.melee_damage(&kind, false);
|
||||
self.vehicle_attack_sounds();
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -95,11 +95,6 @@ impl Game<'_> {
|
||||
/// `BookViewScreen.mouseClicked`: the click event of the text under the
|
||||
/// cursor on the page.
|
||||
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
|
||||
self.book_style_at(page, left)?.click
|
||||
}
|
||||
/// `ActiveTextCollector` over the page: the style of the character
|
||||
/// under the cursor, for its click and hover events.
|
||||
fn book_style_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::Style> {
|
||||
let (mx, my) = self.cursor;
|
||||
let row = ((my - 32.0) / 9.0).floor();
|
||||
if row < 0.0 || row >= LINES as f32 {
|
||||
@@ -114,7 +109,7 @@ impl Game<'_> {
|
||||
for (c, style) in line.chars() {
|
||||
x -= self.gui.atlas.font.advance(c, style);
|
||||
if x < 0.0 {
|
||||
return Some(style.clone());
|
||||
return style.click.clone();
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -144,22 +139,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
// closeContainerOnServer, then clickCommandAction with no screen
|
||||
// after the command: the book closes and the command runs.
|
||||
ClickEvent::RunCommand(command) => {
|
||||
self.close_book();
|
||||
self.click_command(&command);
|
||||
ClickEvent::RunCommand(command) => self.click_command(&command),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
// defaultHandleClickEvent: the chat screen takes it over.
|
||||
if self.player.is_some() {
|
||||
self.close_book();
|
||||
self.open_chat(&command);
|
||||
}
|
||||
}
|
||||
// Screen.defaultHandleClickEvent: copied, the book stays open.
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
// defaultHandleGameClickEvent: the dialog opens over the book.
|
||||
// BookViewScreen.handleClickEvent: the rest is
|
||||
// Screen.defaultHandleGameClickEvent's.
|
||||
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
|
||||
// SuggestCommand's insertText does nothing on a book; a server's
|
||||
// text never opens a file.
|
||||
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
@@ -1319,16 +1314,6 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
|
||||
// show_text hover event, as the chat shows it.
|
||||
if let Some(hover) = pages
|
||||
.get(*page)
|
||||
.and_then(|t| self.book_style_at(t, left))
|
||||
.and_then(|s| s.hover)
|
||||
{
|
||||
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
|
||||
self.draw_tooltip_lines(batch, &lines, mx, my);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ impl MapData {
|
||||
m.unlimited = self.unlimited;
|
||||
m
|
||||
}
|
||||
/// `MapItemSavedData.locked`: the same pixels and banner markers, locked.
|
||||
/// `MapItemSavedData.locked`: the same pixels, locked.
|
||||
pub(super) fn locked_copy(&self) -> MapData {
|
||||
let mut m = MapData::fresh(
|
||||
f64::from(self.center[0]),
|
||||
@@ -165,7 +165,6 @@ impl MapData {
|
||||
);
|
||||
m.center = self.center;
|
||||
m.colors = self.colors.clone();
|
||||
m.banners = self.banners.clone();
|
||||
m.tracking = self.tracking;
|
||||
m.unlimited = self.unlimited;
|
||||
m.locked = true;
|
||||
|
||||
@@ -12,11 +12,7 @@ use horizon_client::{
|
||||
screen_draw::Measure,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The tree the chat parses and completes with: a server's, once it has
|
||||
/// sent one (ClientPacketListener.commands), else the game's own.
|
||||
@@ -127,64 +123,6 @@ impl UiState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids `build_registries` lists from the prepared resources' folders.
|
||||
/// The folders don't change while the game runs, so each root is read once
|
||||
/// instead of at every world opening (thousands of files).
|
||||
struct ResourceIds {
|
||||
recipes: Vec<String>,
|
||||
damage_types: Vec<String>,
|
||||
loot_tables: Vec<String>,
|
||||
templates: Vec<String>,
|
||||
}
|
||||
static RESOURCE_IDS: Mutex<Vec<(PathBuf, Arc<ResourceIds>)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
let mut cache = RESOURCE_IDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, ids)) = cache.iter().find(|(cached, _)| cached == root) {
|
||||
return ids.clone();
|
||||
}
|
||||
let data = root.join("data/minecraft");
|
||||
let ids = Arc::new(ResourceIds {
|
||||
recipes: ids_under(&data.join("recipe"), "json"),
|
||||
damage_types: ids_under(&data.join("damage_type"), "json"),
|
||||
loot_tables: ids_under(&data.join("loot_table"), "json"),
|
||||
templates: ids_under(&root.join("worldgen/structure/minecraft"), "nbt"),
|
||||
});
|
||||
cache.push((root.to_owned(), ids.clone()));
|
||||
ids
|
||||
}
|
||||
|
||||
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_owned()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
// The listing's own type, without another look at each file; a
|
||||
// link is followed, as `Path::is_dir` does.
|
||||
let is_dir = e
|
||||
.file_type()
|
||||
.is_ok_and(|t| t.is_dir() || (t.is_symlink() && path.is_dir()));
|
||||
if is_dir {
|
||||
stack.push(path);
|
||||
} else if path.extension().and_then(|x| x.to_str()) == Some(extension)
|
||||
&& let Ok(rel) = path.strip_prefix(root)
|
||||
{
|
||||
out.push(format!(
|
||||
"minecraft:{}",
|
||||
rel.with_extension("").to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
|
||||
pub(super) fn allow_commands(&self) -> bool {
|
||||
@@ -208,11 +146,61 @@ impl Game<'_> {
|
||||
let permission = owner_permission(self.allow_commands());
|
||||
let reg = &mut self.ui.registries;
|
||||
reg.permission = permission;
|
||||
let ids = resource_ids(&self.options.resources);
|
||||
self.ui.registries.recipes = ids.recipes.clone();
|
||||
self.ui.registries.damage_types = ids.damage_types.clone();
|
||||
self.ui.registries.loot_tables = ids.loot_tables.clone();
|
||||
self.ui.registries.templates = ids.templates.clone();
|
||||
{
|
||||
let dir = |sub: &str| -> Vec<String> {
|
||||
let root = self.options.resources.join("data/minecraft").join(sub);
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().and_then(|x| x.to_str()) == Some("json")
|
||||
&& let Ok(rel) = p.strip_prefix(&root)
|
||||
{
|
||||
out.push(format!(
|
||||
"minecraft:{}",
|
||||
rel.with_extension("").to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
self.ui.registries.recipes = dir("recipe");
|
||||
self.ui.registries.damage_types = dir("damage_type");
|
||||
self.ui.registries.loot_tables = dir("loot_table");
|
||||
self.ui.registries.templates = {
|
||||
let root = self.options.resources.join("worldgen/structure/minecraft");
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().and_then(|x| x.to_str()) == Some("nbt")
|
||||
&& let Ok(rel) = p.strip_prefix(&root)
|
||||
{
|
||||
out.push(format!(
|
||||
"minecraft:{}",
|
||||
rel.with_extension("").to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
}
|
||||
self.ui.registries.sounds = self.audio.event_names();
|
||||
self.ui.registries.attributes = [
|
||||
"armor",
|
||||
@@ -973,7 +961,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
|
||||
pub(super) fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
|
||||
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
|
||||
let split = self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -1236,24 +1224,3 @@ fn owner_permission(allow_commands: bool) -> u8 {
|
||||
/// `ConfirmLinkScreen`'s buttons, left to right: yes, copy, no.
|
||||
const LINK_YES: usize = 0;
|
||||
const LINK_COPY: usize = 1;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_ids_are_listed_once_per_root() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
|
||||
let recipe = root.join("data/minecraft/recipe");
|
||||
std::fs::create_dir_all(recipe.join("sub")).unwrap();
|
||||
std::fs::write(recipe.join("b.json"), "{}").unwrap();
|
||||
std::fs::write(recipe.join("sub/a.json"), "{}").unwrap();
|
||||
std::fs::write(recipe.join("notes.txt"), "").unwrap();
|
||||
let first = resource_ids(&root);
|
||||
assert_eq!(first.recipes, ["minecraft:b", "minecraft:sub/a"]);
|
||||
assert!(first.loot_tables.is_empty());
|
||||
std::fs::write(recipe.join("c.json"), "{}").unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &resource_ids(&root)));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
//! A server's Code of Conduct bound to the game (`ClientConfigurationPacketListenerImpl.
|
||||
//! handleCodeOfConduct` and `CodeOfConductScreen`): the text in a scroll area,
|
||||
//! the "do not notify again" box, Acknowledge and Disconnect, and the server
|
||||
//! list entry that keeps the accepted text. The server waits in configuration
|
||||
//! until the answer, so the connection stays open while the screen shows.
|
||||
use super::{Game, Screen, server_packs::saves};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
dialog_screen::BOX,
|
||||
font::Font,
|
||||
hud::{HudBatch, rgba},
|
||||
multiplayer,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_core::random::java_string_hash;
|
||||
use horizon_protocol::servers;
|
||||
|
||||
/// The text's line height.
|
||||
const LINE: f32 = 9.0;
|
||||
/// The footer's button: `Button.DEFAULT_WIDTH` and the row's spacing of 8.
|
||||
const BUTTON: (f32, f32) = (150.0, 20.0);
|
||||
const GAP: f32 = 8.0;
|
||||
/// The text box's cell padding (`s.padding(12)`) and the text's padding inside it.
|
||||
const MARGIN: f32 = 12.0;
|
||||
const PAD: f32 = 4.0;
|
||||
/// Narrowest column: the two buttons and their gap.
|
||||
const COLUMN: f32 = 2.0 * BUTTON.0 + GAP;
|
||||
|
||||
/// An open `CodeOfConductScreen`.
|
||||
pub(super) struct Prompt {
|
||||
text: String,
|
||||
/// `text.hashCode()`: what the server list keeps once it is accepted.
|
||||
hash: i32,
|
||||
/// `stopShowing`: the box is ticked.
|
||||
stop_showing: bool,
|
||||
scroll: ui::Scroll,
|
||||
}
|
||||
|
||||
/// Where the screen's parts go (`WarningScreen`: title, the text box with 12 px
|
||||
/// around it, the checkbox, then the buttons, 8 px apart): the title and the
|
||||
/// wrapped text in its box (`area`: x, y, w, h), then the check and the
|
||||
/// two buttons.
|
||||
struct Layout {
|
||||
title: (Text, f32, f32),
|
||||
lines: Vec<Text>,
|
||||
area: [f32; 4],
|
||||
/// The text's full height with its padding: more than the area's scrolls.
|
||||
content: f32,
|
||||
check: [f32; 4],
|
||||
label: Text,
|
||||
buttons: [[f32; 4]; 2],
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
fn layout(&self, font: &Font, (w, h): (f32, f32)) -> Layout {
|
||||
let title = Text::translate("multiplayer.codeOfConduct.title", &[]).bold();
|
||||
let label = Text::translate("multiplayer.codeOfConduct.check", &[]);
|
||||
// `FittingMultiLineTextWidget`: `width - 100` wide, `height - 100` at
|
||||
// most, the text 4 px inside.
|
||||
let col = (w - 100.0).max(COLUMN);
|
||||
let lines: Vec<Text> = font
|
||||
.split_lines(&Text::literal(self.text.as_str()), col - 2.0 * PAD)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
let content = LINE * lines.len() as f32 + 2.0 * PAD;
|
||||
let box_h = content.min((h - 100.0).max(LINE + 2.0 * PAD));
|
||||
let fixed = LINE + GAP + MARGIN + MARGIN + GAP + BOX + GAP + BUTTON.1;
|
||||
let total = fixed + box_h;
|
||||
let ly = ((h - total) / 2.0).trunc();
|
||||
let cell = |cw: f32| ((w - cw) / 2.0).trunc();
|
||||
let area_y = ly + LINE + GAP + MARGIN;
|
||||
let check_y = area_y + box_h + MARGIN + GAP;
|
||||
let check_w = BOX + 4.0 + font.width_text(&label);
|
||||
let button_y = check_y + BOX + GAP;
|
||||
let bx = cell(COLUMN);
|
||||
Layout {
|
||||
title: (title.clone(), cell(font.width_text(&title)), ly),
|
||||
lines,
|
||||
area: [cell(col), area_y, col, box_h],
|
||||
content,
|
||||
check: [cell(check_w), check_y, check_w, BOX],
|
||||
label,
|
||||
buttons: [
|
||||
[bx, button_y, BUTTON.0, BUTTON.1],
|
||||
[bx + BUTTON.0 + GAP, button_y, BUTTON.0, BUTTON.1],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The server list entry of the joined address, if it has one.
|
||||
fn accepted_code_of_conduct(&self) -> Option<i32> {
|
||||
let address = self.server_packs.address.as_deref()?;
|
||||
servers::load(&multiplayer::servers_file(&saves()))
|
||||
.into_iter()
|
||||
.find(|s| s.ip == address)
|
||||
.map(|s| s.accepted_code_of_conduct)
|
||||
}
|
||||
|
||||
/// `ServerList.saveSingleServer`: the joined address's entry keeps `accepted`.
|
||||
fn store_code_of_conduct(&self, accepted: i32) {
|
||||
let Some(address) = &self.server_packs.address else {
|
||||
return;
|
||||
};
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let mut list = servers::load(&file);
|
||||
let mut found = false;
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
entry.accepted_code_of_conduct = accepted;
|
||||
found = true;
|
||||
}
|
||||
if found && let Err(e) = servers::save(&file, &list) {
|
||||
log::warn!("Horizon: could not save {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleCodeOfConduct`: text accepted before for this server is
|
||||
/// accepted at once; otherwise the screen opens over the connecting one.
|
||||
pub(super) fn handle_code_of_conduct(&mut self, text: String) {
|
||||
let Some(r) = &self.remote else {
|
||||
return;
|
||||
};
|
||||
let hash = java_string_hash(text.encode_utf16());
|
||||
if self.accepted_code_of_conduct() == Some(hash) {
|
||||
r.session.accept_code_of_conduct();
|
||||
return;
|
||||
}
|
||||
self.capture(false);
|
||||
self.code_of_conduct = Some(Prompt {
|
||||
text,
|
||||
hash,
|
||||
stop_showing: false,
|
||||
scroll: ui::Scroll::default(),
|
||||
});
|
||||
}
|
||||
|
||||
/// `CodeOfConductScreen.onResult`: accepting tells the server, declining
|
||||
/// ends the connection. The entry keeps the text only when it was
|
||||
/// accepted with the box ticked, else the stored text is cleared.
|
||||
fn answer_code_of_conduct(&mut self, accepted: bool) -> Result<()> {
|
||||
let Some(prompt) = self.code_of_conduct.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
if accepted {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.accept_code_of_conduct();
|
||||
}
|
||||
if self.player.is_some() && self.screen == Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
let kept = if accepted && prompt.stop_showing {
|
||||
prompt.hash
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.store_code_of_conduct(kept);
|
||||
if accepted {
|
||||
return Ok(());
|
||||
}
|
||||
let reason = Text::translate("multiplayer.disconnect.code_of_conduct", &[]);
|
||||
self.leave_server(Some(reason.plain()))
|
||||
}
|
||||
|
||||
pub(super) fn draw_code_of_conduct(&self, batch: &mut HudBatch) {
|
||||
let Some(prompt) = &self.code_of_conduct else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.player.is_none() {
|
||||
// In place of the connecting screen, on its background.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
}
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
let layout = prompt.layout(&atlas.font, (w, h));
|
||||
let cursor = self.cursor;
|
||||
let [ax, ay, aw, ah] = layout.area;
|
||||
let offset = prompt
|
||||
.scroll
|
||||
.amount
|
||||
.clamp(0.0, (layout.content - ah).max(0.0));
|
||||
|
||||
let (title, tx, ty) = &layout.title;
|
||||
batch.text_styled(atlas, title, *tx, *ty, u32::MAX, true);
|
||||
ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/widget/text_field",
|
||||
ax,
|
||||
ay,
|
||||
aw,
|
||||
ah,
|
||||
u32::MAX,
|
||||
);
|
||||
let start = batch.vertices.len();
|
||||
for (i, line) in layout.lines.iter().enumerate() {
|
||||
let y = ay + PAD + i as f32 * LINE - offset;
|
||||
batch.text_styled(atlas, line, ax + PAD, y, u32::MAX, true);
|
||||
}
|
||||
batch.clip_since(start, ax + 1.0, ay + 1.0, ax + aw - 1.0, ay + ah - 1.0);
|
||||
if layout.content > ah {
|
||||
// AbstractTextAreaWidget.scrollBarX: the box's right edge.
|
||||
ui::scrollbar(batch, atlas, ax + aw, ay, ay + ah, layout.content, offset);
|
||||
}
|
||||
|
||||
let [x, y, cw, ch] = layout.check;
|
||||
let hover = ui::hovered(cursor, x, y, cw, ch);
|
||||
let sprite = match (prompt.stop_showing, hover) {
|
||||
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
|
||||
(true, false) => "gui/sprites/widget/checkbox_selected",
|
||||
(false, true) => "gui/sprites/widget/checkbox_highlighted",
|
||||
(false, false) => "gui/sprites/widget/checkbox",
|
||||
};
|
||||
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&layout.label,
|
||||
x + BOX + 4.0,
|
||||
y + 4.0,
|
||||
rgba(224, 224, 224, 255),
|
||||
true,
|
||||
);
|
||||
|
||||
let labels = [
|
||||
Text::translate("gui.acknowledge", &[]),
|
||||
Text::translate("menu.disconnect", &[]),
|
||||
];
|
||||
for ([x, y, bw, bh], label) in layout.buttons.into_iter().zip(&labels) {
|
||||
let hover = ui::hovered(cursor, x, y, bw, bh);
|
||||
ui::button(batch, atlas, x, y, bw, bh, label, true, hover);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click: the box toggles, a button answers.
|
||||
pub(super) fn code_of_conduct_click(&mut self) -> Result<()> {
|
||||
let size = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
let Some(prompt) = self.code_of_conduct.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let layout = prompt.layout(&self.gui.atlas.font, size);
|
||||
let [x, y, w, h] = layout.check;
|
||||
let toggled = ui::hovered(cursor, x, y, w, h);
|
||||
if toggled {
|
||||
prompt.stop_showing = !prompt.stop_showing;
|
||||
}
|
||||
let answer = layout
|
||||
.buttons
|
||||
.iter()
|
||||
.position(|&[x, y, w, h]| ui::hovered(cursor, x, y, w, h))
|
||||
.map(|i| i == 0);
|
||||
if !toggled && answer.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
match answer {
|
||||
Some(accepted) => self.answer_code_of_conduct(accepted),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouse wheel over the screen: the text scrolls 10 a step.
|
||||
pub(super) fn code_of_conduct_scroll(&mut self, delta: f32) {
|
||||
let size = self.gui_size();
|
||||
let Some(prompt) = self.code_of_conduct.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let layout = prompt.layout(&self.gui.atlas.font, size);
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
prompt
|
||||
.scroll
|
||||
.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
}
|
||||
@@ -51,16 +51,9 @@ pub(super) struct CombatTracker {
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
pub(super) player_attacker: Option<([f64; 3], f32)>,
|
||||
/// While set, a sweep attack's reach to the other players is kept here
|
||||
/// instead of landing, for the caller that holds the LAN host.
|
||||
pub(super) sweeps: Option<Vec<super::lan_pvp::Sweep>>,
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// The exploding mob named itself (`MobEvent::ExplosionSource`), so the
|
||||
/// blast leaves it out (`getEntities(source, box)`). A source guessed
|
||||
/// from the mobs near the centre is still hurt: it may be a bystander.
|
||||
pub(super) explosion_source_named: bool,
|
||||
/// The blast now going off comes from a primed TNT, creeper, wither
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
|
||||
@@ -1376,8 +1376,7 @@ impl Game<'_> {
|
||||
self.player = Some(player);
|
||||
return Err(err("command.failed", &[]));
|
||||
}
|
||||
let at = player.position;
|
||||
self.trigger_dimension(from, dimension, [at.x, at.y, at.z]);
|
||||
self.trigger_dimension(from, dimension);
|
||||
let target = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
player.position = target;
|
||||
player.velocity = Vec3::ZERO;
|
||||
@@ -1929,10 +1928,7 @@ impl Game<'_> {
|
||||
fn spawned_mark(&self) -> (u32, usize) {
|
||||
(
|
||||
self.next_entity,
|
||||
self.tnts.len()
|
||||
+ self.falling.len()
|
||||
+ self.creatures.projectiles.len()
|
||||
+ self.rockets.len(),
|
||||
self.tnts.len() + self.falling.len() + self.creatures.projectiles.len(),
|
||||
)
|
||||
}
|
||||
/// BlockStateArgument -> registry state.
|
||||
|
||||
@@ -64,17 +64,6 @@ pub(in crate::game) struct ExecState {
|
||||
pub function_depth: u32,
|
||||
}
|
||||
|
||||
/// A modifier that brings the sources to `max_command_forks` ends the chain
|
||||
/// (`BuildContexts.ERROR_FORK_LIMIT_REACHED`); the error is only shown when
|
||||
/// no fork came before it (`CommandSourceStack.handleError`).
|
||||
fn fork_limit_reached(forked: bool, limit: usize) -> R<i32> {
|
||||
if forked {
|
||||
Ok(0)
|
||||
} else {
|
||||
Err(err("command.forkLimit", &[&limit]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Java's `(int)`/`(short)`/`(byte)` narrowing of a double.
|
||||
fn java_int(v: f64) -> i32 {
|
||||
v as i32
|
||||
@@ -314,8 +303,6 @@ impl Game<'_> {
|
||||
|
||||
/// ExecuteCommand: `ctx` is the context holding the `execute` literal.
|
||||
pub(in crate::game) fn run_execute(&mut self, ctx: &Context) -> R<i32> {
|
||||
// BuildContexts: a modifier may not bring the sources to this many.
|
||||
let fork_limit = self.ui.rules.int("max_command_forks") as usize;
|
||||
let mut sources = vec![self.command_source.unwrap_or_else(|| self.player_source())];
|
||||
let mut c = ctx;
|
||||
// A fork modifier (as, at, positioned as, rotated as, facing entity,
|
||||
@@ -367,9 +354,6 @@ impl Game<'_> {
|
||||
let mut kept = Vec::new();
|
||||
for s in sources {
|
||||
let (keep, _) = self.with_source(s, |g| g.condition(c, want))?;
|
||||
if kept.len() + usize::from(keep) >= fork_limit {
|
||||
return fork_limit_reached(forked, fork_limit);
|
||||
}
|
||||
if keep {
|
||||
kept.push(s);
|
||||
}
|
||||
@@ -385,11 +369,7 @@ impl Game<'_> {
|
||||
};
|
||||
let mut next = Vec::new();
|
||||
for s in sources {
|
||||
let new = self.with_source(s, |g| g.modify(word, c, s))?;
|
||||
if next.len() + new.len() >= fork_limit {
|
||||
return fork_limit_reached(forked, fork_limit);
|
||||
}
|
||||
next.extend(new);
|
||||
next.extend(self.with_source(s, |g| g.modify(word, c, s))?);
|
||||
}
|
||||
sources = next;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ use horizon_client::{
|
||||
command::CommandError,
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use horizon_protocol::packets::{
|
||||
Empty,
|
||||
play::{self, Clientbound as C, DialogHolder},
|
||||
};
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
|
||||
/// Waypoint.Icon: the locator bar style and colour of a transmitter.
|
||||
@@ -47,6 +43,8 @@ pub(in crate::game) struct CommandExtras {
|
||||
pub jfr: Option<(Instant, u64, String)>,
|
||||
/// Waypoint icons changed by `/waypoint modify`.
|
||||
pub waypoint_icons: HashMap<Target, WaypointIcon>,
|
||||
/// The dialog `/dialog show` opened (its id or inline definition).
|
||||
pub dialog: Option<String>,
|
||||
/// `/datapack disable vanilla` took the built-in pack out of the selection.
|
||||
pub vanilla_disabled: bool,
|
||||
/// The waypoint manager's tracked transmitters (None until first used).
|
||||
@@ -432,9 +430,9 @@ impl Game<'_> {
|
||||
let Some(Arg::Resource(dialog)) = ctx.get("dialog").cloned() else {
|
||||
return Ok(0);
|
||||
};
|
||||
self.show_dialog_to(&targets, &dialog);
|
||||
self.open_dialog(&dialog)?;
|
||||
} else {
|
||||
self.clear_dialog_to(&targets);
|
||||
self.close_dialog();
|
||||
}
|
||||
let (single, multiple) = if id == "dialog.show" {
|
||||
(
|
||||
@@ -1041,77 +1039,15 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayer.openDialog` for every target of `/dialog show`: a guest
|
||||
/// is sent `ClientboundShowDialogPacket`, the window's own player opens
|
||||
/// the dialog on its screen.
|
||||
fn show_dialog_to(&mut self, targets: &[Target], dialog: &str) {
|
||||
let Some(holder) = self.dialog_holder(dialog) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::ShowDialog(play::ShowDialog {
|
||||
dialog: holder.clone(),
|
||||
});
|
||||
for &t in targets {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.show_play_dialog(&holder),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.openDialog`: the dialog screen for a dialog id or inline
|
||||
/// definition.
|
||||
pub(in crate::game) fn open_dialog(&mut self, dialog: &str) -> R<()> {
|
||||
self.cmds.dialog = Some(dialog.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
/// `ServerPlayer.closeDialog` for every target of `/dialog clear`.
|
||||
fn clear_dialog_to(&mut self, targets: &[Target]) {
|
||||
let packet = C::ClearDialog(Empty);
|
||||
for &t in targets {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.clear_dialog(),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.openDialog`'s dialog: an entry of the `minecraft:dialog`
|
||||
/// registry, or the dialog an inline `ResourceOrIdArgument` carries.
|
||||
fn dialog_holder(&self, dialog: &str) -> Option<DialogHolder> {
|
||||
let text = dialog.trim();
|
||||
if text.starts_with('{') {
|
||||
return horizon_client::command::snbt::parse_fully(text)
|
||||
.ok()
|
||||
.map(DialogHolder::Direct);
|
||||
}
|
||||
// A registry id, written with or without its namespace.
|
||||
let id = if text.contains(':') {
|
||||
text.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{text}")
|
||||
};
|
||||
let index = self
|
||||
.ui
|
||||
.registries
|
||||
.ids("minecraft:dialog")
|
||||
.iter()
|
||||
.position(|name| *name == id);
|
||||
match index {
|
||||
Some(i) => Some(DialogHolder::Registry(i as i32)),
|
||||
None => {
|
||||
log::warn!("Horizon: no dialog {id} in the registry");
|
||||
None
|
||||
}
|
||||
}
|
||||
/// `ClientboundClearDialogPacket`: close the dialog screen.
|
||||
pub(in crate::game) fn close_dialog(&mut self) {
|
||||
self.cmds.dialog = None;
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -25,11 +25,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
|
||||
if let Err(e) = hotbar::save(&path, &rows) {
|
||||
// saveHotbar sets the message inside its try, so a failed save says nothing.
|
||||
log::warn!("the creative hotbar was not saved: {e}");
|
||||
return;
|
||||
}
|
||||
let _ = hotbar::save(&path, &rows);
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
|
||||
let message = Text::translate(
|
||||
"inventory.hotbarSaved",
|
||||
|
||||
@@ -460,9 +460,6 @@ impl Game<'_> {
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
|
||||
// day.json: false from tick 12542, true from 23460 (a step track).
|
||||
monsters_burn: self.dim == Dim::Overworld
|
||||
&& !(12542..23460).contains(&self.world.day_time.rem_euclid(24000)),
|
||||
game_time: self.world.game_time,
|
||||
day_time: self.world.day_time,
|
||||
raining: weather && rain > 0.2,
|
||||
@@ -800,12 +797,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// LivingEntity.die / dropAllDeathLoot.
|
||||
fn mob_died(&mut self, m: &Mob) {
|
||||
// LivingEntity.die: a sleeper gets up, and its bed is free again.
|
||||
if let Some(bed) = horizon_client::mobs::villager::sleeping_pos(m) {
|
||||
self.wake_villager(m.id);
|
||||
// The body may be gone already.
|
||||
self.set_bed_occupied(bed, false);
|
||||
}
|
||||
let killer = self.last_hurt_by_player.remove(&m.id);
|
||||
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
|
||||
let mut dying = m.clone();
|
||||
@@ -1179,20 +1170,13 @@ impl Game<'_> {
|
||||
volume,
|
||||
spread,
|
||||
} => {
|
||||
// WitherBoss.customServerAiStep: the spawn roar is global
|
||||
// level event 1023 at the block position.
|
||||
if sound == "entity.wither.spawn" {
|
||||
let pos = at.map(|v| v.floor() as i32);
|
||||
self.global_level_event(1023, pos, 0);
|
||||
} else {
|
||||
// The client's own level random: nothing drawn from the server's.
|
||||
let mut r = horizon_core::random::LegacyRandom::new(
|
||||
self.world.game_time.wrapping_mul(341_873_128_712),
|
||||
);
|
||||
let pitch = (r.next_float() - r.next_float()) * spread + 1.0;
|
||||
self.audio
|
||||
.play(&format!("minecraft:{sound}"), Some(at), volume, pitch)
|
||||
}
|
||||
// The client's own level random: nothing drawn from the server's.
|
||||
let mut r = horizon_core::random::LegacyRandom::new(
|
||||
self.world.game_time.wrapping_mul(341_873_128_712),
|
||||
);
|
||||
let pitch = (r.next_float() - r.next_float()) * spread + 1.0;
|
||||
self.audio
|
||||
.play(&format!("minecraft:{sound}"), Some(at), volume, pitch)
|
||||
}
|
||||
MobEvent::SoundId(name, at, volume, pitch) => {
|
||||
self.audio.play(&name, Some(at), volume, pitch)
|
||||
@@ -1301,7 +1285,6 @@ impl Game<'_> {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
// Only a creeper or a wither empties the hives it blows up.
|
||||
// The creeper is already gone from the list, so the event
|
||||
// carries its kind.
|
||||
@@ -1311,7 +1294,6 @@ impl Game<'_> {
|
||||
self.explode(at, radius)
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
self.combat.explosion_source_named = false;
|
||||
result?
|
||||
}
|
||||
MobEvent::AttackMob {
|
||||
@@ -1491,7 +1473,6 @@ impl Game<'_> {
|
||||
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
|
||||
// above trampled farmland breaks and drops.
|
||||
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
|
||||
MobEvent::BedOccupied { pos, occupied } => self.set_bed_occupied(pos, occupied),
|
||||
MobEvent::SetDoorOpen { pos, open } => {
|
||||
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
@@ -1662,7 +1643,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
|
||||
MobEvent::AxolotlSupportPlayer { mob } => {
|
||||
MobEvent::AxolotlSupportPlayer => {
|
||||
let previous = self
|
||||
.potions
|
||||
.effects
|
||||
@@ -1681,12 +1662,6 @@ impl Game<'_> {
|
||||
.effects
|
||||
.list
|
||||
.retain(|e| e.id != "mining_fatigue");
|
||||
let source = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.map(|m| self.mob_entity(m));
|
||||
self.trigger_effects_changed(source);
|
||||
}
|
||||
MobEvent::DolphinTreasure { mob } => {
|
||||
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
|
||||
@@ -2660,53 +2635,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `onProjectileHit` of chorus flowers and decorated pots: an impact
|
||||
/// projectile (`#impact_projectiles`) breaks the block when
|
||||
/// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes
|
||||
/// the `projectiles_can_break_blocks` rule and a shooter that may break
|
||||
/// blocks (`canBreakBlockInAdventureMode`: never a mob, never an
|
||||
/// adventure player). A pot is cracked first (flags 260), so it shatters.
|
||||
fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) {
|
||||
use horizon_client::command::args::GameMode;
|
||||
let impact = matches!(
|
||||
p.kind,
|
||||
ProjectileKind::Arrow { .. }
|
||||
| ProjectileKind::Trident
|
||||
| ProjectileKind::Snowball
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::SmallFireball
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
| ProjectileKind::WitherSkull { .. }
|
||||
| ProjectileKind::WindCharge
|
||||
| ProjectileKind::BreezeWindCharge
|
||||
);
|
||||
let shooter_may_break = match p.owner {
|
||||
Some(Who::Player) => self.mode != GameMode::Adventure,
|
||||
Some(Who::Mob(_)) => false,
|
||||
None => true,
|
||||
};
|
||||
if !impact || !shooter_may_break || !self.ui.rules.bool("projectiles_can_break_blocks") {
|
||||
return;
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
match &*self.res.facts().get(state).name {
|
||||
"minecraft:chorus_flower" => {}
|
||||
"minecraft:decorated_pot" => {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let Some(mut pot) = registry.state(state).cloned() else {
|
||||
return;
|
||||
};
|
||||
pot.properties.insert("cracked".into(), "true".into());
|
||||
let Ok(cracked) = registry.resolve(&pot) else {
|
||||
return;
|
||||
};
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, cracked, 260));
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
let _ = self.destroy_block_dropping(pos);
|
||||
}
|
||||
/// What an arrow's tick asked for: its particles and sounds.
|
||||
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
|
||||
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
|
||||
@@ -2871,7 +2799,6 @@ impl Game<'_> {
|
||||
&& face != [0; 3]
|
||||
{
|
||||
self.bell_projectile_hit(p, pos, at, face);
|
||||
self.projectile_breaks_block(p, pos);
|
||||
}
|
||||
self.combat.projectile_owner = match p.owner {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
@@ -3097,56 +3024,7 @@ impl Game<'_> {
|
||||
.find(|m| m.id == id)
|
||||
.map_or(0.0, |m| m.attrs.knockback_resistance),
|
||||
};
|
||||
let knockback = if !hurt {
|
||||
0.0
|
||||
} else if let Some(weapon) =
|
||||
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref())
|
||||
{
|
||||
let victim = match who {
|
||||
Who::Player => Some("minecraft:player"),
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.as_str()),
|
||||
};
|
||||
let owner = match p.owner {
|
||||
Some(Who::Player) => Some("minecraft:player"),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.as_str()),
|
||||
None => None,
|
||||
};
|
||||
let arrow_type = if p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
|
||||
{
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
"minecraft:arrow"
|
||||
};
|
||||
let ctx = horizon_client::enchant::EffectContext {
|
||||
this: victim,
|
||||
attacker: owner,
|
||||
direct_attacker: Some(arrow_type),
|
||||
damage_type: Some("minecraft:arrow"),
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
self.res.stations().enchantments.modify_value(
|
||||
Some(weapon),
|
||||
"minecraft:knockback",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
0.0,
|
||||
)
|
||||
} else {
|
||||
p.bonus.punch as f32
|
||||
};
|
||||
let push = f64::from(knockback) * 0.6 * (1.0 - resistance).max(0.0);
|
||||
let push = f64::from(p.bonus.punch) * 0.6 * (1.0 - resistance).max(0.0);
|
||||
if hurt && push > 0.0 && flight > 1e-6 {
|
||||
let k = push / flight;
|
||||
self.handle_mob_events(vec![MobEvent::Push {
|
||||
@@ -4238,7 +4116,7 @@ impl Game<'_> {
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage);
|
||||
if max > 0 {
|
||||
let loss = self.wear(&stack, 16);
|
||||
let loss = self.durability_loss(&stack, 16);
|
||||
let slot = self.held_slot();
|
||||
if let Some(held) = self.stack_in_mut(slot).as_mut() {
|
||||
held.damage += loss;
|
||||
@@ -4277,10 +4155,7 @@ impl Game<'_> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
|
||||
// last moved, not a velocity the server kept for a guest.
|
||||
let on_ground = p.ground.on_ground;
|
||||
let velocity = self.known_player_movement();
|
||||
let (velocity, on_ground) = (p.velocity, p.ground.on_ground);
|
||||
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
|
||||
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
|
||||
proj.shoot_from_rotation(
|
||||
@@ -4554,10 +4429,15 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
if &*mob.kind.id == "minecraft:player" {
|
||||
// PlayerRenderer's CapeLayer, WingsLayer and SpinAttackEffectLayer.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
// PlayerRenderer's WingsLayer and SpinAttackEffectLayer.
|
||||
self.player_elytra_geometry(
|
||||
mob,
|
||||
mob.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
@@ -4860,7 +4740,6 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::Explosion => "minecraft:explosion",
|
||||
K::Fall => "minecraft:fall",
|
||||
K::FallingBlock => "minecraft:falling_block",
|
||||
K::FallingStalactite => "minecraft:falling_stalactite",
|
||||
K::OnFire => "minecraft:on_fire",
|
||||
K::InFire => "minecraft:in_fire",
|
||||
K::Lava => "minecraft:lava",
|
||||
@@ -4896,7 +4775,6 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
names: Vec::new(),
|
||||
components: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A generated loot stack as a Horizon item stack.
|
||||
@@ -4919,9 +4797,6 @@ fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
|
||||
for (id, text) in &stack.names {
|
||||
item.data_mut().set_loot_name(id, text);
|
||||
}
|
||||
for (id, value) in &stack.components {
|
||||
item.data_mut().set_loot_component(id, value);
|
||||
}
|
||||
item
|
||||
}
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -203,7 +203,7 @@ impl Game<'_> {
|
||||
power,
|
||||
1.0,
|
||||
);
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo);
|
||||
}
|
||||
if !rocket {
|
||||
let kind = ProjectileKind::Arrow {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! 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};
|
||||
use super::{Game, Hit, view_vector};
|
||||
use horizon_client::entities::{Cushion, dye_index};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
@@ -107,7 +107,8 @@ impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
|
||||
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, dir) = self.aim()?;
|
||||
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() {
|
||||
|
||||
@@ -416,13 +416,6 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// Horizon's own: HDR's output while the setting is on, under the GPU (a group of its
|
||||
// own would move others to the other column).
|
||||
if self.settings.horizon.hdr
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
d.group("system", [r.hdr_line()]);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
@@ -924,17 +917,6 @@ impl Game<'_> {
|
||||
(t.pitch, t.yaw),
|
||||
);
|
||||
}
|
||||
// EntityType.EYE_OF_ENDER: 0.25 by 0.25, never turned.
|
||||
for (previous, now) in self.end.eye_motions() {
|
||||
let v = |p: glam::DVec3| Vec3::new(p.x, p.y, p.z);
|
||||
g.hitbox(
|
||||
at(v(previous), v(now)),
|
||||
(0.25, 0.25),
|
||||
0.2125,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for r in &self.rockets {
|
||||
// Projectile.updateRotation: the look follows the motion.
|
||||
let v = r.velocity;
|
||||
|
||||
@@ -299,14 +299,13 @@ impl Lists {
|
||||
}
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset`.
|
||||
const WORLD_PRESETS: [&str; 7] = [
|
||||
const WORLD_PRESETS: [&str; 6] = [
|
||||
"minecraft:normal",
|
||||
"minecraft:flat",
|
||||
"minecraft:large_biomes",
|
||||
"minecraft:amplified",
|
||||
"minecraft:single_biome_surface",
|
||||
"minecraft:debug_all_block_states",
|
||||
"minecraft:flat_all_dimensions",
|
||||
];
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
@@ -1376,8 +1375,6 @@ impl Game<'_> {
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
log("INFO", "Saving worlds");
|
||||
// ServerPlayer.disconnect: nobody is left in a bed.
|
||||
self.free_player_beds()?;
|
||||
self.save_game()?;
|
||||
self.log_saved_levels();
|
||||
self.lan_close(false);
|
||||
|
||||
@@ -51,7 +51,8 @@ impl Game<'_> {
|
||||
/// The redirectable projectile under the crosshair within the attack
|
||||
/// reach, and how far along the view it is.
|
||||
pub(super) fn redirectable_hit(&self) -> Option<(usize, f64)> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
self.creatures
|
||||
.projectiles
|
||||
|
||||
@@ -15,10 +15,7 @@ use horizon_client::{
|
||||
gamerules::Value as RuleValue,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Mob, Species,
|
||||
spawner::{SpawnDataExt, SpawnerExt},
|
||||
},
|
||||
mobs::{Mob, Species},
|
||||
motion::PlayerMotion,
|
||||
projectiles::ProjectileKind,
|
||||
world::World,
|
||||
|
||||
@@ -987,7 +987,7 @@ impl Game<'_> {
|
||||
"minecraft:firework_rocket" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.launch_angled_rocket(at, shot.velocity, &one, None);
|
||||
self.launch_angled_rocket(at, shot.velocity, &one);
|
||||
}
|
||||
_ => {
|
||||
let kind = match item.as_str() {
|
||||
|
||||
@@ -912,9 +912,8 @@ impl Game<'_> {
|
||||
// The blast's damage source carries the player, so the dragon
|
||||
// remembers them before the blast can kill it.
|
||||
self.fight.blast_by = by;
|
||||
// EndCrystal.hurtServer: ExplosionInteraction.BLOCK.
|
||||
let interaction = self.destroy_type("block_explosion_drop_decay");
|
||||
let _ = self.server_explode(at, 6.0, interaction, false);
|
||||
let _ =
|
||||
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
|
||||
self.fight.blast_by = None;
|
||||
}
|
||||
if respawn_crystal {
|
||||
@@ -1089,7 +1088,8 @@ impl Game<'_> {
|
||||
/// Player melee on the dragon's parts or a crystal under the crosshair.
|
||||
/// The dragon part under the crosshair within reach: its id and distance.
|
||||
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
let eye = self.eye_position()?;
|
||||
let dir = super::view_vector(self.yaw, self.pitch);
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
let mut best: Option<(u32, f64)> = None;
|
||||
for (id, b) in self.special_targets() {
|
||||
@@ -1122,21 +1122,22 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// A dragon part is no LivingEntity: `canCriticalAttack` is false.
|
||||
let (amount, _, _, _) = self.melee_damage("minecraft:ender_dragon", false);
|
||||
self.swing_arm();
|
||||
// The part's box.
|
||||
let area = self
|
||||
.special_targets()
|
||||
.into_iter()
|
||||
.find(|(part, _)| *part == id)
|
||||
.map(|(_, b)| b);
|
||||
let hurt = self.hit_special(id, amount, true, false);
|
||||
// The sweep and the sounds read the charge, which the hit then spends.
|
||||
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
|
||||
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
|
||||
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
|
||||
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
|
||||
let mut amount =
|
||||
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
|
||||
let falling = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.ground.on_ground && p.velocity.y < 0.0 && !p.sprinting);
|
||||
if scale > 0.9 && falling {
|
||||
amount *= 1.5;
|
||||
}
|
||||
amount += self.enchant_damage_bonus("minecraft:ender_dragon") * scale;
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
self.hit_special(id, amount, true, false);
|
||||
true
|
||||
}
|
||||
/// The 24 nodes (`findClosestNode` builds them on first use).
|
||||
@@ -2103,7 +2104,6 @@ impl Game<'_> {
|
||||
75,
|
||||
(96.0 * a.sin()).floor() as i32,
|
||||
];
|
||||
self.level_event_at(3000, pos, 0);
|
||||
let _ = self.place_gateway_at(pos);
|
||||
}
|
||||
if !self.fight.previously_killed
|
||||
@@ -3221,12 +3221,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// DragonRespawnStage.SUMMONING_PILLARS: ExplosionInteraction.BLOCK.
|
||||
let interaction = self.destroy_type("block_explosion_drop_decay");
|
||||
let _ = self.server_explode(
|
||||
[f64::from(cx) + 0.5, f64::from(h), f64::from(cz) + 0.5],
|
||||
5.0,
|
||||
interaction,
|
||||
super::explosion::BlockInteraction::Destroy,
|
||||
false,
|
||||
);
|
||||
// EndSpikeFeature.placeSpike with crystal_invulnerable and the beam.
|
||||
|
||||
@@ -71,24 +71,10 @@ impl Game<'_> {
|
||||
)
|
||||
.max(0.0) as u32
|
||||
}
|
||||
/// `ItemStack.hurtAndBreak` for the player's `stack`: the wear Unbreaking
|
||||
/// leaves, and `ItemDurabilityTrigger` when some is left (`applyDamage`).
|
||||
pub(super) fn wear(&mut self, stack: &ItemStack, amount: u32) -> u32 {
|
||||
let loss = self.durability_loss(stack, amount);
|
||||
if loss > 0 {
|
||||
self.trigger_item_durability(stack);
|
||||
}
|
||||
loss
|
||||
}
|
||||
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
|
||||
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
|
||||
self.enchanted_damage(target, 0.0)
|
||||
}
|
||||
/// `Player.getEnchantedDamage`: `base` run through the held weapon's
|
||||
/// damage effects against a mob type.
|
||||
pub(super) fn enchanted_damage(&mut self, target: &str, base: f32) -> f32 {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return base;
|
||||
return 0.0;
|
||||
};
|
||||
let ctx = EffectContext {
|
||||
this: Some(target),
|
||||
@@ -104,7 +90,7 @@ impl Game<'_> {
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
base,
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
/// Strength / weakness on the attack damage attribute.
|
||||
@@ -470,7 +456,7 @@ impl Game<'_> {
|
||||
}
|
||||
hits.push(1.0 + self.world.random.next_float() * 4.0);
|
||||
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
|
||||
let loss = self.wear(&stack, 2);
|
||||
let loss = self.durability_loss(&stack, 2);
|
||||
if let Some(s) = self.armor[slot].as_mut() {
|
||||
s.damage += loss;
|
||||
if self
|
||||
@@ -555,7 +541,7 @@ impl Game<'_> {
|
||||
/// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the
|
||||
/// `ammo_use` conditions as the tool, so Infinity only frees plain arrows.
|
||||
pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) {
|
||||
let Some(bow) = self.held().cloned() else {
|
||||
let Some(bow) = self.inventory[self.selected].clone() else {
|
||||
return (0.0, false);
|
||||
};
|
||||
let e = &self.res.stations().enchantments;
|
||||
@@ -590,7 +576,7 @@ impl Game<'_> {
|
||||
/// `EnchantmentHelper.onProjectileSpawned` ignite effects of the held bow
|
||||
/// (flame): the seconds the new arrow burns for.
|
||||
pub(super) fn bow_projectile_ignite(&mut self) -> Option<f32> {
|
||||
let bow = self.held().cloned()?;
|
||||
let bow = self.inventory[self.selected].clone()?;
|
||||
let ctx = EffectContext {
|
||||
this: Some("minecraft:arrow"),
|
||||
items: Some(self.res.items()),
|
||||
@@ -728,7 +714,7 @@ impl Game<'_> {
|
||||
let Some(stack) = self.armor[3].clone() else {
|
||||
return;
|
||||
};
|
||||
let loss = self.wear(&stack, amount);
|
||||
let loss = self.durability_loss(&stack, amount);
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
|
||||
@@ -181,10 +181,6 @@ impl EndState {
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// Where each eye in flight was last tick and is now (the hitbox view).
|
||||
pub(super) fn eye_motions(&self) -> impl Iterator<Item = (glam::DVec3, glam::DVec3)> + '_ {
|
||||
self.eyes.iter().map(|e| (e.previous, e.position))
|
||||
}
|
||||
/// `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) {
|
||||
@@ -333,13 +329,6 @@ impl Game<'_> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// EnderEyeItem.use's `getPlayerPOVHitResult`: whether the block aimed
|
||||
/// at is an End portal frame (the use passes there).
|
||||
pub(super) fn aims_at_end_portal_frame(&self) -> bool {
|
||||
self.pick()
|
||||
.is_some_and(|hit| &*self.block_name(hit.pos) == "minecraft:end_portal_frame")
|
||||
}
|
||||
|
||||
/// EnderEyeItem.useOn: fill an empty End portal frame, then light the
|
||||
/// portal when the ring of twelve filled frames is complete.
|
||||
pub(super) fn use_ender_eye(&mut self, stack: &ItemStack, use_on_block: bool) -> Result<bool> {
|
||||
@@ -356,12 +345,7 @@ impl Game<'_> {
|
||||
if matches!(self.guest_use, Some(super::GuestUse::OnBlock(_))) {
|
||||
return Ok(false);
|
||||
}
|
||||
// EnderEyeItem.use: startUsingItem (getUseDuration 0, so the use
|
||||
// lasts until the button lets go), then the eye is thrown towards
|
||||
// the nearest stronghold if there is one (CONSUME without).
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
// EnderEyeItem.use: throw the eye towards the nearest stronghold.
|
||||
self.throw_ender_eye();
|
||||
return Ok(true);
|
||||
};
|
||||
@@ -392,8 +376,14 @@ impl Game<'_> {
|
||||
}
|
||||
self.consume_held();
|
||||
self.swing_arm();
|
||||
// EnderEyeItem.useOn emits this event for all nearby players.
|
||||
self.level_event_at(1503, hit.pos, 0);
|
||||
let center = hit.pos.map(|v| f64::from(v) + 0.5);
|
||||
// LevelEvent 1503: END_PORTAL_FRAME_FILL.
|
||||
self.audio.play(
|
||||
"minecraft:block.end_portal_frame.fill",
|
||||
Some(center),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
if let Some(c) = self.portal_ring_center(hit.pos) {
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
|
||||
@@ -258,8 +258,6 @@ impl Game<'_> {
|
||||
if dist > 1.0 || p.health.dead_or_dying() {
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.onExplosionHit: the blast that hit the player is theirs.
|
||||
self.adv.marks.blast = Some((feet, false));
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let h = f64::from(p.dimensions.height);
|
||||
let eye = [
|
||||
@@ -382,14 +380,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Mobs, but not the one exploding (`getEntities(source, box)`): a
|
||||
// wither's spawn blast neither hurts nor pushes it.
|
||||
let spared = exploding.filter(|_| self.combat.explosion_source_named);
|
||||
// Mobs.
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
for m in mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.alive() && spared != Some(m.id))
|
||||
{
|
||||
for m in mobs.iter_mut().filter(|m| m.alive()) {
|
||||
let hurtable = teams_allow(self, self.mob_team(m.id));
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
@@ -510,15 +503,6 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying
|
||||
/// and plain destruction.
|
||||
pub(super) fn destroy_type(&self, rule: &str) -> BlockInteraction {
|
||||
if self.ui.rules.bool(rule) {
|
||||
BlockInteraction::DestroyWithDecay
|
||||
} else {
|
||||
BlockInteraction::Destroy
|
||||
}
|
||||
}
|
||||
/// Runs an explosion whose source empties the hives it destroys
|
||||
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
|
||||
/// TNT minecart).
|
||||
|
||||
@@ -6,33 +6,11 @@ use horizon_world::level::LevelEvent;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn finish_falling_block(&mut self, mut block: FallingBlock, pos: [i32; 3]) {
|
||||
let name = self.res.facts().get(block.state).name.clone();
|
||||
let anvil = name.ends_with("anvil");
|
||||
let stalactite = matches!(
|
||||
&*name,
|
||||
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
|
||||
);
|
||||
let sound_event = match &*name {
|
||||
"minecraft:pointed_dripstone" => Some(1045),
|
||||
"minecraft:sulfur_spike" => Some(1052),
|
||||
_ => None,
|
||||
};
|
||||
let anvil = self.res.facts().get(block.state).name.ends_with("anvil");
|
||||
if let Some(impact) = block.damage.impact(block.fall_distance) {
|
||||
if impact.amount > 0.0 {
|
||||
let mob_kind = if stalactite {
|
||||
horizon_client::mobs::DamageKind::FallingStalactite
|
||||
} else {
|
||||
horizon_client::mobs::DamageKind::FallingBlock
|
||||
};
|
||||
falling_block::hurt_mobs_with_kind(
|
||||
&mut self.mobs,
|
||||
block.position,
|
||||
impact.amount,
|
||||
mob_kind,
|
||||
);
|
||||
let kind = if stalactite {
|
||||
"minecraft:falling_stalactite"
|
||||
} else if anvil {
|
||||
falling_block::hurt_mobs(&mut self.mobs, block.position, impact.amount);
|
||||
let kind = if anvil {
|
||||
"minecraft:falling_anvil"
|
||||
} else {
|
||||
"minecraft:falling_block"
|
||||
@@ -80,13 +58,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if block.damage.cancel_drop {
|
||||
if let Some(id) = sound_event {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
id,
|
||||
pos: BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
data: 0,
|
||||
});
|
||||
} else if anvil {
|
||||
if anvil {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
id: 1029,
|
||||
pos: BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
@@ -127,13 +99,6 @@ impl Game<'_> {
|
||||
&& block.drop_item
|
||||
&& self.ui.rules.bool("entity_drops")
|
||||
{
|
||||
if let Some(id) = sound_event {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
id,
|
||||
pos: BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
data: 0,
|
||||
});
|
||||
}
|
||||
let item = self.res.facts().get(block.state).item.clone();
|
||||
if &*item != "minecraft:air" {
|
||||
self.pop_resource(
|
||||
|
||||
@@ -7,12 +7,10 @@ use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light,
|
||||
collisions::SceneCollisions,
|
||||
entity_nbt::RocketNbt,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, Species, Who},
|
||||
mobs::{Damage, DamageKind},
|
||||
particles::{FireworkExplosion, ParticleOptions, ParticleType},
|
||||
projectiles::ProjectileWorld,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -32,14 +30,9 @@ pub(super) struct Rocket {
|
||||
pub(super) explosions: Vec<FireworkExplosion>,
|
||||
/// Boosting the player (attachedToEntity).
|
||||
pub(super) attached: bool,
|
||||
/// On a server, the guest (by connection) who launched it, or whom it
|
||||
/// boosts: `owner`'s `Who::Player` then means them, not the host.
|
||||
/// On a server, the player it boosts when not this game's own: a guest's
|
||||
/// connection.
|
||||
pub(super) guest: Option<u32>,
|
||||
/// `Projectile.getOwner`: the player who launched it, credited with
|
||||
/// what its blast hurts.
|
||||
pub(super) owner: Option<Who>,
|
||||
/// `Projectile.leftOwner`: until then it can't hit its owner.
|
||||
left_owner: bool,
|
||||
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
|
||||
/// boosting happens when it is the local player.
|
||||
pub(super) attached_to: Option<u32>,
|
||||
@@ -64,36 +57,11 @@ impl Rocket {
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
owner: None,
|
||||
left_owner: false,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// A rocket loaded from its NBT (`/summon`, a saved chunk).
|
||||
pub(super) fn from_nbt(r: RocketNbt) -> Self {
|
||||
let item = r
|
||||
.item
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:firework_rocket", 1));
|
||||
let (_, explosions) = fireworks_of(&item);
|
||||
Self {
|
||||
id: 0,
|
||||
position: r.position,
|
||||
previous: r.position,
|
||||
velocity: r.motion,
|
||||
life: r.life,
|
||||
lifetime: r.lifetime,
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
owner: r.owner,
|
||||
left_owner: r.left_owner,
|
||||
attached_to: None,
|
||||
angled: r.angled,
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: the rocket flies on by itself between
|
||||
/// the server's updates (a rocket that follows an entity is placed by the
|
||||
/// caller).
|
||||
@@ -104,19 +72,6 @@ impl Rocket {
|
||||
}
|
||||
self.life += 1;
|
||||
}
|
||||
/// The move of a rocket attached to no entity: sped up and rising, or an
|
||||
/// angled one's own motion.
|
||||
fn next_motion(&self) -> Vec3 {
|
||||
if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
|
||||
/// up (an angled one keeps its motion) and rise, then move. True when a
|
||||
/// block stopped it.
|
||||
@@ -130,7 +85,15 @@ impl Rocket {
|
||||
self.position.z + h,
|
||||
),
|
||||
);
|
||||
let wanted = self.next_motion();
|
||||
let wanted = if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
|
||||
return false;
|
||||
};
|
||||
@@ -194,8 +157,6 @@ impl Game<'_> {
|
||||
explosions,
|
||||
attached,
|
||||
guest: None,
|
||||
owner: None,
|
||||
left_owner: false,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item: stack.clone(),
|
||||
@@ -211,9 +172,7 @@ impl Game<'_> {
|
||||
hit.point[1] + f64::from(hit.face[1]) * 0.15,
|
||||
hit.point[2] + f64::from(hit.face[2]) * 0.15,
|
||||
);
|
||||
let mut rocket = self.new_rocket(at, stack, false);
|
||||
rocket.owner = Some(Who::Player);
|
||||
rocket.guest = self.lan_acting;
|
||||
let rocket = self.new_rocket(at, stack, false);
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
@@ -223,18 +182,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// A rocket shot at an angle (`DATA_SHOT_AT_ANGLE`): out of a dispenser
|
||||
/// (`FireworkRocketItem.asProjectile`) or a crossbow.
|
||||
pub(super) fn launch_angled_rocket(
|
||||
&mut self,
|
||||
at: Vec3,
|
||||
velocity: Vec3,
|
||||
stack: &ItemStack,
|
||||
owner: Option<Who>,
|
||||
) {
|
||||
pub(super) fn launch_angled_rocket(&mut self, at: Vec3, velocity: Vec3, stack: &ItemStack) {
|
||||
let mut rocket = self.new_rocket(at, stack, false);
|
||||
rocket.velocity = velocity;
|
||||
rocket.angled = true;
|
||||
rocket.owner = owner;
|
||||
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
|
||||
self.rockets.push(rocket);
|
||||
}
|
||||
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
|
||||
@@ -246,7 +197,6 @@ impl Game<'_> {
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
rocket.owner = Some(Who::Player);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
@@ -256,33 +206,10 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
pub(super) fn tick_rockets(&mut self) {
|
||||
let targets = if self.rockets.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.rocket_targets()
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < self.rockets.len() {
|
||||
let mut r = self.rockets.remove(i);
|
||||
r.previous = r.position;
|
||||
// Projectile.tick: whether it has left its owner, before it moves.
|
||||
let owner = self.rocket_owner(&r);
|
||||
if !r.left_owner {
|
||||
let h = 0.125;
|
||||
let reach = Aabb::new(
|
||||
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
|
||||
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
|
||||
)
|
||||
.expand_towards(r.velocity)
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
r.left_owner = owner.is_none_or(|o| {
|
||||
!targets
|
||||
.iter()
|
||||
.any(|(who, b)| *who == o && b.intersects(reach))
|
||||
});
|
||||
}
|
||||
let skip = owner.filter(|_| !r.left_owner);
|
||||
let mut hit = None;
|
||||
if r.attached
|
||||
&& let Some(conn) = r.guest
|
||||
{
|
||||
@@ -308,10 +235,7 @@ impl Game<'_> {
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
hit = self.rocket_struck(&targets, &r, r.velocity, skip);
|
||||
} else {
|
||||
// getHitResultOnMoveVector before the move.
|
||||
hit = self.rocket_struck(&targets, &r, r.next_motion(), skip);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -322,20 +246,6 @@ impl Game<'_> {
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
}
|
||||
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
|
||||
// (ProjectileDeflection.REVERSE), anything else sets it off.
|
||||
match hit {
|
||||
Some(Who::Mob(id))
|
||||
if self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == id && m.species() == Species::Breeze) =>
|
||||
{
|
||||
r.velocity = r.velocity.scale(-0.5);
|
||||
}
|
||||
Some(_) => r.life = r.lifetime,
|
||||
None => {}
|
||||
}
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if r.life == 0 {
|
||||
self.audio.play(
|
||||
@@ -417,28 +327,17 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// damageSources().fireworks(this, getOwner()): a guest's rocket
|
||||
// hurts as theirs, and the mobs it hit answer to them.
|
||||
let owner = r.owner;
|
||||
if let Some(conn) = r.guest.filter(|_| owner.is_some())
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
let ran = self
|
||||
.as_guest(&mut host, conn, |g| g.rocket_hurt_mobs(&hits, owner, at))
|
||||
.is_some();
|
||||
if ran {
|
||||
for (id, _) in &hits {
|
||||
if self.mobs.iter().any(|m| m.id == *id) {
|
||||
host.mob_players.insert(*id, conn);
|
||||
}
|
||||
}
|
||||
for (id, amount) in hits {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(
|
||||
amount,
|
||||
Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker: None,
|
||||
from: Some(at),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
if !ran {
|
||||
self.rocket_hurt_mobs(&hits, owner, at);
|
||||
}
|
||||
} else {
|
||||
self.rocket_hurt_mobs(&hits, owner, at);
|
||||
}
|
||||
}
|
||||
let camera = self.eye_position().unwrap_or(at);
|
||||
@@ -446,87 +345,6 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
}
|
||||
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
|
||||
for &(id, amount) in hits {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(
|
||||
amount,
|
||||
Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker,
|
||||
from: Some(at),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The owner as `rocket_targets` names it: a guest by their entity id.
|
||||
fn rocket_owner(&self, r: &Rocket) -> Option<Who> {
|
||||
let owner = r.owner?;
|
||||
let Some(conn) = r.guest.filter(|_| owner == Who::Player) else {
|
||||
return Some(owner);
|
||||
};
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
|
||||
Some(Who::Mob(g.entity_id as u32))
|
||||
}
|
||||
/// What a rocket can hit (`Projectile.canHitEntity`): living mobs, the
|
||||
/// dragon's parts and crystals, the player and the guests.
|
||||
fn rocket_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
&& !self.spectator()
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
targets.extend(self.lan_projectile_targets());
|
||||
targets
|
||||
}
|
||||
/// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest
|
||||
/// target on the move, short of the block it clips, each box grown by
|
||||
/// `computeMargin` (`tickCount` is the rocket's life plus one).
|
||||
fn rocket_struck(
|
||||
&self,
|
||||
targets: &[(Who, Aabb)],
|
||||
r: &Rocket,
|
||||
motion: Vec3,
|
||||
skip: Option<Who>,
|
||||
) -> Option<Who> {
|
||||
let from = r.position;
|
||||
let mut to = from.add_vector(motion);
|
||||
let w = self.mob_world_base(&[]);
|
||||
if let Some((at, ..)) = (super::creatures::ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
})
|
||||
.clip(from, to)
|
||||
{
|
||||
to = at;
|
||||
}
|
||||
let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3);
|
||||
targets
|
||||
.iter()
|
||||
.filter(|(who, _)| Some(*who) != skip)
|
||||
.filter_map(|(who, b)| {
|
||||
let at = b
|
||||
.inflate(Vec3::new(margin, margin, margin))
|
||||
.clip(from, to)?;
|
||||
Some((*who, from.distance_squared(at)))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(who, _)| who)
|
||||
}
|
||||
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
|
||||
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
|
||||
let camera = self.eye_position();
|
||||
|
||||
@@ -164,7 +164,7 @@ impl Game<'_> {
|
||||
(p.position.x - start[0]) as f32,
|
||||
(p.position.z - start[2]) as f32,
|
||||
);
|
||||
self.avatar.tick(start);
|
||||
self.avatar.tick();
|
||||
self.avatar
|
||||
.update_bob(ground && alive && !swimming, velocity);
|
||||
if riding {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `ItemFrame.survives`, `interact` (put an item in, then rotate it),
|
||||
//! `hurtServer` (take the item out, then break the frame) and
|
||||
//! `ItemFrameRenderer` (the frame model and the item at a 45 degree step).
|
||||
use super::{Game, Hit};
|
||||
use super::{Game, Hit, view_vector};
|
||||
use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary};
|
||||
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
@@ -160,7 +160,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// The frame under the crosshair within reach and its distance.
|
||||
pub(super) fn frame_hit(&self) -> Option<(usize, f64)> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
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)> = None;
|
||||
for (i, f) in self.item_frames.iter().enumerate() {
|
||||
@@ -188,7 +189,7 @@ impl Game<'_> {
|
||||
/// Nearer than the targeted block?
|
||||
pub(super) fn before_block(&self, distance: f64) -> bool {
|
||||
self.pick().is_none_or(|h| {
|
||||
let eye = self.aim().map(|(eye, _)| eye).unwrap_or_default();
|
||||
let eye = self.eye_position().unwrap_or_default();
|
||||
(0..3)
|
||||
.map(|a| (h.point[a] - eye[a]).powi(2))
|
||||
.sum::<f64>()
|
||||
|
||||
@@ -3,22 +3,6 @@
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::command::args::GameMode;
|
||||
|
||||
pub(super) fn mode_id_of(mode: GameMode) -> i32 {
|
||||
match mode {
|
||||
GameMode::Survival => 0,
|
||||
GameMode::Creative => 1,
|
||||
GameMode::Adventure => 2,
|
||||
GameMode::Spectator => 3,
|
||||
}
|
||||
}
|
||||
pub(super) fn mode_from_id(id: i32) -> GameMode {
|
||||
match id {
|
||||
1 => GameMode::Creative,
|
||||
2 => GameMode::Adventure,
|
||||
3 => GameMode::Spectator,
|
||||
_ => GameMode::Survival,
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn spectator(&self) -> bool {
|
||||
self.mode == GameMode::Spectator
|
||||
@@ -28,10 +12,21 @@ impl Game<'_> {
|
||||
matches!(self.mode, GameMode::Survival | GameMode::Creative)
|
||||
}
|
||||
pub(super) fn mode_id(&self) -> i32 {
|
||||
mode_id_of(self.mode)
|
||||
match self.mode {
|
||||
GameMode::Survival => 0,
|
||||
GameMode::Creative => 1,
|
||||
GameMode::Adventure => 2,
|
||||
GameMode::Spectator => 3,
|
||||
}
|
||||
}
|
||||
pub(super) fn set_mode_id(&mut self, id: i32) {
|
||||
self.set_mode(mode_from_id(id));
|
||||
let mode = match id {
|
||||
1 => GameMode::Creative,
|
||||
2 => GameMode::Adventure,
|
||||
3 => GameMode::Spectator,
|
||||
_ => GameMode::Survival,
|
||||
};
|
||||
self.set_mode(mode);
|
||||
}
|
||||
/// `ServerPlayer.setGameMode` and `GameType.updatePlayerAbilities`.
|
||||
pub(super) fn set_mode(&mut self, mode: GameMode) {
|
||||
|
||||
@@ -78,13 +78,7 @@ impl Game<'_> {
|
||||
f64::from(base[1]) + 0.05,
|
||||
f64::from(base[2]) + 0.5,
|
||||
];
|
||||
let id = self.create_triggered(kind, at, 0.0);
|
||||
// CarvedPumpkinBlock.trySpawnGolem: setPlayerCreated(true) on the iron golem.
|
||||
if kind == "minecraft:iron_golem"
|
||||
&& let Some(m) = id.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
|
||||
{
|
||||
m.extra.player_created = true;
|
||||
}
|
||||
self.create_triggered(kind, at, 0.0);
|
||||
self.update_pattern_blocks(&blocks)
|
||||
}
|
||||
/// Whether placing a golem head at `head` completes a golem.
|
||||
|
||||
@@ -324,12 +324,12 @@ impl Game<'_> {
|
||||
let Some(hit) = self.pick() else {
|
||||
return false;
|
||||
};
|
||||
let id = &*self
|
||||
let name = &*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
let name = id.trim_start_matches("minecraft:");
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
match name {
|
||||
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
|
||||
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
|
||||
@@ -338,9 +338,8 @@ impl Game<'_> {
|
||||
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
|
||||
| "end_portal" | "end_gateway" => true,
|
||||
// ChestBlock.getMenuProvider: none while a redstone conductor
|
||||
// sits on top of either half (a sitting cat aside). Copper
|
||||
// chests are `ChestBlock`s too.
|
||||
_ if horizon_client::containers::is_chest_block(id) => {
|
||||
// sits on top of either half (a sitting cat aside).
|
||||
"chest" | "trapped_chest" => {
|
||||
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let blocked = |p: (i32, i32, i32)| {
|
||||
let above = self.world.block(p.0, p.1 + 1, p.2);
|
||||
|
||||
@@ -425,7 +425,6 @@ impl Game<'_> {
|
||||
return result.and(stopped);
|
||||
}
|
||||
let mut next = Instant::now();
|
||||
let mut was_paused = false;
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.run_tasks();
|
||||
if self.agent.hold_server {
|
||||
@@ -435,14 +434,6 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let paused = flags.paused.load(Ordering::Relaxed);
|
||||
if paused && !was_paused {
|
||||
// IntegratedServer.tickServer: the tick the game pauses saves
|
||||
// everything (saveEverything, without flushing), as an
|
||||
// autosave does.
|
||||
log::info!("Saving and pausing game...");
|
||||
self.save_game_with(false)?;
|
||||
}
|
||||
was_paused = paused;
|
||||
if paused {
|
||||
// Only the connections run.
|
||||
self.tick_server_timed(true)?;
|
||||
@@ -487,8 +478,6 @@ impl Game<'_> {
|
||||
self.lan = Some(host);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
// ServerPlayer.disconnect: nobody is left in a bed.
|
||||
self.free_player_beds()?;
|
||||
self.save_game()?;
|
||||
self.lan_close(false);
|
||||
self.save.flush()?;
|
||||
|
||||
Loaded 100 of 529 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user