mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into feat/server-mods-0.3
# Conflicts: # Cargo.toml # crates/cargo-horizon-mod/src/check.rs # crates/horizon-client/src/game/mods.rs # crates/horizon-mod-sdk/src/lib.rs # crates/horizon-mod-sdk/wit/horizon.wit # crates/horizon-mods/src/manifest.rs # crates/horizon-mods/src/runtime.rs # crates/horizon-mods/src/testing.rs # crates/horizon-mods/tests/sandbox.rs # docs/api/mods-reference.md # docs/api/mods.md # docs/mods.md
This commit is contained in:
commit
b90ab85f44
489 files changed
+44104
-5697
No files matched your search
@@ -3,7 +3,8 @@ name: checks
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. A fork's pull
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
|
||||
# never start this workflow. See docs/release-flow.md.
|
||||
on:
|
||||
@@ -72,8 +73,37 @@ jobs:
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
parity:
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
parity-scope:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run: ${{ steps.scope.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- id: scope
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
|
||||
run: |
|
||||
changed=$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)
|
||||
harness=$(grep -E '^(tools/test_[a-z0-9_]+_parity\.py|tools/(parity[a-z_]*|lab)\.py|tools/parity_hosts\.json|mods/horizon/src/test/|crates/[^/]+/examples/|crates/horizon-client/src/game/[a-z_]*(fixture|harness)\.rs|\.github/workflows/parity[a-z-]*\.yml)' <<< "$changed" || true)
|
||||
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "The pull request runs its harnesses: ${harness:-label run-parity}"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No harness, oracle, probe or runner change: parity-main checks this on main"
|
||||
fi
|
||||
parity:
|
||||
needs: parity-scope
|
||||
if: needs.parity-scope.outputs.run == 'true'
|
||||
uses: ./.github/workflows/parity.yml
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
@@ -29,7 +29,8 @@ env:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
# The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
|
||||
runs-on: [self-hosted, horizon-game, macOS]
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: 03:00 in Paris
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: parity-main
|
||||
# The vanilla harnesses on main, a batch of merges at a time (docs/release-flow.md#parity-on-main), on the Mac mini's
|
||||
# runner. One run at a time: merges that land while a run goes on wait in one pending run (GitHub keeps only the
|
||||
# newest), which checks every merge since main's last green parity-main (tools/parity_main.py base). On a failure it
|
||||
# bisects the failing harnesses over those merges and comments on the pull request that broke them. Releases ship
|
||||
# only a commit whose parity-main passed (tools/release_train.py), so nothing a harness catches reaches players.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
# The comment on the merge that broke a harness.
|
||||
pull-requests: write
|
||||
# tools/parity_main.py base reads earlier runs.
|
||||
actions: read
|
||||
concurrency:
|
||||
group: parity-main
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
parity-main:
|
||||
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
|
||||
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
|
||||
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: Main's last green parity-main
|
||||
id: base
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
|
||||
- name: The harnesses the merges since then can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: |
|
||||
echo "Merges since ${BASE_SHA}:"
|
||||
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
|
||||
- name: The merge that broke them
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: |
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -36,14 +36,76 @@ jobs:
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: |
|
||||
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
|
||||
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify -q origin/main >/dev/null \
|
||||
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
|
||||
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
|
||||
bash "$RUNNER_TEMP/vanilla-data.sh"
|
||||
else
|
||||
bash tools/ci/vanilla-data.sh
|
||||
fi
|
||||
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: |
|
||||
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
|
||||
# the tool nor the number, so there is nothing to check.
|
||||
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
|
||||
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
- name: The version constants are the pinned jar's
|
||||
run: python3 tools/gen_version_consts.py --check
|
||||
- name: The harnesses this change can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
run: |
|
||||
# main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
|
||||
# branch made before a better selection landed gets it too; the head's own runner then runs those
|
||||
# harnesses. A branch that changes the selection itself selects with its own code.
|
||||
set -euo pipefail
|
||||
own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
|
||||
fork_point=$(git merge-base "$BASE_SHA" HEAD)
|
||||
if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
|
||||
own
|
||||
exit
|
||||
fi
|
||||
had_reach=$(git ls-files tools/parity_reach.py)
|
||||
git show origin/main:tools/parity.py > tools/parity.py
|
||||
git show origin/main:tools/parity_reach.py > tools/parity_reach.py
|
||||
status=0
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
|
||||
git checkout -- tools/parity.py
|
||||
if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
|
||||
cat .local/parity-selection.txt
|
||||
if [ "$status" != 0 ]; then
|
||||
echo "main's selection failed: the head's own selection runs"
|
||||
own
|
||||
exit
|
||||
fi
|
||||
names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
|
||||
# The summary line says how many would run: a count that doesn't match the names read (a format change)
|
||||
# must not let the job pass untested.
|
||||
expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
|
||||
count=$(printf '%s' "$names" | grep -c . || true)
|
||||
if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
|
||||
echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
|
||||
own
|
||||
exit
|
||||
fi
|
||||
if [ "$count" = 0 ]; then
|
||||
echo "Nothing to run."
|
||||
exit 0
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
python3 tools/parity.py $names --timeout 2400
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides,
|
||||
# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
|
||||
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
@@ -18,7 +19,8 @@ name: release
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [checks]
|
||||
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
|
||||
workflows: [checks, parity-main]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
schedule:
|
||||
@@ -64,8 +66,8 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
|
||||
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
|
||||
# main's newest commit with green checks.
|
||||
# The commit whose checks or parity-main just passed; a dry run builds the branch it was started on;
|
||||
# otherwise main's newest commit with green checks and parity-main.
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
run: |
|
||||
@@ -77,9 +79,50 @@ jobs:
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
build:
|
||||
exporters:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
|
||||
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
- run: bash tools/ci/vanilla-data.sh
|
||||
- name: Compile and validate original exporters against the pinned jars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
|
||||
# prepares afresh.
|
||||
cargo build --release --locked -p horizon-prepare
|
||||
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
|
||||
# write those (vanilla-data.sh links .local/exporters to them).
|
||||
rm -rf .local/exporters
|
||||
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
|
||||
mkdir -p exporters
|
||||
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
|
||||
# Only our classes and their manifest leave this runner.
|
||||
stray=$(find exporters -type f ! -path exporters/manifest.json \
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: [decide, exporters]
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -218,6 +261,10 @@ jobs:
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -31,3 +31,6 @@
|
||||
/.horizon-cache/
|
||||
/crash-reports/
|
||||
/debug/
|
||||
|
||||
# Release-only compiled original exporter bundle.
|
||||
/exporters/
|
||||
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
@@ -81,7 +82,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
|
||||
@@ -7,6 +7,11 @@ welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
|
||||
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
|
||||
## Build it
|
||||
|
||||
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
|
||||
|
||||
Generated
+370
-310
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/welcome", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
|
||||
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
|
||||
<a href="#project-status"><b>Project status</b></a> ·
|
||||
<a href="#contributing"><b>Contributing</b></a>
|
||||
</p>
|
||||
@@ -30,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
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/ludovic111/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 own copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -109,6 +110,10 @@ Everything prepared lands in the gitignored `.local/`. It is derived from the ga
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
|
||||
+966
@@ -0,0 +1,966 @@
|
||||
# Horizon roadmap
|
||||
|
||||
<!-- Generated by tools/roadmap.py from roadmap/roadmap.toml: edit that file, then run `python3 tools/roadmap.py build`. -->
|
||||
|
||||
What is being done on Horizon, what comes next and where you can help. There are no dates: things are Now, Next or Later. Each item is said in plain words, with the technical picture folded under it. The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on [playhorizon.dev/hub](https://playhorizon.dev/hub). Pull requests carry the `roadmap:<id>` label of their item.
|
||||
|
||||
## At a glance
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it runs faster on the same computer. Single player, LAN, servers, resource packs and Horizon's own mods work today, on your own copy of the game.
|
||||
|
||||
**Being done now**
|
||||
|
||||
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
- **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**: 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.
|
||||
|
||||
**Developers**: Read CONTRIBUTING.md, pick a Help wanted item or a To do card, check vanilla's code for the behaviour, and paste the block `python3 tools/pr_ready.py` prints in your pull request.
|
||||
|
||||
## Now
|
||||
|
||||
### Performance
|
||||
|
||||
#### Steady frames at long render distances
|
||||
|
||||
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster block lookups
|
||||
|
||||
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Modpacks' world generation, natively
|
||||
|
||||
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Playing on servers: everything shows as it should
|
||||
|
||||
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server dialogs and login screens
|
||||
|
||||
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Pre-generate an area with /pregen
|
||||
|
||||
A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### 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.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
|
||||
|
||||
More: [docs/mods.md](docs/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>
|
||||
|
||||
API 0.3, experimental first ([#1833](https://github.com/ludovic111/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 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>
|
||||
|
||||
Capability `ui` is in review ([#1864](https://github.com/ludovic111/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: 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>
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
|
||||
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### 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.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Play in your language
|
||||
|
||||
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
|
||||
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon's own shader format and kit
|
||||
|
||||
Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
|
||||
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### A leaner codebase that builds faster
|
||||
|
||||
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
|
||||
</details>
|
||||
|
||||
## Next
|
||||
|
||||
### Performance
|
||||
|
||||
#### Fresh benchmarks against vanilla
|
||||
|
||||
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster world creation and entry on small CPUs
|
||||
|
||||
Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Servers that hold 20 TPS with 50 to 100 players
|
||||
|
||||
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
|
||||
|
||||
</details>
|
||||
|
||||
#### Low-end computers: Steam Deck, old Macs, 8 GB
|
||||
|
||||
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
|
||||
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native upscaling
|
||||
|
||||
Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
|
||||
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### CurseForge in the mod browser (coming soon)
|
||||
|
||||
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A Convert to Rust button for what isn't rewritten yet
|
||||
|
||||
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods with code: machines, magic, mobs
|
||||
|
||||
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Data packs in the mod browser
|
||||
|
||||
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Prepare a version without Java
|
||||
|
||||
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server console, bans and a full admin API
|
||||
|
||||
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Hosting polish: Windows service, safe updates
|
||||
|
||||
Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
|
||||
|
||||
</details>
|
||||
|
||||
#### Guided hosting with Tailscale and an invite button
|
||||
|
||||
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
|
||||
|
||||
More: [docs/hosting.md](docs/hosting.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### An official Docker image for servers
|
||||
|
||||
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
|
||||
|
||||
More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### A server you install with one command
|
||||
|
||||
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
|
||||
Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
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/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Machines and blocks with inventories (API 0.4)
|
||||
|
||||
Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
|
||||
|
||||
</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
|
||||
|
||||
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Villages and combat, exactly as in vanilla
|
||||
|
||||
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Choose the audio output device
|
||||
|
||||
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Well-known shader looks, rewritten for Horizon
|
||||
|
||||
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Microsoft sign-in (coming soon)
|
||||
|
||||
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>
|
||||
|
||||
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/ludovic111/horizon/issues/1270)
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
#### Wayland and Hyprland polish
|
||||
|
||||
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### A versioned agent-control protocol
|
||||
|
||||
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
|
||||
|
||||
More: [docs/api/stability.md](docs/api/stability.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Port mods with your own AI agent
|
||||
|
||||
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### Horizon on X (coming soon)
|
||||
|
||||
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### Pipeline protection and a GitHub App for agents
|
||||
|
||||
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Signed updates everywhere, installers included
|
||||
|
||||
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
## Later
|
||||
|
||||
### Performance
|
||||
|
||||
#### Exact far terrain from real chunks
|
||||
|
||||
Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Older Fabric and Forge mods
|
||||
|
||||
Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A modpack's own resource and shader packs
|
||||
|
||||
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Whole modpacks, such as ATM10, rewritten
|
||||
|
||||
Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part ([docs/modpacks.md](docs/modpacks.md)). It depends on 'Mods with code' and the native mod API.
|
||||
|
||||
</details>
|
||||
|
||||
#### Running Java mods that aren't rewritten
|
||||
|
||||
Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Ely.by and other sign-in providers
|
||||
|
||||
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
|
||||
</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.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Play with Friends joins over an encrypted tunnel with an invite code ([docs/play-with-friends.md](docs/play-with-friends.md), 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### 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.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
|
||||
|
||||
</details>
|
||||
|
||||
#### Native versions of popular client mods
|
||||
|
||||
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 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/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Create-style physics as a native mod
|
||||
|
||||
Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
|
||||
</details>
|
||||
|
||||
#### Proximity voice chat as a native mod
|
||||
|
||||
Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Every Minecraft version, prepared automatically
|
||||
|
||||
Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
|
||||
|
||||
</details>
|
||||
|
||||
#### Controllers: tested on real pads, and in the launcher
|
||||
|
||||
Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### A light default shader pack
|
||||
|
||||
A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Optional ray tracing
|
||||
|
||||
An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
|
||||
|
||||
</details>
|
||||
|
||||
#### HDR monitors
|
||||
|
||||
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
#### MSAA, anisotropic filtering, exclusive fullscreen
|
||||
|
||||
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
|
||||
|
||||
<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; exclusive fullscreen where the backend supports it.
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Lean installs that use less disk
|
||||
|
||||
Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started.
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Linux ARM64
|
||||
|
||||
Game and launcher builds for 64-bit ARM Linux computers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
</details>
|
||||
|
||||
#### AppImage and a Nix flake
|
||||
|
||||
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### A complete video studio inside the game
|
||||
|
||||
Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
|
||||
|
||||
</details>
|
||||
|
||||
#### Game menus in the launcher's style
|
||||
|
||||
An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following [docs/design-system.md](docs/design-system.md); off by default because it changes the vanilla look.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### An optional AI teammate
|
||||
|
||||
An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### A video series about Horizon
|
||||
|
||||
A series of videos about how Horizon is made and what is new, with each upload announced on Discord.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video.
|
||||
|
||||
</details>
|
||||
|
||||
## Help wanted
|
||||
|
||||
### Performance
|
||||
|
||||
#### Benchmark your computer
|
||||
|
||||
Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### End city banners keep their pattern
|
||||
|
||||
Banners in End cities show as plain magenta instead of with their pattern.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
|
||||
|
||||
</details>
|
||||
|
||||
#### Enchanted loot in End cities
|
||||
|
||||
Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
|
||||
- **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/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/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/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
|
||||
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
|
||||
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
|
||||
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
|
||||
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
|
||||
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
|
||||
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
|
||||
@@ -22,6 +22,8 @@ pub struct Report {
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod has code to run (a content mod may have none).
|
||||
pub code: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
@@ -50,6 +52,7 @@ impl Report {
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"code": self.code,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
@@ -88,7 +91,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::Mod {
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -143,6 +148,7 @@ pub fn check(
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
code: false,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
@@ -154,10 +160,14 @@ pub fn check(
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards.
|
||||
/// storage lands in a folder removed afterwards. A content mod has no code to
|
||||
/// run: `inspect` already read its content as the game does.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
|
||||
let Some(wasm) = package.wasm.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
report.code = true;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
@@ -212,6 +222,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
players: vec![steve.clone()],
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snapshot(0));
|
||||
mods.player_joined(&steve);
|
||||
@@ -241,9 +254,19 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
});
|
||||
}
|
||||
let shapes = mods.hud_shapes().count();
|
||||
if shapes > 0 {
|
||||
report.output.push(format!("drew {shapes} HUD shape(s)"));
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
None => Ok(()),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
|
||||
@@ -10,7 +12,7 @@
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
@@ -40,7 +42,7 @@ fn main() -> ExitCode {
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -94,7 +96,7 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let files = [
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
@@ -104,13 +106,62 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
include_str!("../template/tests/hello.toml"),
|
||||
),
|
||||
];
|
||||
for (file, text) in files {
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
),
|
||||
(
|
||||
"data/{id}/horizon/block/glowing_amethyst.json",
|
||||
include_str!("../template/content/block.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/blockstates/glowing_amethyst.json",
|
||||
include_str!("../template/content/blockstate.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/models/block/glowing_amethyst.json",
|
||||
include_str!("../template/content/model.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/items/glowing_amethyst.json",
|
||||
include_str!("../template/content/item.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/lang/en_us.json",
|
||||
include_str!("../template/content/lang.json"),
|
||||
),
|
||||
(
|
||||
"data/{id}/loot_table/blocks/glowing_amethyst.json",
|
||||
include_str!("../template/content/loot.json"),
|
||||
),
|
||||
(
|
||||
"data/{id}/recipe/glowing_amethyst.json",
|
||||
include_str!("../template/content/recipe.json"),
|
||||
),
|
||||
(
|
||||
"data/minecraft/tags/block/mineable/pickaxe.json",
|
||||
include_str!("../template/content/pickaxe.json"),
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
Content::read(dir, &manifest)?;
|
||||
println!(
|
||||
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
|
||||
dir.display()
|
||||
@@ -118,14 +169,53 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
|
||||
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
|
||||
/// the manifest, the crate's component (none without a `Cargo.toml`: a
|
||||
/// content mod) and the files in `assets/` and `data/`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(MANIFEST)
|
||||
build_in(Path::new("."), release)
|
||||
}
|
||||
|
||||
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
|
||||
let (wasm, target) = compile(root, release)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
(Some(wasm), target)
|
||||
} else if manifest.capabilities.contains(&Capability::Registries) {
|
||||
(None, root.join("target"))
|
||||
} else {
|
||||
return Err(format!(
|
||||
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
|
||||
));
|
||||
};
|
||||
// The same checks as the game's, and the files in the order it lists them.
|
||||
let content = Content::read(root, &manifest)?;
|
||||
let mut reader = content.reader()?;
|
||||
let mut files = Vec::with_capacity(content.files.len());
|
||||
for name in &content.files {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.args([
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
@@ -153,17 +243,8 @@ fn build(release: bool) -> Result<PathBuf, String> {
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
Ok((wasm, target.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
@@ -304,4 +385,49 @@ mod tests {
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
let path = dir.join("gems");
|
||||
let new_args = [
|
||||
path.display().to_string(),
|
||||
"--template".into(),
|
||||
"content".into(),
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path, true).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
let content = Content::read(&hmod, &package.manifest).unwrap();
|
||||
assert!(
|
||||
content
|
||||
.files
|
||||
.contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
assert!(!report.code);
|
||||
assert!(
|
||||
report.text().contains("no code: content only"),
|
||||
"{}",
|
||||
report.text()
|
||||
);
|
||||
// Without registries, a mod needs code.
|
||||
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
|
||||
let manifest = manifest.replace("[\"registries\"]", "[]");
|
||||
std::fs::write(path.join(MANIFEST), manifest).unwrap();
|
||||
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"template": "minecraft:amethyst_block",
|
||||
"properties": { "hardness": 2.0, "light": 7 }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"variants": {
|
||||
"": { "model": "{id}:block/glowing_amethyst" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks and items to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"block.{id}.glowing_amethyst": "Glowing Amethyst"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"condition": { "type": "minecraft:survives_explosion" },
|
||||
"entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
|
||||
"rolls": 1
|
||||
}
|
||||
],
|
||||
"random_sequence": "{id}:blocks/glowing_amethyst"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": { "all": "minecraft:block/amethyst_block" }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"values": ["{id}:glowing_amethyst"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shapeless",
|
||||
"category": "building",
|
||||
"ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
|
||||
"result": { "id": "{id}:glowing_amethyst" }
|
||||
}
|
||||
@@ -18,10 +18,12 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
@@ -68,6 +70,11 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -367,6 +367,7 @@ pub fn main() -> Result<()> {
|
||||
lid: &|_| open,
|
||||
sign: &|_| sign.as_ref(),
|
||||
banner: &|_| layers.clone(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! usage: chat_screen_fixture RESOURCES REGISTRIES CASES OUTPUT
|
||||
use crate::pack_screen_fixture;
|
||||
use horizon_client::{
|
||||
chat::{ChatAction, ChatScreen, Commands},
|
||||
chat::{ChatAbilities, ChatAction, ChatScreen, ChatVisibility, Commands},
|
||||
command::vanilla,
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
@@ -53,6 +53,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
history.len(),
|
||||
w,
|
||||
h,
|
||||
ChatAbilities::new(ChatVisibility::Full),
|
||||
);
|
||||
screen.auto_suggestions = case["autoSuggest"] == true;
|
||||
let mut closed = false;
|
||||
|
||||
@@ -319,6 +319,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
atlas,
|
||||
comp(&m["text"]),
|
||||
indicator,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
m["at"].as_u64().unwrap_or(0),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Lore tooltip companion for tools/test_lore_tooltip_parity.py: the spans
|
||||
//! `item_tooltip::lore_texts` gives each stack (SNBT, one per line).
|
||||
use horizon_client::{
|
||||
item_data::parse_stack,
|
||||
item_tooltip::{TooltipDisplay, lore_texts},
|
||||
};
|
||||
use std::{error::Error, fmt::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1])?;
|
||||
let mut output = String::new();
|
||||
for line in input
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
{
|
||||
let stack = parse_stack(line)?.ok_or("empty stack")?;
|
||||
let display = TooltipDisplay::for_stack(&stack);
|
||||
if !display.shows("minecraft:lore") {
|
||||
writeln!(output, "-")?;
|
||||
continue;
|
||||
}
|
||||
let lines = lore_texts(&stack);
|
||||
if lines.is_empty() {
|
||||
writeln!(output, "-")?;
|
||||
continue;
|
||||
}
|
||||
let mut rendered = Vec::new();
|
||||
for text in lines {
|
||||
let mut spans = Vec::new();
|
||||
for span in &text.spans {
|
||||
if span.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let style = &span.style;
|
||||
spans.push(format!(
|
||||
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
|
||||
span.text,
|
||||
style.color.map(|c| c as i32).unwrap_or(-1),
|
||||
style.bold == Some(true),
|
||||
style.italic == Some(true),
|
||||
style.underlined == Some(true),
|
||||
style.strikethrough == Some(true),
|
||||
style.obfuscated == Some(true),
|
||||
));
|
||||
}
|
||||
rendered.push(spans.join(" | "));
|
||||
}
|
||||
writeln!(output, "{}", rendered.join(" || "))?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -49,6 +49,7 @@ mod input_probe;
|
||||
mod interaction_input_fixture;
|
||||
mod lan_probe;
|
||||
mod light_parity;
|
||||
mod lore_tooltip_fixture;
|
||||
mod marker_particles_probe;
|
||||
mod menu_fixture;
|
||||
mod mipmap_probe;
|
||||
@@ -133,6 +134,7 @@ probes! {
|
||||
interaction_input_fixture,
|
||||
lan_probe,
|
||||
light_parity,
|
||||
lore_tooltip_fixture,
|
||||
marker_particles_probe,
|
||||
menu_fixture,
|
||||
mipmap_probe,
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new());
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let case: Value = serde_json::from_str(&line?)?;
|
||||
|
||||
@@ -169,6 +169,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
cooking_timer: menu.data[2],
|
||||
cooking_total: menu.data[3],
|
||||
speed: 1.0,
|
||||
recipes_used: Default::default(),
|
||||
});
|
||||
menu.drops.clear();
|
||||
menu.equipped.clear();
|
||||
|
||||
@@ -598,6 +598,7 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
|
||||
let _ = writeln!(out, " removed {reason}");
|
||||
return;
|
||||
}
|
||||
let _ = write!(out, "\tleashed {}", u8::from(m.extra.leash.is_some()));
|
||||
let p = m.position;
|
||||
let v = m.velocity;
|
||||
let _ = write!(
|
||||
@@ -977,6 +978,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
// Set once the brain exists (below).
|
||||
x if x.starts_with("cooldown=") => {}
|
||||
"dead" => m.health = 0.0,
|
||||
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
|
||||
x if x.starts_with("leashto=") => {
|
||||
let n: u32 = x[8..].parse().unwrap();
|
||||
@@ -1097,6 +1099,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
};
|
||||
}
|
||||
let mut dropped_leads = 0;
|
||||
for t in 1..=s.ticks {
|
||||
world.game_time += 1;
|
||||
if s.daycycle {
|
||||
@@ -1208,6 +1211,16 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
if !alive {
|
||||
m.removed = true;
|
||||
}
|
||||
// Apply the same detach/drop event as Game::break_leash.
|
||||
for event in &events[from..] {
|
||||
if let MobEvent::LeashBreak { mob, drop, .. } = event
|
||||
&& let Some(m) = mobs.iter_mut().find(|m| m.id == *mob)
|
||||
&& horizon_client::mobs::leash::remove(m).is_some()
|
||||
&& *drop
|
||||
{
|
||||
dropped_leads += 1;
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
villager_events(
|
||||
&mut mobs,
|
||||
@@ -1427,6 +1440,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world.game_time,
|
||||
);
|
||||
}
|
||||
if s.name.starts_with("leash_death_") {
|
||||
let _ = writeln!(out, "{t} leads {dropped_leads}");
|
||||
}
|
||||
for e in &events {
|
||||
if let MobEvent::Sound(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(
|
||||
|
||||
@@ -152,7 +152,7 @@ pub fn main() -> Result<()> {
|
||||
world(&assets, &facts, false, true)?,
|
||||
world(&assets, &facts, true, true)?,
|
||||
];
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new());
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
|
||||
for (ty, item) in [
|
||||
(ParticleType::ItemSlime, "item_slime_ball"),
|
||||
(ParticleType::ItemSnowball, "item_snowball"),
|
||||
|
||||
@@ -63,6 +63,10 @@ enum Animate {
|
||||
SculkSensor,
|
||||
/// Potent sulfur that is not dry.
|
||||
PotentSulfur,
|
||||
DriedGhast {
|
||||
waterlogged: bool,
|
||||
},
|
||||
EndPortal,
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -137,6 +141,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
drag: prop("drag").as_deref() == Some("true"),
|
||||
},
|
||||
"fire" | "soul_fire" => Animate::Fire,
|
||||
"dried_ghast" => Animate::DriedGhast {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
"end_portal" => Animate::EndPortal,
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
|
||||
{
|
||||
@@ -180,6 +188,8 @@ pub struct AnimateTables {
|
||||
impermeable: Vec<bool>,
|
||||
power_provider: Vec<bool>,
|
||||
power_transmitter: Vec<bool>,
|
||||
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
|
||||
dried_ghast_sounds: Vec<bool>,
|
||||
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
|
||||
ambient: Vec<Vec<(T, f32)>>,
|
||||
}
|
||||
@@ -237,6 +247,7 @@ impl AnimateTables {
|
||||
impermeable: by_state("minecraft:impermeable"),
|
||||
power_provider: by_state("minecraft:enchantment_power_provider"),
|
||||
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
|
||||
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
|
||||
ambient,
|
||||
})
|
||||
}
|
||||
@@ -869,6 +880,45 @@ fn block_animate_tick(
|
||||
];
|
||||
add(engine, T::Smoke, p, [0.0; 3]);
|
||||
}
|
||||
Animate::DriedGhast { waterlogged } => {
|
||||
// DriedGhastBlock.animateTick.
|
||||
let c = [fx + 0.5, fy + 0.5, fz + 0.5];
|
||||
if waterlogged {
|
||||
if next_int(engine.random(), 40) == 0 {
|
||||
let name = "minecraft:block.dried_ghast.ambient_water";
|
||||
sound(engine, name, c, 1.0, 1.0);
|
||||
}
|
||||
let r = engine.random();
|
||||
if next_int(r, 6) == 0 {
|
||||
let x = c[0] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
|
||||
let y = c[1] + 0.4;
|
||||
let z = c[2] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
|
||||
let v = [0.0, f64::from(r.next_float()), 0.0];
|
||||
add(engine, T::HappyVillager, [x, y, z], v);
|
||||
}
|
||||
} else {
|
||||
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
|
||||
let triggers = || {
|
||||
tables
|
||||
.dried_ghast_sounds
|
||||
.get(below.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if next_int(engine.random(), 40) == 0 && triggers() {
|
||||
sound(engine, "minecraft:block.dried_ghast.ambient", c, 1.0, 1.0);
|
||||
}
|
||||
if next_int(engine.random(), 6) == 0 {
|
||||
add(engine, T::WhiteSmoke, c, [0.0, 0.02, 0.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::EndPortal => {
|
||||
// EndPortalBlock.animateTick: one smoke particle above the surface.
|
||||
let r = engine.random();
|
||||
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
|
||||
add(engine, T::Smoke, p, [0.0; 3]);
|
||||
}
|
||||
Animate::EnderChest => {
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
|
||||
@@ -143,6 +143,7 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"font",
|
||||
"packs",
|
||||
"mods",
|
||||
"shader-packs",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
|
||||
@@ -730,6 +730,10 @@ pub struct Scene<'a> {
|
||||
pub sign: &'a dyn Fn([i32; 3]) -> Option<&'a crate::signs::SignData>,
|
||||
/// Banner layers at a position: pattern texture and RGB tint, bottom first.
|
||||
pub banner: &'a dyn Fn([i32; 3]) -> Vec<(String, u32)>,
|
||||
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
}
|
||||
@@ -1279,6 +1283,13 @@ impl BlockEntityRenderer {
|
||||
SkullType::Piglin => ("piglin_head", "entity/piglin/piglin"),
|
||||
SkullType::Player => ("player_head", "entity/player/slim/steve"),
|
||||
};
|
||||
// SkullBlockRenderer.getRenderType: the profile's skin when it
|
||||
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
|
||||
let texture = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos).unwrap_or(texture)
|
||||
} else {
|
||||
texture
|
||||
};
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
@@ -1886,6 +1897,7 @@ mod tests {
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
|
||||
@@ -87,6 +87,19 @@ fn filled(player: &mut dyn Player, new: ItemStack, limit_creative: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LayeredCauldronBlock.handleEntityOnFireInside`: a burning entity in a
|
||||
/// water cauldron lowers it a level; a powder snow cauldron becomes a water
|
||||
/// cauldron of its level first, then lowers.
|
||||
pub fn extinguished(cauldron: &BlockState) -> BlockState {
|
||||
if cauldron.name == "minecraft:powder_snow_cauldron" {
|
||||
let mut water = cauldron.clone();
|
||||
water.name = "minecraft:water_cauldron".into();
|
||||
lowered(&water)
|
||||
} else {
|
||||
lowered(cauldron)
|
||||
}
|
||||
}
|
||||
|
||||
/// `LayeredCauldronBlock.lowerFillLevel`.
|
||||
fn lowered(cauldron: &BlockState) -> BlockState {
|
||||
let level: u8 = cauldron
|
||||
@@ -296,3 +309,24 @@ pub fn interact(
|
||||
_ => pass(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
assert_eq!(extinguished(&water(3)), water(2));
|
||||
assert_eq!(extinguished(&water(1)), state("minecraft:cauldron", None));
|
||||
// Powder snow turns to water of its level, then lowers.
|
||||
assert_eq!(
|
||||
extinguished(&state("minecraft:powder_snow_cauldron", Some(3))),
|
||||
water(2)
|
||||
);
|
||||
assert_eq!(
|
||||
extinguished(&state("minecraft:powder_snow_cauldron", Some(1))),
|
||||
state("minecraft:cauldron", None)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,92 @@ mod tests;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChatVisibility {
|
||||
Full,
|
||||
System,
|
||||
Hidden,
|
||||
}
|
||||
impl ChatVisibility {
|
||||
/// Options.chatVisibility's saved value (0 full, 1 system, 2 hidden).
|
||||
pub fn from_option(value: u8) -> Self {
|
||||
match value {
|
||||
0 => Self::Full,
|
||||
1 => Self::System,
|
||||
_ => Self::Hidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatAbilities`: what the Chat option allows, as `Minecraft.computeChatAbilities`
|
||||
/// derives it from the visibility alone (`ChatRestriction`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ChatAbilities {
|
||||
pub send_messages: bool,
|
||||
pub send_commands: bool,
|
||||
pub receive_player_messages: bool,
|
||||
pub receive_system_messages: bool,
|
||||
}
|
||||
impl ChatAbilities {
|
||||
/// `ChatRestriction.CHAT_AND_COMMANDS_DISABLED_BY_OPTIONS` (Hidden) takes
|
||||
/// chat, commands, player messages and system messages;
|
||||
/// `CHAT_DISABLED_BY_OPTIONS` (System) takes the chat and player messages.
|
||||
pub fn new(visibility: ChatVisibility) -> Self {
|
||||
match visibility {
|
||||
ChatVisibility::Full => Self {
|
||||
send_messages: true,
|
||||
send_commands: true,
|
||||
receive_player_messages: true,
|
||||
receive_system_messages: true,
|
||||
},
|
||||
ChatVisibility::System => Self {
|
||||
send_messages: false,
|
||||
send_commands: true,
|
||||
receive_player_messages: false,
|
||||
receive_system_messages: true,
|
||||
},
|
||||
ChatVisibility::Hidden => Self {
|
||||
send_messages: false,
|
||||
send_commands: false,
|
||||
receive_player_messages: false,
|
||||
receive_system_messages: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
/// `visibleMessagesFilter`: the client's own lines always show.
|
||||
pub fn shows(&self, source: ChatSource) -> bool {
|
||||
match source {
|
||||
ChatSource::Player => self.receive_player_messages,
|
||||
ChatSource::Server => self.receive_system_messages,
|
||||
ChatSource::Client => true,
|
||||
}
|
||||
}
|
||||
/// Whether the chat screen may send `input` (`hasAllowedInput`).
|
||||
pub fn allows(&self, input: &str) -> bool {
|
||||
if input.starts_with('/') {
|
||||
self.send_commands
|
||||
} else {
|
||||
self.send_messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatAbilities {
|
||||
fn default() -> Self {
|
||||
Self::new(ChatVisibility::Full)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a chat line came from (`GuiMessageSource`): the visibility filters
|
||||
/// player messages and the server's, while the client's own always show.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChatSource {
|
||||
Player,
|
||||
Server,
|
||||
Client,
|
||||
}
|
||||
|
||||
/// The chat options (doubles, as vanilla keeps them).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChatOptions {
|
||||
@@ -71,6 +157,7 @@ struct Message {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -105,6 +192,8 @@ pub struct Chat {
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
pub received: u64,
|
||||
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
|
||||
abilities: ChatAbilities,
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
@@ -114,16 +203,32 @@ impl Chat {
|
||||
fn max_width(&self) -> i32 {
|
||||
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
|
||||
}
|
||||
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
|
||||
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines. A
|
||||
/// message the Chat option hides is dropped, unlogged.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
// ChatComponent.logChatMessage: every message is in the log.
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
}
|
||||
fn add_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
if !self.abilities.shows(source) {
|
||||
return;
|
||||
}
|
||||
// ChatComponent.logChatMessage: every message shown is in the log.
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
|
||||
if self.log {
|
||||
println!("[CHAT] {}", content.plain());
|
||||
@@ -132,16 +237,17 @@ impl Chat {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
};
|
||||
self.add_lines(atlas, &message, focused);
|
||||
self.add_lines(font, &message, focused);
|
||||
self.messages.insert(0, message);
|
||||
self.messages.truncate(100);
|
||||
self.received += 1;
|
||||
}
|
||||
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
|
||||
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
let content = self.displayed(&message.content);
|
||||
for line in atlas.font.wrap_components(&content, width) {
|
||||
for line in font.wrap_components(&content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
self.new_since_scroll = true;
|
||||
self.scroll_by(1, focused);
|
||||
@@ -167,16 +273,38 @@ impl Chat {
|
||||
content.strip_legacy()
|
||||
}
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
|
||||
/// keeping only the messages the Chat option shows.
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.lines.clear();
|
||||
let messages: Vec<Message> = self.messages.iter().rev().cloned().collect();
|
||||
let messages: Vec<Message> = self
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|m| self.abilities.shows(m.source))
|
||||
.cloned()
|
||||
.collect();
|
||||
for m in &messages {
|
||||
self.add_lines(atlas, m, false);
|
||||
self.add_lines(font, m, false);
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
|
||||
/// the messages it now hides leave the chat, those it shows again return.
|
||||
pub fn set_abilities(&mut self, font: &crate::font::Font, abilities: ChatAbilities) {
|
||||
if self.abilities != abilities {
|
||||
self.abilities = abilities;
|
||||
self.rewrap(font);
|
||||
}
|
||||
}
|
||||
/// Whether a message from `source` would show (`visibleMessagesFilter`).
|
||||
pub fn shows(&self, source: ChatSource) -> bool {
|
||||
self.abilities.shows(source)
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.lines.clear();
|
||||
@@ -437,6 +565,8 @@ pub struct ChatScreen {
|
||||
allow_suggestions: bool,
|
||||
keep_suggestions: bool,
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -482,6 +612,7 @@ impl ChatScreen {
|
||||
history_len: usize,
|
||||
width: i32,
|
||||
height: i32,
|
||||
abilities: ChatAbilities,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(4, height - 12, width - 4, 12);
|
||||
input.max_length = 256;
|
||||
@@ -505,6 +636,7 @@ impl ChatScreen {
|
||||
allow_suggestions: false,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -522,7 +654,8 @@ impl ChatScreen {
|
||||
if (width, height) != (self.width, self.height) {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height);
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -604,6 +737,14 @@ impl ChatScreen {
|
||||
self.pending = Some((range.0, list));
|
||||
self.usage_width = 0;
|
||||
self.usage_position = 0;
|
||||
// CommandSuggestions.updateCommandInfo: the Chat option may take
|
||||
// the messages.
|
||||
if !self.abilities.send_messages {
|
||||
let line =
|
||||
Text::translate("chat_screen.messages_not_allowed", &[]).with_color(text::RED);
|
||||
self.usage_width = m.width(&line);
|
||||
self.usage.push(line);
|
||||
}
|
||||
} else {
|
||||
self.awaiting = None;
|
||||
self.pending = None;
|
||||
@@ -708,6 +849,15 @@ impl ChatScreen {
|
||||
}
|
||||
self.usage.extend(entries);
|
||||
}
|
||||
// CommandSuggestions.updateUsageInfo: the Chat option may take the
|
||||
// commands, under the usage. (Vanilla also counts a command with a
|
||||
// message argument, `/say hi`, as a message; Horizon's parse keeps no
|
||||
// argument kinds, so only a bare message does.)
|
||||
if !self.abilities.send_commands {
|
||||
self.usage.push(
|
||||
Text::translate("chat_screen.commands_not_allowed", &[]).with_color(text::RED),
|
||||
);
|
||||
}
|
||||
self.usage_width = self.usage.iter().map(|u| m.width(u)).max().unwrap_or(0);
|
||||
self.usage_position = if self.usage.is_empty() {
|
||||
0
|
||||
@@ -842,7 +992,14 @@ impl ChatScreen {
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => return ChatAction::Submit(self.normalized()),
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
|
||||
@@ -2,6 +2,11 @@ use super::*;
|
||||
use crate::{font::Font, hud::Rect};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// The Chat option's default: no restriction.
|
||||
fn full() -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::Full)
|
||||
}
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
Font::load(
|
||||
@@ -139,7 +144,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
|
||||
assert_eq!(link.component()["click_event"]["command"], expected);
|
||||
let mut chat = Chat::default();
|
||||
add(&mut chat, &font, Text::literal("Found at ").append(link));
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
|
||||
let x = 4.0 + f64::from(font.width("Found at "));
|
||||
assert!(!screen.click(&m, &commands, x, 315.0));
|
||||
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
|
||||
@@ -176,7 +181,15 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
|
||||
registries: ®istries,
|
||||
};
|
||||
let history = vec!["first message".to_owned(), "latest message".to_owned()];
|
||||
let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
|
||||
let mut screen = ChatScreen::new(
|
||||
&m,
|
||||
&commands,
|
||||
"unfinished draft",
|
||||
history.len(),
|
||||
640,
|
||||
360,
|
||||
full(),
|
||||
);
|
||||
for (key, expected) in [
|
||||
(ArrowUp, "latest message"),
|
||||
(ArrowUp, "first message"),
|
||||
@@ -213,7 +226,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
dispatcher,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
screen
|
||||
};
|
||||
@@ -259,7 +272,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
let tab = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
let (request, sent) = screen.take_request().expect("the server is asked");
|
||||
assert_eq!(sent, "/bossbar get pt");
|
||||
@@ -281,7 +294,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
dispatcher: local,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
}
|
||||
@@ -327,3 +340,99 @@ fn a_server_component_keeps_its_click_and_hover_events() {
|
||||
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
|
||||
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
|
||||
}
|
||||
|
||||
/// `ChatVisiblity`: Commands Only keeps the chat out, Hidden takes the
|
||||
/// commands too (`ChatAbilities`), and Enter sends nothing the option hides.
|
||||
#[test]
|
||||
fn the_chat_option_decides_what_may_be_sent() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let enter = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Enter, false, false)
|
||||
};
|
||||
for (visibility, chat, command) in [
|
||||
(ChatVisibility::Full, true, true),
|
||||
(ChatVisibility::System, false, true),
|
||||
(ChatVisibility::Hidden, false, false),
|
||||
] {
|
||||
let abilities = ChatAbilities::new(visibility);
|
||||
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
|
||||
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
|
||||
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
|
||||
screen.type_text(&m, &c, line);
|
||||
match enter(&mut screen) {
|
||||
ChatAction::Submit(sent) => {
|
||||
assert!(allowed, "{visibility:?} sent {sent}");
|
||||
assert_eq!(sent, line);
|
||||
}
|
||||
ChatAction::None => assert!(!allowed, "{visibility:?} kept {line}"),
|
||||
ChatAction::Close => panic!("Enter does not close the chat"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The restriction the option sets is a usage line: red, under the input, for
|
||||
/// a command the Chat option takes and for a message it takes.
|
||||
#[test]
|
||||
fn restricted_input_says_why_it_will_not_send() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let usage = |visibility, line: &str| {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
|
||||
screen.type_text(&m, &c, line);
|
||||
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(usage(ChatVisibility::Full, "hi"), Vec::<String>::new());
|
||||
// The font has no language files here, so the line is its key.
|
||||
assert_eq!(
|
||||
usage(ChatVisibility::System, "hi"),
|
||||
["chat_screen.messages_not_allowed"]
|
||||
);
|
||||
// A command the option allows says nothing about the restriction.
|
||||
assert!(
|
||||
!usage(ChatVisibility::System, "/time set day")
|
||||
.iter()
|
||||
.any(|line| line == "chat_screen.commands_not_allowed")
|
||||
);
|
||||
assert!(
|
||||
usage(ChatVisibility::Hidden, "/time set day")
|
||||
.iter()
|
||||
.any(|line| line == "chat_screen.commands_not_allowed")
|
||||
);
|
||||
}
|
||||
|
||||
/// `ChatComponent.setVisibleMessageFilter`: a message the Chat option hides
|
||||
/// on arrival is dropped; one already in the chat leaves it while the option
|
||||
/// hides its kind and comes back when it shows it again.
|
||||
#[test]
|
||||
fn the_chat_option_filters_the_lines_both_ways() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
let says = |chat: &mut Chat, text: &str, source| {
|
||||
chat.add_message(&font, Text::literal(text), None, source, 0, false);
|
||||
};
|
||||
let shown =
|
||||
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
|
||||
says(&mut chat, "player", ChatSource::Player);
|
||||
says(&mut chat, "server", ChatSource::Server);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
|
||||
assert_eq!(shown(&chat), ["server"]);
|
||||
says(&mut chat, "dropped", ChatSource::Player);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Hidden));
|
||||
says(&mut chat, "client", ChatSource::Client);
|
||||
assert_eq!(shown(&chat), ["client"]);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
|
||||
assert_eq!(shown(&chat), ["client", "server", "player"]);
|
||||
}
|
||||
@@ -92,6 +92,13 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.is_suffocating(chunk, block, p)
|
||||
}
|
||||
fn may_reach_out(
|
||||
&mut self,
|
||||
chunk: Self::Chunk,
|
||||
section_y: i32,
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.may_reach_out(chunk, section_y)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
block: CollisionBlock,
|
||||
@@ -262,6 +269,17 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result<CollisionBlock> {
|
||||
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
|
||||
}
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
}))
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
_: Self::Chunk,
|
||||
|
||||
@@ -592,3 +592,21 @@ fn mod_commands_join_the_tree_for_every_player() {
|
||||
// The game's own tree is left as it was.
|
||||
assert!(vanilla::game_dispatcher().find("where").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_commands_are_off_unless_the_server_turns_them_on() {
|
||||
// By default the game runs vanilla's tree: /help lists no /pregen.
|
||||
assert!(vanilla::game_dispatcher().find("pregen").is_none());
|
||||
for dev in [false, true] {
|
||||
let vanilla_tree = vanilla::game_tree(dev, false);
|
||||
assert!(vanilla_tree.find("pregen").is_none());
|
||||
let d = vanilla::game_tree(dev, true);
|
||||
let parse = d.parse("pregen start 100 circle", ®());
|
||||
assert_eq!(parse.executable().unwrap().1, "pregen.start");
|
||||
// Vanilla's commands are the same with or without it.
|
||||
assert_eq!(
|
||||
d.nodes[0].children.len(),
|
||||
vanilla_tree.nodes[0].children.len() + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -538,12 +538,69 @@ fn apply_vanilla_order(d: &mut Dispatcher) {
|
||||
}
|
||||
/// The tree the game runs: a release integrated server's, or with
|
||||
/// `HORIZON_DEV_COMMANDS` set the development one
|
||||
/// (SharedConstants.DEBUG_DEV_COMMANDS).
|
||||
/// (SharedConstants.DEBUG_DEV_COMMANDS), plus Horizon's own commands when
|
||||
/// the server turned them on (`set_horizon_commands`).
|
||||
pub fn game_dispatcher() -> &'static Dispatcher {
|
||||
if std::env::var_os("HORIZON_DEV_COMMANDS").is_some() {
|
||||
dispatcher()
|
||||
} else {
|
||||
release_dispatcher()
|
||||
game_tree(
|
||||
std::env::var_os("HORIZON_DEV_COMMANDS").is_some(),
|
||||
HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
/// Whether this process's server has Horizon's own commands (`/pregen`). Off
|
||||
/// by default, so `/help` and the command tree are vanilla's; a dedicated
|
||||
/// server turns them on with `horizon-pregen=true` in server.properties, the
|
||||
/// game with `--pregen` (docs/pregeneration.md).
|
||||
static HORIZON_COMMANDS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Turns Horizon's own commands on or off for the trees `game_dispatcher`
|
||||
/// returns from now on.
|
||||
pub fn set_horizon_commands(on: bool) {
|
||||
HORIZON_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// The server.properties key that turns `/pregen` on (Horizon's own; vanilla
|
||||
/// ignores keys it doesn't know).
|
||||
pub const PREGEN_PROPERTY: &str = "horizon-pregen";
|
||||
pub(crate) fn game_tree(dev: bool, horizon: bool) -> &'static Dispatcher {
|
||||
static HORIZON: [std::sync::OnceLock<Dispatcher>; 2] =
|
||||
[const { std::sync::OnceLock::new() }; 2];
|
||||
match (dev, horizon) {
|
||||
(true, false) => dispatcher(),
|
||||
(false, false) => release_dispatcher(),
|
||||
(dev, true) => HORIZON[usize::from(dev)].get_or_init(|| {
|
||||
let mut d = build_with(dev);
|
||||
horizon_commands(&mut d, 0);
|
||||
d
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// Commands vanilla doesn't have, after its own: `/pregen`
|
||||
/// (docs/pregeneration.md).
|
||||
fn horizon_commands(d: &mut Dispatcher, root: usize) {
|
||||
let pregen = d.literal(root, "pregen");
|
||||
d.requires(pregen, GAMEMASTERS);
|
||||
let start = d.literal(pregen, "start");
|
||||
let radius = d.argument(
|
||||
start,
|
||||
"radius",
|
||||
ArgType::Integer {
|
||||
min: 0,
|
||||
max: 30_000_000,
|
||||
},
|
||||
);
|
||||
d.executes(radius, "pregen.start");
|
||||
for shape in ["square", "circle"] {
|
||||
let shape = d.literal(radius, shape);
|
||||
d.executes(shape, "pregen.start");
|
||||
let center = d.argument(shape, "center", ArgType::ColumnPos);
|
||||
d.executes(center, "pregen.start");
|
||||
}
|
||||
for (word, id) in [
|
||||
("pause", "pregen.pause"),
|
||||
("continue", "pregen.continue"),
|
||||
("cancel", "pregen.cancel"),
|
||||
("status", "pregen.status"),
|
||||
] {
|
||||
let w = d.literal(pregen, word);
|
||||
d.executes(w, id);
|
||||
}
|
||||
}
|
||||
pub fn dispatcher() -> &'static Dispatcher {
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct Furnace {
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
@@ -79,6 +83,60 @@ pub enum BlockEntity {
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
impl Skull {
|
||||
pub fn new(
|
||||
profile: Option<horizon_world::nbt::Tag>,
|
||||
note_block_sound: Option<String>,
|
||||
custom_name: Option<horizon_world::nbt::Tag>,
|
||||
) -> Self {
|
||||
let skin = profile.as_ref().map(SkullSkin::of);
|
||||
Self {
|
||||
profile,
|
||||
note_block_sound,
|
||||
custom_name,
|
||||
skin,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
impl SkullSkin {
|
||||
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
|
||||
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
|
||||
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
|
||||
let fallback = crate::skins::profile_uuid_nbt(profile)
|
||||
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
|
||||
Self { download, fallback }
|
||||
}
|
||||
}
|
||||
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
|
||||
/// `*_skull` and `*_head` but the piston's head.
|
||||
fn is_skull(name: &str) -> bool {
|
||||
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
@@ -190,6 +248,7 @@ impl BlockEntity {
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
@@ -233,6 +292,9 @@ impl BlockEntity {
|
||||
if name.ends_with("_banner") {
|
||||
return Some(Self::Banner(Banner::default()));
|
||||
}
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:beacon" {
|
||||
return Some(Self::Beacon(Box::new(Beacon {
|
||||
last_check_y: i32::MIN / 2,
|
||||
@@ -278,6 +340,7 @@ impl BlockEntity {
|
||||
Self::Vault(_) => "minecraft:vault",
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -293,7 +356,8 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => Vec::new(),
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Skull(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
@@ -310,7 +374,8 @@ impl BlockEntity {
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_) => &[],
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
@@ -330,7 +395,8 @@ impl BlockEntity {
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_) => &mut [],
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
@@ -483,6 +549,14 @@ impl Furnace {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
@@ -564,6 +638,7 @@ impl Furnace {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
@@ -594,3 +669,41 @@ fn total_time(time: u32, speed: f32) -> i32 {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// `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(
|
||||
count: u32,
|
||||
experience: f32,
|
||||
random: &mut impl horizon_core::random::RandomSource,
|
||||
) -> u32 {
|
||||
let total = count as f32 * experience;
|
||||
let whole = total.floor();
|
||||
let fraction = total - whole;
|
||||
let mut xp = whole.max(0.0) as u32;
|
||||
if fraction != 0.0 && random.next_float() < fraction {
|
||||
xp += 1;
|
||||
}
|
||||
xp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut untouched = LegacyRandom::new(7);
|
||||
assert_eq!(create_experience(4, 0.5, &mut random), 2);
|
||||
assert_eq!(create_experience(3, 0.0, &mut random), 0);
|
||||
assert_eq!(random.next_long(), untouched.next_long());
|
||||
let paid: u32 = (0..1000)
|
||||
.map(|_| create_experience(1, 0.35, &mut random))
|
||||
.sum();
|
||||
assert!((250..450).contains(&paid), "{paid}");
|
||||
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! `Player.getCooldowns()` (`ItemCooldowns`) as the game keeps it: the
|
||||
//! `use_cooldown` component's entries by cooldown group, counted down once a
|
||||
//! tick, and the percent `GuiGraphicsExtractor.itemCooldown` shades a slot
|
||||
//! by. The world crate's `item_cooldowns` models vanilla's hash map and its
|
||||
//! observers for the parity harness; this is the same data with the lookup a
|
||||
//! slot needs, and no observer (vanilla's own player uses the plain class).
|
||||
use crate::items::ItemStack;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stack's `minecraft:use_cooldown` component.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UseCooldown {
|
||||
pub seconds: f32,
|
||||
/// `cooldown_group`: the group the entry is filed under, else the item.
|
||||
pub group: Option<Arc<str>>,
|
||||
}
|
||||
impl UseCooldown {
|
||||
/// `UseCooldown.ticks`: the seconds in ticks, truncated.
|
||||
pub fn ticks(&self) -> i32 {
|
||||
(self.seconds * 20.0) as i32
|
||||
}
|
||||
/// The component as `items.json` holds it.
|
||||
pub fn from_json(v: &serde_json::Value) -> Option<Self> {
|
||||
let seconds = v.get("seconds")?.as_f64()? as f32;
|
||||
Some(Self {
|
||||
seconds,
|
||||
group: v
|
||||
.get("cooldown_group")
|
||||
.and_then(|g| g.as_str())
|
||||
.map(Arc::from),
|
||||
})
|
||||
}
|
||||
/// The component as a stack's saved `components` holds it.
|
||||
pub fn from_tag(t: &Tag) -> Option<Self> {
|
||||
let Tag::Compound(c) = t else {
|
||||
return None;
|
||||
};
|
||||
let text = |k: &str| match c.get(&horizon_world::nbt::JavaString::from(k)) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let seconds = match c.get(&horizon_world::nbt::JavaString::from("seconds")) {
|
||||
Some(Tag::Float(bits)) => f32::from_bits(*bits),
|
||||
Some(Tag::Double(bits)) => f64::from_bits(*bits) as f32,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self {
|
||||
seconds,
|
||||
group: text("cooldown_group").map(Arc::from),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The component a stack carries: its own patch first (`ItemStack.get`), then
|
||||
/// the item's default from `items.json` (`ItemInfo::use_cooldown`).
|
||||
pub fn component(stack: &ItemStack, default: Option<&UseCooldown>) -> Option<UseCooldown> {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:use_cooldown"))
|
||||
.and_then(UseCooldown::from_tag)
|
||||
.or_else(|| default.cloned())
|
||||
}
|
||||
|
||||
/// `ItemCooldowns.getCooldownGroup`: the component's group, else the item id.
|
||||
pub fn group(stack: &ItemStack, default: Option<&UseCooldown>) -> Arc<str> {
|
||||
component(stack, default)
|
||||
.and_then(|c| c.group)
|
||||
.unwrap_or_else(|| Arc::clone(&stack.item))
|
||||
}
|
||||
|
||||
/// `ItemCooldowns`: start and end ticks by group, and the tick counter.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cooldowns {
|
||||
tick_count: i32,
|
||||
entries: HashMap<Arc<str>, (i32, i32)>,
|
||||
}
|
||||
impl Cooldowns {
|
||||
/// `getCooldownPercent`: what is left of the entry, from 1 to 0.
|
||||
pub fn percent(&self, group: &str, partial_tick: f32) -> f32 {
|
||||
let Some((start, end)) = self.entries.get(group) else {
|
||||
return 0.0;
|
||||
};
|
||||
let duration = end.wrapping_sub(*start) as f32;
|
||||
let remaining = *end as f32 - (self.tick_count as f32 + partial_tick);
|
||||
(remaining / duration).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `isOnCooldown`: whether the group still has time to run.
|
||||
pub fn is_on_cooldown(&self, group: &str) -> bool {
|
||||
self.percent(group, 0.0) > 0.0
|
||||
}
|
||||
/// `addCooldown`.
|
||||
pub fn add(&mut self, group: Arc<str>, ticks: i32) {
|
||||
self.entries.insert(
|
||||
group,
|
||||
(self.tick_count, self.tick_count.wrapping_add(ticks)),
|
||||
);
|
||||
}
|
||||
/// `ItemCooldowns.tick`: the counter, then every entry that has run out,
|
||||
/// which it hands back (`onCooldownEnded`).
|
||||
pub fn tick(&mut self) -> Vec<Arc<str>> {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
let done: Vec<Arc<str>> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, (_, end))| *end <= self.tick_count)
|
||||
.map(|(group, _)| Arc::clone(group))
|
||||
.collect();
|
||||
self.entries.retain(|_, (_, end)| *end > self.tick_count);
|
||||
done
|
||||
}
|
||||
/// `removeCooldown`.
|
||||
pub fn remove(&mut self, group: &str) {
|
||||
self.entries.remove(group);
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn stack(item: &str) -> ItemStack {
|
||||
ItemStack::new(item, 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_percent_falls_from_one_to_zero_over_the_entry() {
|
||||
let mut c = Cooldowns::default();
|
||||
c.add(Arc::from("minecraft:ender_pearl"), 20);
|
||||
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 1.0);
|
||||
for _ in 0..10 {
|
||||
assert!(c.tick().is_empty());
|
||||
}
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.5);
|
||||
// The partial tick moves it within the tick.
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.5), 0.475);
|
||||
for _ in 0..9 {
|
||||
c.tick();
|
||||
}
|
||||
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
// ItemCooldowns.tick drops the entry at its end tick, and says so.
|
||||
assert_eq!(c.tick(), [Arc::<str>::from("minecraft:ender_pearl")]);
|
||||
assert!(!c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
assert!(c.is_empty());
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_group_comes_from_the_stack_then_the_item() {
|
||||
let pearl = stack("minecraft:ender_pearl");
|
||||
let default = UseCooldown::from_json(&json!({"seconds": 1.0})).expect("the component");
|
||||
assert_eq!(&*group(&pearl, Some(&default)), "minecraft:ender_pearl");
|
||||
// A stack's own component overrides the item's, group included.
|
||||
let mut grouped = stack("minecraft:ender_pearl");
|
||||
grouped.data_mut().set_other(
|
||||
"minecraft:use_cooldown",
|
||||
Some(Tag::compound([
|
||||
("seconds", Tag::Float(2.0f32.to_bits())),
|
||||
("cooldown_group", Tag::String("minecraft:pearls".into())),
|
||||
])),
|
||||
);
|
||||
let c = component(&grouped, Some(&default)).expect("the stack's component");
|
||||
assert_eq!(c.ticks(), 40);
|
||||
assert_eq!(&*group(&grouped, Some(&default)), "minecraft:pearls");
|
||||
// An item without the component has no cooldown.
|
||||
assert!(component(&stack("minecraft:dirt"), None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1610,6 +1610,14 @@ impl Recipes {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
|
||||
/// pack does not have (a save from another pack can name it).
|
||||
pub fn cooking_experience(&self, id: &str) -> f32 {
|
||||
self.cooking
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map_or(0.0, |c| c.experience)
|
||||
}
|
||||
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
|
||||
let index = self.cook_index.get_or_init(|| {
|
||||
let mut index = CookIndex::new();
|
||||
|
||||
@@ -71,10 +71,52 @@ impl Names {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| path.replace('_', " "))
|
||||
}
|
||||
/// An item's display name in the selected language: `item.*` then
|
||||
/// `block.*`, then the pinned English map (`Names::item`).
|
||||
pub fn display_item(&self, id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
if let Some(lang) = crate::lang::current() {
|
||||
for prefix in ["item", "block"] {
|
||||
if let Some(name) = lang.get(&format!("{prefix}.{namespace}.{path}")) {
|
||||
return name.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.item(id)
|
||||
}
|
||||
/// A translation key's text in the selected language, else the pinned
|
||||
/// English map (`HoverName` and tooltip keys such as potion names).
|
||||
pub fn display_get(&self, key: &str) -> Option<String> {
|
||||
crate::lang::current()
|
||||
.and_then(|lang| lang.get(key).map(str::to_owned))
|
||||
.or_else(|| self.get(key).map(str::to_owned))
|
||||
}
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.map.get(key).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreativeModeTabs`' key for an exported tab id (`itemGroup.*`); the
|
||||
/// export's `title` is the English fallback.
|
||||
pub fn item_group_key(id: &str) -> Option<&'static str> {
|
||||
Some(match id.trim_start_matches("minecraft:") {
|
||||
"building_blocks" => "itemGroup.buildingBlocks",
|
||||
"colored_blocks" => "itemGroup.coloredBlocks",
|
||||
"natural_blocks" => "itemGroup.natural",
|
||||
"functional_blocks" => "itemGroup.functional",
|
||||
"redstone_blocks" => "itemGroup.redstone",
|
||||
"hotbar" => "itemGroup.hotbar",
|
||||
"search" => "itemGroup.search",
|
||||
"tools_and_utilities" => "itemGroup.tools",
|
||||
"combat" => "itemGroup.combat",
|
||||
"food_and_drinks" => "itemGroup.foodAndDrink",
|
||||
"ingredients" => "itemGroup.ingredients",
|
||||
"spawn_eggs" => "itemGroup.spawnEggs",
|
||||
"op_blocks" => "itemGroup.op",
|
||||
"inventory" => "itemGroup.inventory",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
impl Creative {
|
||||
pub const WIDTH: f32 = 195.0;
|
||||
pub const HEIGHT: f32 = 136.0;
|
||||
@@ -303,7 +345,17 @@ impl Creative {
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
// The export's lines are English (they come from the vanilla jars);
|
||||
// the hover name in the selected language is matched too, so a
|
||||
// localized search finds the item. It is the whole name (a tipped
|
||||
// arrow's potion, the ominous banner), as the first line is.
|
||||
let text = java_lower(term);
|
||||
let localized = |i: usize| {
|
||||
java_lower(&crate::item_tooltip::name_line(
|
||||
&self.search_items[i],
|
||||
&|k| names.display_get(k),
|
||||
))
|
||||
};
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
@@ -314,11 +366,15 @@ impl Creative {
|
||||
&e.lines
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => any(lines.iter().map(String::as_str), &text),
|
||||
None => {
|
||||
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
|
||||
}
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path) || any(lines.iter().map(String::as_str), path))
|
||||
&& (e.path.contains(path)
|
||||
|| any(lines.iter().map(String::as_str), path)
|
||||
|| localized(i).contains(path))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -759,6 +815,24 @@ mod tests {
|
||||
assert_eq!(q("#craft"), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_the_display_name_in_the_selected_language() {
|
||||
let c = creative(0);
|
||||
let names = Names {
|
||||
map: HashMap::from([
|
||||
("item.minecraft.anvil".to_owned(), "铁砧".to_owned()),
|
||||
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
|
||||
]),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
assert_eq!(q("铁砧"), [0]);
|
||||
assert_eq!(q("木锄"), [2]);
|
||||
// The English tooltip lines the export indexed still match.
|
||||
assert_eq!(q("anvil"), [0]);
|
||||
// A colon's path searches the display name too.
|
||||
assert_eq!(q("minecraft:铁砧"), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
@@ -794,6 +868,20 @@ mod tests {
|
||||
assert!(!c.scrolling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_tab_titles_have_vanilla_keys() {
|
||||
assert_eq!(
|
||||
item_group_key("minecraft:building_blocks"),
|
||||
Some("itemGroup.buildingBlocks")
|
||||
);
|
||||
assert_eq!(
|
||||
item_group_key("minecraft:food_and_drinks"),
|
||||
Some("itemGroup.foodAndDrink")
|
||||
);
|
||||
assert_eq!(item_group_key("minecraft:hotbar"), Some("itemGroup.hotbar"));
|
||||
assert_eq!(item_group_key("minecraft:nonsense"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_moves_one_row_per_notch() {
|
||||
let mut c = creative(90);
|
||||
|
||||
@@ -84,6 +84,33 @@ impl Displayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
|
||||
/// pass's median milliseconds, in the order they run).
|
||||
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
|
||||
const SHOWN: usize = 8;
|
||||
if passes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total: f32 = passes.iter().map(|p| p.1).sum();
|
||||
let mut lines = vec![format!(
|
||||
"Shader pack GPU: {total:.2} ms in {} passes",
|
||||
passes.len()
|
||||
)];
|
||||
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
|
||||
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
lines.extend(
|
||||
costliest
|
||||
.iter()
|
||||
.take(SHOWN)
|
||||
.map(|(name, ms)| format!("{name}: {ms:.2} ms")),
|
||||
);
|
||||
if costliest.len() > SHOWN {
|
||||
let rest: f32 = costliest[SHOWN..].iter().map(|p| p.1).sum();
|
||||
lines.push(format!("{} more: {rest:.2} ms", costliest.len() - SHOWN));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
let wrapped = angle % 360.0;
|
||||
@@ -337,4 +364,15 @@ mod tests {
|
||||
(1.0, [640, 360])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_pass_lines_list_the_costliest_passes_first() {
|
||||
assert!(shader_pass_lines(&[]).is_empty());
|
||||
let passes: Vec<(String, f32)> = (0..10).map(|i| (format!("p{i}"), i as f32)).collect();
|
||||
let lines = shader_pass_lines(&passes);
|
||||
assert_eq!(lines[0], "Shader pack GPU: 45.00 ms in 10 passes");
|
||||
assert_eq!(lines[1], "p9: 9.00 ms");
|
||||
assert_eq!(lines[8], "p2: 2.00 ms");
|
||||
assert_eq!(lines[9], "2 more: 1.00 ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
//! Server dialogs (`net.minecraft.server.dialog`): what `Dialog.DIRECT_CODEC`
|
||||
//! decodes from a `show_dialog` packet, the values its inputs hold, and the
|
||||
//! click event an action makes of them (`Action.createAction`).
|
||||
use crate::text::Text;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `CommonDialogData` and the type's own fields.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Dialog {
|
||||
pub kind: Kind,
|
||||
pub title: Text,
|
||||
pub external_title: Option<Text>,
|
||||
pub can_close_with_escape: bool,
|
||||
pub pause: bool,
|
||||
pub after_action: AfterAction,
|
||||
pub body: Vec<Body>,
|
||||
pub inputs: Vec<Input>,
|
||||
}
|
||||
|
||||
/// The dialog types (`DialogTypes`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Kind {
|
||||
/// `NoticeDialog`: one button, "OK" when the server names none.
|
||||
Notice(Button),
|
||||
Confirmation {
|
||||
yes: Button,
|
||||
no: Button,
|
||||
},
|
||||
MultiAction {
|
||||
actions: Vec<Button>,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
},
|
||||
ServerLinks {
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
|
||||
DialogList {
|
||||
dialogs: Tag,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// `DialogAction`: the screen after an action runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AfterAction {
|
||||
Close,
|
||||
None,
|
||||
WaitForResponse,
|
||||
}
|
||||
|
||||
/// `ActionButton`: `CommonButtonData` and what it does.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Button {
|
||||
pub label: Text,
|
||||
pub tooltip: Option<Text>,
|
||||
pub width: i32,
|
||||
pub action: Option<Action>,
|
||||
}
|
||||
|
||||
/// `Action`: a fixed click event, or one made from the inputs' values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
/// `StaticAction`: a click event as it is.
|
||||
Static(Click),
|
||||
/// `CommandTemplate`: a command with `$(key)` replaced by input values.
|
||||
RunCommand(String),
|
||||
/// `CustomAll`: a custom click event whose payload is `additions` plus
|
||||
/// every input's value under its key.
|
||||
Custom { id: String, additions: Option<Tag> },
|
||||
}
|
||||
|
||||
/// `ClickEvent`, the kinds a server may send (`isAllowedFromServer`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
OpenUrl(String),
|
||||
RunCommand(String),
|
||||
SuggestCommand(String),
|
||||
/// The dialog: an id of the `minecraft:dialog` registry or the dialog itself.
|
||||
ShowDialog(Tag),
|
||||
ChangePage(i32),
|
||||
CopyToClipboard(String),
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<Tag>,
|
||||
},
|
||||
}
|
||||
|
||||
/// `DialogBody`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Body {
|
||||
/// `PlainMessage`: centred text wrapped to `width`.
|
||||
Message { contents: Text, width: i32 },
|
||||
/// `ItemBody`: the item as `ItemStackTemplate` sent it.
|
||||
Item {
|
||||
item: Tag,
|
||||
description: Option<(Text, i32)>,
|
||||
show_decorations: bool,
|
||||
show_tooltip: bool,
|
||||
width: i32,
|
||||
height: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Input`: the key its value goes by, and its control.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Input {
|
||||
pub key: String,
|
||||
pub control: Control,
|
||||
}
|
||||
|
||||
/// `InputControl`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Control {
|
||||
Text {
|
||||
width: i32,
|
||||
label: Text,
|
||||
label_visible: bool,
|
||||
initial: String,
|
||||
max_length: i32,
|
||||
/// `MultilineOptions`: the most lines and the height, when set.
|
||||
multiline: Option<(Option<i32>, Option<i32>)>,
|
||||
},
|
||||
Boolean {
|
||||
label: Text,
|
||||
initial: bool,
|
||||
on_true: String,
|
||||
on_false: String,
|
||||
},
|
||||
SingleOption {
|
||||
width: i32,
|
||||
/// Each option's id, its label and whether it is the initial one.
|
||||
options: Vec<(String, Option<Text>, bool)>,
|
||||
label: Text,
|
||||
label_visible: bool,
|
||||
},
|
||||
NumberRange {
|
||||
width: i32,
|
||||
label: Text,
|
||||
label_format: String,
|
||||
start: f32,
|
||||
end: f32,
|
||||
initial: Option<f32>,
|
||||
step: Option<f32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an input holds while the dialog is open.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum InputValue {
|
||||
Text(String),
|
||||
Bool(bool),
|
||||
/// The chosen option's index.
|
||||
Option(usize),
|
||||
/// The slider's position, 0 to 1.
|
||||
Slider(f32),
|
||||
}
|
||||
|
||||
fn text(t: &Tag) -> Text {
|
||||
crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))
|
||||
}
|
||||
fn string(t: Option<&Tag>) -> Option<String> {
|
||||
match t? {
|
||||
Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn number(t: Option<&Tag>) -> Option<f64> {
|
||||
Some(match t? {
|
||||
Tag::Byte(v) => f64::from(*v),
|
||||
Tag::Short(v) => f64::from(*v),
|
||||
Tag::Int(v) => f64::from(*v),
|
||||
Tag::Long(v) => *v as f64,
|
||||
Tag::Float(v) => f64::from(f32::from_bits(*v)),
|
||||
Tag::Double(v) => f64::from_bits(*v),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn int(t: &Tag, key: &str, default: i32) -> i32 {
|
||||
number(t.get(key)).map_or(default, |v| v as i32)
|
||||
}
|
||||
fn boolean(t: &Tag, key: &str, default: bool) -> bool {
|
||||
number(t.get(key)).map_or(default, |v| v != 0.0)
|
||||
}
|
||||
fn list(t: Option<&Tag>) -> &[Tag] {
|
||||
match t {
|
||||
Some(Tag::List(items)) => items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
/// A registry name without its default namespace.
|
||||
fn path(id: &str) -> &str {
|
||||
id.strip_prefix("minecraft:").unwrap_or(id)
|
||||
}
|
||||
|
||||
impl Button {
|
||||
/// `ActionButton.CODEC`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
Some(Self {
|
||||
label: text(t.get("label")?),
|
||||
tooltip: t.get("tooltip").map(text),
|
||||
width: int(t, "width", 150),
|
||||
action: t.get("action").and_then(Action::parse),
|
||||
})
|
||||
}
|
||||
/// `NoticeDialog.DEFAULT_ACTION`.
|
||||
fn ok() -> Self {
|
||||
Self {
|
||||
label: Text::translate("gui.ok", &[]),
|
||||
tooltip: None,
|
||||
width: 150,
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Click {
|
||||
/// `ClickEvent.CODEC`, from its `action` (a click event in a component)
|
||||
/// or `type` (an action) field.
|
||||
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
|
||||
let s = |key| string(t.get(key));
|
||||
Some(match path(kind) {
|
||||
"open_url" => Self::OpenUrl(s("url")?),
|
||||
"run_command" => Self::RunCommand(s("command")?),
|
||||
"suggest_command" => Self::SuggestCommand(s("command")?),
|
||||
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
|
||||
"change_page" => Self::ChangePage(int(t, "page", 1)),
|
||||
"copy_to_clipboard" => Self::CopyToClipboard(s("value")?),
|
||||
"custom" => Self::Custom {
|
||||
id: s("id")?,
|
||||
payload: t.get("payload").cloned(),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// `Action.CODEC`, dispatched on `type`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let kind = string(t.get("type"))?;
|
||||
Some(match path(&kind) {
|
||||
"dynamic/run_command" => Self::RunCommand(string(t.get("template"))?),
|
||||
"dynamic/custom" => Self::Custom {
|
||||
id: string(t.get("id"))?,
|
||||
additions: t.get("additions").cloned(),
|
||||
},
|
||||
other => Self::Static(Click::parse(t, other)?),
|
||||
})
|
||||
}
|
||||
/// `createAction`: the click event, given each input's key, control and value.
|
||||
pub fn click(&self, inputs: &[Input], values: &[InputValue]) -> Option<Click> {
|
||||
let values = || inputs.iter().zip(values);
|
||||
Some(match self {
|
||||
Self::Static(click) => click.clone(),
|
||||
Self::RunCommand(template) => Click::RunCommand(substitute(template, &|key| {
|
||||
values()
|
||||
.find(|(i, _)| i.key == key)
|
||||
.map(|(i, v)| i.control.substitution(v))
|
||||
})),
|
||||
Self::Custom { id, additions } => {
|
||||
let mut tag = match additions {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
for (input, value) in values() {
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert(input.key.as_str().into(), input.control.tag(value));
|
||||
}
|
||||
}
|
||||
Click::Custom {
|
||||
id: id.clone(),
|
||||
payload: Some(tag),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParsedTemplate.instantiate`: each `$(key)` replaced by its value, or by
|
||||
/// nothing for a key no input has.
|
||||
fn substitute(template: &str, value: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(at) = rest.find("$(") {
|
||||
let Some(end) = rest[at..].find(')') else {
|
||||
break;
|
||||
};
|
||||
out.push_str(&rest[..at]);
|
||||
out.push_str(&value(&rest[at + 2..at + end]).unwrap_or_default());
|
||||
rest = &rest[at + end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `StringTag.escapeWithoutQuotes`.
|
||||
fn escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' | '\'' | '\\' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
'\u{8}' => out.push_str("\\b"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\u{c}' => out.push_str("\\f"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
c if c < ' ' => out.push_str(&format!("\\x{:02x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Control {
|
||||
/// `InputControl.MAP_CODEC`, dispatched on `type`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let label = || t.get("label").map(text);
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"text" => Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial: string(t.get("initial")).unwrap_or_default(),
|
||||
max_length: int(t, "max_length", 32),
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
},
|
||||
"boolean" => Self::Boolean {
|
||||
label: label()?,
|
||||
initial: boolean(t, "initial", false),
|
||||
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
|
||||
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
|
||||
},
|
||||
"single_option" => Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
|
||||
options: list(t.get("options"))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
|
||||
o => Some((
|
||||
string(o.get("id"))?,
|
||||
o.get("display").map(text),
|
||||
boolean(o, "initial", false),
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
},
|
||||
"number_range" => {
|
||||
let float = |key| number(t.get(key)).map(|v| v as f32);
|
||||
Self::NumberRange {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_format: string(t.get("label_format"))
|
||||
.unwrap_or_else(|| "options.generic_value".into()),
|
||||
start: float("start")?,
|
||||
end: float("end")?,
|
||||
initial: float("initial"),
|
||||
step: float("step"),
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The value it starts with.
|
||||
pub fn initial(&self) -> InputValue {
|
||||
match self {
|
||||
Self::Text { initial, .. } => InputValue::Text(initial.clone()),
|
||||
Self::Boolean { initial, .. } => InputValue::Bool(*initial),
|
||||
Self::SingleOption { options, .. } => {
|
||||
InputValue::Option(options.iter().position(|o| o.2).unwrap_or(0))
|
||||
}
|
||||
Self::NumberRange {
|
||||
start,
|
||||
end,
|
||||
initial,
|
||||
..
|
||||
} => InputValue::Slider(to_slider(
|
||||
initial.unwrap_or((start + end) / 2.0),
|
||||
*start,
|
||||
*end,
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTemplateSubstitution`.
|
||||
pub fn substitution(&self, value: &InputValue) -> String {
|
||||
match (self, value) {
|
||||
(Self::Text { .. }, InputValue::Text(s)) => escape(s),
|
||||
(
|
||||
Self::Boolean {
|
||||
on_true, on_false, ..
|
||||
},
|
||||
InputValue::Bool(b),
|
||||
) => if *b { on_true } else { on_false }.clone(),
|
||||
(Self::SingleOption { options, .. }, InputValue::Option(i)) => {
|
||||
options.get(*i).map(|o| o.0.clone()).unwrap_or_default()
|
||||
}
|
||||
(Self::NumberRange { .. }, InputValue::Slider(_)) => {
|
||||
number_string(self.scaled(value).unwrap_or(0.0))
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTag`.
|
||||
pub fn tag(&self, value: &InputValue) -> Tag {
|
||||
match (self, value) {
|
||||
(Self::Boolean { .. }, InputValue::Bool(b)) => Tag::Byte(i8::from(*b)),
|
||||
(Self::NumberRange { .. }, _) => {
|
||||
Tag::Float(self.scaled(value).unwrap_or(0.0).to_bits())
|
||||
}
|
||||
(Self::Text { .. }, InputValue::Text(s)) => Tag::String(s.as_str().into()),
|
||||
_ => Tag::String(self.substitution(value).as_str().into()),
|
||||
}
|
||||
}
|
||||
/// `RangeInfo.computeScaledValue`: a number range's value at its slider position.
|
||||
pub fn scaled(&self, value: &InputValue) -> Option<f32> {
|
||||
let (
|
||||
Self::NumberRange {
|
||||
start,
|
||||
end,
|
||||
initial,
|
||||
step,
|
||||
..
|
||||
},
|
||||
InputValue::Slider(slider),
|
||||
) = (self, value)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (start, end) = (*start, *end);
|
||||
let in_range = start + slider * (end - start);
|
||||
let Some(step) = step else {
|
||||
return Some(in_range);
|
||||
};
|
||||
let first = initial.unwrap_or((start + end) / 2.0);
|
||||
// Math.round: half up.
|
||||
let steps = ((in_range - first) / step + 0.5).floor() as i32;
|
||||
let result = first + steps as f32 * step;
|
||||
let position = to_slider(result, start, end);
|
||||
if (0.0..=1.0).contains(&position) {
|
||||
return Some(result);
|
||||
}
|
||||
Some(first + (steps - steps.signum()) as f32 * step)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RangeInfo.scaledValueToSlider`.
|
||||
fn to_slider(value: f32, start: f32, end: f32) -> f32 {
|
||||
if start == end {
|
||||
0.5
|
||||
} else {
|
||||
(value - start) / (end - start)
|
||||
}
|
||||
}
|
||||
|
||||
/// `SliderImpl.valueToString`: whole numbers without a fraction, the rest as
|
||||
/// Java's `Float.toString`.
|
||||
pub fn number_string(v: f32) -> String {
|
||||
if v == (v as i32) as f32 {
|
||||
(v as i32).to_string()
|
||||
} else {
|
||||
crate::snbt::java_float(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// `DialogBody.DIALOG_BODY_CODEC`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let message = |t: &Tag| -> Option<(Text, i32)> {
|
||||
// PlainMessage.CODEC: the full form or just its contents.
|
||||
match t {
|
||||
Tag::Compound(_) if t.get("contents").is_some() => {
|
||||
Some((text(t.get("contents")?), int(t, "width", 200)))
|
||||
}
|
||||
t => Some((text(t), 200)),
|
||||
}
|
||||
};
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"plain_message" => {
|
||||
let (contents, width) = message(t)?;
|
||||
Self::Message { contents, width }
|
||||
}
|
||||
"item" => Self::Item {
|
||||
item: t.get("item")?.clone(),
|
||||
description: t.get("description").and_then(message),
|
||||
show_decorations: boolean(t, "show_decorations", true),
|
||||
show_tooltip: boolean(t, "show_tooltip", true),
|
||||
width: int(t, "width", 16),
|
||||
height: int(t, "height", 16),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
/// `Dialog.DIRECT_CODEC`; `None` for a type or field it can't read
|
||||
/// (vanilla fails the whole packet then).
|
||||
pub fn parse(t: &Tag) -> Option<Self> {
|
||||
let button = |key: &str| t.get(key).and_then(Button::parse);
|
||||
let kind = match path(&string(t.get("type"))?) {
|
||||
"notice" => Kind::Notice(match t.get("action") {
|
||||
Some(b) => Button::parse(b)?,
|
||||
None => Button::ok(),
|
||||
}),
|
||||
"confirmation" => Kind::Confirmation {
|
||||
yes: button("yes")?,
|
||||
no: button("no")?,
|
||||
},
|
||||
"multi_action" => Kind::MultiAction {
|
||||
actions: list(t.get("actions"))
|
||||
.iter()
|
||||
.map(Button::parse)
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.filter(|a| !a.is_empty())?,
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
},
|
||||
"server_links" => Kind::ServerLinks {
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
},
|
||||
"dialog_list" => Kind::DialogList {
|
||||
dialogs: t.get("dialogs")?.clone(),
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
|
||||
let body = match t.get("body") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
|
||||
Some(one) => Body::parse(one).into_iter().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let inputs = list(t.get("inputs"))
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
Some(Input {
|
||||
key: string(i.get("key"))?,
|
||||
control: Control::parse(i)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(Self {
|
||||
kind,
|
||||
title: text(t.get("title")?),
|
||||
external_title: t.get("external_title").map(text),
|
||||
can_close_with_escape: boolean(t, "can_close_with_escape", true),
|
||||
pause: boolean(t, "pause", true),
|
||||
after_action: match string(t.get("after_action")).as_deref() {
|
||||
Some("none") => AfterAction::None,
|
||||
Some("wait_for_response") => AfterAction::WaitForResponse,
|
||||
_ => AfterAction::Close,
|
||||
},
|
||||
body,
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
/// `onCancel`: what Escape (or closing it) does.
|
||||
pub fn on_cancel(&self) -> Option<&Action> {
|
||||
match &self.kind {
|
||||
Kind::Notice(b) => b.action.as_ref(),
|
||||
Kind::Confirmation { no, .. } => no.action.as_ref(),
|
||||
Kind::MultiAction { exit, .. }
|
||||
| Kind::ServerLinks { exit, .. }
|
||||
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
|
||||
}
|
||||
}
|
||||
/// The buttons it draws, in order: the list, then the footer's.
|
||||
pub fn buttons(&self) -> Vec<&Button> {
|
||||
match &self.kind {
|
||||
Kind::Notice(b) => vec![b],
|
||||
Kind::Confirmation { yes, no } => vec![yes, no],
|
||||
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
|
||||
}
|
||||
}
|
||||
/// What `status` reports of it.
|
||||
pub fn json(&self, values: &[InputValue]) -> Value {
|
||||
let kind = match &self.kind {
|
||||
Kind::Notice(_) => "notice",
|
||||
Kind::Confirmation { .. } => "confirmation",
|
||||
Kind::MultiAction { .. } => "multi_action",
|
||||
Kind::ServerLinks { .. } => "server_links",
|
||||
Kind::DialogList { .. } => "dialog_list",
|
||||
};
|
||||
let inputs: Vec<Value> = self
|
||||
.inputs
|
||||
.iter()
|
||||
.zip(values)
|
||||
.map(|(i, v)| {
|
||||
let kind = match i.control {
|
||||
Control::Text { .. } => "text",
|
||||
Control::Boolean { .. } => "boolean",
|
||||
Control::SingleOption { .. } => "single_option",
|
||||
Control::NumberRange { .. } => "number_range",
|
||||
};
|
||||
json!({"key": i.key, "type": kind, "value": i.control.substitution(v)})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"type": kind,
|
||||
"title": self.title.plain(),
|
||||
"body": self.body.iter().map(|b| match b {
|
||||
Body::Message { contents, .. } => contents.plain(),
|
||||
Body::Item { item, .. } => string(item.get("id")).unwrap_or_default(),
|
||||
}).collect::<Vec<_>>(),
|
||||
"inputs": inputs,
|
||||
"buttons": self.buttons().iter().map(|b| b.label.plain()).collect::<Vec<_>>(),
|
||||
"can_close_with_escape": self.can_close_with_escape,
|
||||
"after_action": match self.after_action {
|
||||
AfterAction::Close => "close",
|
||||
AfterAction::None => "none",
|
||||
AfterAction::WaitForResponse => "wait_for_response",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn s(v: &str) -> Tag {
|
||||
Tag::String(v.into())
|
||||
}
|
||||
|
||||
/// The register dialog AuthMe 6 sends in configuration (the shape its
|
||||
/// `show_dialog` packet has on the test bed).
|
||||
fn register() -> Tag {
|
||||
Tag::compound([
|
||||
("type", s("minecraft:multi_action")),
|
||||
("title", s("Register")),
|
||||
("can_close_with_escape", Tag::Byte(0)),
|
||||
("after_action", s("wait_for_response")),
|
||||
(
|
||||
"body",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:plain_message")),
|
||||
("contents", s("Choose a password")),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"inputs",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("type", s("minecraft:text")),
|
||||
("key", s("password")),
|
||||
("label", s("Password")),
|
||||
])]),
|
||||
),
|
||||
(
|
||||
"actions",
|
||||
Tag::List(vec![
|
||||
Tag::compound([
|
||||
("label", s("Register")),
|
||||
(
|
||||
"action",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:dynamic/custom")),
|
||||
("id", s("authme:prejoin-register/submit")),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
Tag::compound([
|
||||
("label", s("Cancel")),
|
||||
(
|
||||
"action",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:dynamic/run_command")),
|
||||
("template", s("say $(password) $(missing)!")),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_multi_action_dialog_with_defaults() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
assert_eq!(d.title.plain(), "Register");
|
||||
assert!(!d.can_close_with_escape);
|
||||
assert!(d.pause);
|
||||
assert_eq!(d.after_action, AfterAction::WaitForResponse);
|
||||
assert!(matches!(&d.body[..], [Body::Message { width: 200, .. }]));
|
||||
let Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns,
|
||||
} = &d.kind
|
||||
else {
|
||||
panic!("{:?}", d.kind)
|
||||
};
|
||||
assert_eq!((actions.len(), exit.is_none(), *columns), (2, true, 2));
|
||||
assert_eq!(actions[0].width, 150);
|
||||
assert!(d.on_cancel().is_none());
|
||||
let [
|
||||
Input {
|
||||
key,
|
||||
control:
|
||||
Control::Text {
|
||||
max_length: 32,
|
||||
width: 200,
|
||||
..
|
||||
},
|
||||
},
|
||||
] = &d.inputs[..]
|
||||
else {
|
||||
panic!("{:?}", d.inputs)
|
||||
};
|
||||
assert_eq!(key, "password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_carry_the_input_values() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
let values = [InputValue::Text("p\"w".into())];
|
||||
let Kind::MultiAction { actions, .. } = &d.kind else {
|
||||
unreachable!()
|
||||
};
|
||||
let custom = actions[0]
|
||||
.action
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.click(&d.inputs, &values);
|
||||
assert_eq!(
|
||||
custom,
|
||||
Some(Click::Custom {
|
||||
id: "authme:prejoin-register/submit".into(),
|
||||
payload: Some(Tag::compound([("password", s("p\"w"))])),
|
||||
})
|
||||
);
|
||||
// The template escapes the text; a key no input has becomes nothing.
|
||||
let command = actions[1]
|
||||
.action
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.click(&d.inputs, &values);
|
||||
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_without_an_action_has_ok() {
|
||||
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
|
||||
let Kind::Notice(b) = &d.kind else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(b.label, Text::translate("gui.ok", &[]));
|
||||
// Without a title, or with an unknown type, there is no dialog.
|
||||
assert!(Dialog::parse(&Tag::compound([("type", s("notice"))])).is_none());
|
||||
assert!(Dialog::parse(&Tag::compound([("type", s("x")), ("title", s("Hi"))])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_ranges_snap_to_their_step() {
|
||||
let mut range = Control::NumberRange {
|
||||
width: 200,
|
||||
label: Text::literal("n"),
|
||||
label_format: "options.generic_value".into(),
|
||||
start: 0.0,
|
||||
end: 10.0,
|
||||
initial: Some(4.0),
|
||||
step: Some(3.0),
|
||||
};
|
||||
assert_eq!(range.initial(), InputValue::Slider(0.4));
|
||||
assert_eq!(range.scaled(&InputValue::Slider(0.62)), Some(7.0));
|
||||
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(10.0));
|
||||
let Control::NumberRange { step, .. } = &mut range else {
|
||||
unreachable!()
|
||||
};
|
||||
*step = Some(4.0);
|
||||
// 12 is out of range: one step less.
|
||||
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(8.0));
|
||||
assert_eq!(range.substitution(&InputValue::Slider(0.0)), "0");
|
||||
assert_eq!(number_string(2.5), "2.5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! `DialogScreen` and its kinds (`SimpleDialogScreen`, `MultiButtonDialogScreen`):
|
||||
//! where a server dialog's title, body, inputs and buttons go in a
|
||||
//! `HeaderAndFooterLayout` (a 33 high header, the body in a `ScrollableLayout`,
|
||||
//! the footer), and the `WaitingForResponseScreen` an answer can leave open.
|
||||
use crate::{
|
||||
dialog::{Body, Control, Dialog, Kind},
|
||||
font::Font,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
|
||||
const BAR: f32 = 33.0;
|
||||
/// `Checkbox.getBoxSize`.
|
||||
pub const BOX: f32 = 17.0;
|
||||
/// `DialogScreen.WARNING_BUTTON_SPRITES`: normal, disabled, highlighted.
|
||||
pub const WARNING_SPRITES: [&str; 3] = [
|
||||
"gui/sprites/dialog/warning_button",
|
||||
"gui/sprites/dialog/warning_button_disabled",
|
||||
"gui/sprites/dialog/warning_button_highlighted",
|
||||
];
|
||||
|
||||
/// One drawn piece; `y` is from the top of the body (before it scrolls) for
|
||||
/// the body's parts, from the top of the screen for the footer's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Part {
|
||||
/// A line of text and where it starts.
|
||||
Line(Text, f32, f32),
|
||||
/// A text input's box.
|
||||
Field { input: usize, rect: [f32; 4] },
|
||||
/// A boolean input: the box, its label right of it.
|
||||
Check { input: usize, rect: [f32; 4] },
|
||||
/// A button of `Dialog::buttons`.
|
||||
Button { index: usize, rect: [f32; 4] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Layout {
|
||||
/// The title and where it is drawn.
|
||||
pub title: (f32, f32),
|
||||
pub warning: [f32; 4],
|
||||
/// The body's visible area (`ScrollableLayout`'s container): x, y, w, h.
|
||||
pub area: [f32; 4],
|
||||
/// The body's full height: more than the area's scrolls.
|
||||
pub content: f32,
|
||||
pub body: Vec<Part>,
|
||||
pub footer: Vec<Part>,
|
||||
}
|
||||
|
||||
/// A laid-out element of a `LinearLayout` before it is placed.
|
||||
struct Element {
|
||||
w: f32,
|
||||
h: f32,
|
||||
parts: Vec<Part>,
|
||||
}
|
||||
impl Element {
|
||||
/// Its parts moved to `(x, y)`.
|
||||
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
|
||||
self.parts.into_iter().map(move |p| match p {
|
||||
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
|
||||
[x + dx, y + dy, w, h]
|
||||
}
|
||||
/// `FrameLayout.alignInDimension` at 0.5: the offset that centres `inner`.
|
||||
fn centre(outer: f32, inner: f32) -> f32 {
|
||||
((outer - inner) * 0.5).trunc()
|
||||
}
|
||||
|
||||
/// `FocusableTextWidget` with the body's settings: `width` wide, the text
|
||||
/// wrapped to `width - 8` and centred, 4 of padding all round.
|
||||
fn message(font: &Font, contents: &Text, width: f32) -> Element {
|
||||
let lines = font.split_lines(contents, width - 8.0);
|
||||
let mid = (width / 2.0).trunc();
|
||||
let parts = lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (line, _))| {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
Part::Line(line, x, 4.0 + 9.0 * i as f32)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Element {
|
||||
w: width,
|
||||
h: 9.0 * parts.len() as f32 + 8.0,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
Some(match control {
|
||||
Control::Text {
|
||||
width,
|
||||
label,
|
||||
label_visible,
|
||||
..
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
if !label_visible {
|
||||
return Some(Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
}],
|
||||
});
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
parts: vec![
|
||||
Part::Line(label.clone(), 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
Control::Boolean { label, .. } => {
|
||||
let w = BOX + 4.0 + font.width_text(label);
|
||||
Element {
|
||||
w,
|
||||
h: BOX,
|
||||
parts: vec![Part::Check {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, BOX],
|
||||
}],
|
||||
}
|
||||
}
|
||||
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
|
||||
/// is left in a centred row under them.
|
||||
fn columns(widths: &[f32], columns: usize, first: usize) -> Element {
|
||||
let columns = columns.max(1);
|
||||
let full = widths.len() / columns * columns;
|
||||
let row_width = |ws: &[f32]| ws.iter().sum::<f32>() + 2.0 * (ws.len().max(1) - 1) as f32;
|
||||
let mut col = vec![0.0f32; columns];
|
||||
for (i, w) in widths[..full].iter().enumerate() {
|
||||
col[i % columns] = col[i % columns].max(*w);
|
||||
}
|
||||
let last = &widths[full..];
|
||||
if !last.is_empty() {
|
||||
// GridLayout spreads a cell over every column it spans (Divisor).
|
||||
let spread = row_width(last) - 2.0 * (columns - 1) as f32;
|
||||
let (each, extra) = ((spread / columns as f32).floor(), spread % columns as f32);
|
||||
for (c, w) in col.iter_mut().enumerate() {
|
||||
*w = w.max(each + f32::from(u8::from((c as f32) < extra)));
|
||||
}
|
||||
}
|
||||
let x_of = |c: usize| col[..c].iter().sum::<f32>() + 2.0 * c as f32;
|
||||
let mut parts = Vec::new();
|
||||
for (i, w) in widths[..full].iter().enumerate() {
|
||||
let (r, c) = (i / columns, i % columns);
|
||||
parts.push(Part::Button {
|
||||
index: first + i,
|
||||
rect: [x_of(c) + centre(col[c], *w), 22.0 * r as f32, *w, 20.0],
|
||||
});
|
||||
}
|
||||
let width = x_of(columns - 1) + col[columns - 1];
|
||||
if !last.is_empty() {
|
||||
let y = 22.0 * (full / columns) as f32;
|
||||
let mut x = centre(width, row_width(last));
|
||||
for (i, w) in last.iter().enumerate() {
|
||||
parts.push(Part::Button {
|
||||
index: first + full + i,
|
||||
rect: [x, y, *w, 20.0],
|
||||
});
|
||||
x += w + 2.0;
|
||||
}
|
||||
}
|
||||
let rows = full / columns + usize::from(!last.is_empty());
|
||||
Element {
|
||||
w: width,
|
||||
h: (22.0 * rows as f32 - 2.0).max(0.0),
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// `DialogScreen.init` and `repositionElements` on a `w` by `h` screen.
|
||||
pub fn new(dialog: &Dialog, font: &Font, (w, h): (f32, f32)) -> Self {
|
||||
// The header: the title, 10 right of it the warning button, both
|
||||
// centred together.
|
||||
let tw = font.width_text(&dialog.title);
|
||||
let (hx, hy) = (centre(w, tw + 30.0), centre(BAR, 20.0));
|
||||
let title = (hx, hy + centre(20.0, 9.0));
|
||||
let warning = [hx + tw + 10.0, hy, 20.0, 20.0];
|
||||
|
||||
let mut elements: Vec<Element> = dialog
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
|
||||
Body::Item { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
elements.extend(
|
||||
dialog
|
||||
.inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, input_)| input(font, i, &input_.control)),
|
||||
);
|
||||
let buttons = dialog.buttons();
|
||||
let (listed, footer_buttons, footer_h) = match &dialog.kind {
|
||||
Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns: n,
|
||||
} => {
|
||||
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
|
||||
elements.push(columns(&widths, *n as usize, 0));
|
||||
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
|
||||
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
|
||||
(
|
||||
actions.len(),
|
||||
footer,
|
||||
if exit.is_some() { BAR } else { 5.0 },
|
||||
)
|
||||
}
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
|
||||
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
|
||||
(0, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
// SimpleDialogScreen: the buttons side by side, 8 apart.
|
||||
Kind::Notice(_) | Kind::Confirmation { .. } => {
|
||||
(0, (0..buttons.len()).collect::<Vec<_>>(), BAR)
|
||||
}
|
||||
};
|
||||
debug_assert!(listed + footer_buttons.len() <= buttons.len());
|
||||
|
||||
// The body: a column 10 apart, each element centred in it.
|
||||
let body_w = elements.iter().map(|e| e.w).fold(0.0, f32::max);
|
||||
let content = elements.iter().map(|e| e.h).sum::<f32>()
|
||||
+ 10.0 * elements.len().saturating_sub(1) as f32;
|
||||
// ScrollableLayout: 10 each side for the scrollbar (ReserveStrategy.BOTH),
|
||||
// no higher than what the header and footer leave.
|
||||
let area_w = body_w + 20.0;
|
||||
let area_h = content.min(h - BAR - footer_h).max(0.0);
|
||||
let area_y = (BAR + 30.0).min(h - footer_h - area_h);
|
||||
let area_x = centre(w, area_w);
|
||||
let mut body = Vec::new();
|
||||
let mut y = 0.0;
|
||||
for e in elements {
|
||||
let (x, eh) = (area_x + 10.0 + centre(body_w, e.w), e.h);
|
||||
body.extend(e.at(x, y));
|
||||
y += eh + 10.0;
|
||||
}
|
||||
|
||||
let widths: Vec<f32> = footer_buttons
|
||||
.iter()
|
||||
.map(|&i| buttons[i].width as f32)
|
||||
.collect();
|
||||
let row = widths.iter().sum::<f32>() + 8.0 * widths.len().saturating_sub(1) as f32;
|
||||
let (mut x, fy) = (centre(w, row), h - footer_h + centre(footer_h, 20.0));
|
||||
let mut footer = Vec::new();
|
||||
for (&i, bw) in footer_buttons.iter().zip(&widths) {
|
||||
footer.push(Part::Button {
|
||||
index: i,
|
||||
rect: [x, fy, *bw, 20.0],
|
||||
});
|
||||
x += bw + 8.0;
|
||||
}
|
||||
Self {
|
||||
title,
|
||||
warning: Self::in_bounds(warning, w, h),
|
||||
area: [area_x, area_y, area_w, area_h],
|
||||
content,
|
||||
body,
|
||||
footer,
|
||||
}
|
||||
}
|
||||
/// `makeSureWarningButtonIsInBounds`.
|
||||
fn in_bounds(r: [f32; 4], w: f32, h: f32) -> [f32; 4] {
|
||||
if r[0] < 0.0 || r[1] < 0.0 || r[0] > w - 20.0 || r[1] > h - 20.0 {
|
||||
[(w - 40.0).max(0.0), 5.0f32.min(h), 20.0, 20.0]
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
/// Every part where it is drawn with the body scrolled by `scroll`, and
|
||||
/// whether it is in the body (clipped to its area).
|
||||
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
|
||||
let dy = self.area[1] - scroll;
|
||||
let body = self.body.iter().map(move |p| {
|
||||
let p = match p.clone() {
|
||||
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
};
|
||||
(p, true)
|
||||
});
|
||||
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
|
||||
}
|
||||
}
|
||||
|
||||
/// `WaitingForResponseScreen`: its Back button's label after `ticks`, and
|
||||
/// whether it shows and works (it shows after a second, works after five).
|
||||
pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
|
||||
let seconds = (ticks / 20).min(5);
|
||||
let label = match seconds {
|
||||
0 => Text::literal(""),
|
||||
5 => Text::translate("gui.back", &[]),
|
||||
s => Text::translate(
|
||||
"gui.waitingForResponse.button.inactive",
|
||||
&[Text::literal((5 - s).to_string())],
|
||||
),
|
||||
};
|
||||
(label, seconds >= 1, seconds == 5)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grids_pack_full_rows_and_centre_the_rest() {
|
||||
// AuthMe's two buttons: one row, 2 apart.
|
||||
let two = columns(&[150.0, 150.0], 2, 0);
|
||||
assert_eq!((two.w, two.h), (302.0, 20.0));
|
||||
// Three in two columns: the third alone, centred under them.
|
||||
let three = columns(&[150.0, 150.0, 150.0], 2, 0);
|
||||
assert_eq!((three.w, three.h), (302.0, 42.0));
|
||||
assert_eq!(
|
||||
three.parts[2],
|
||||
Part::Button {
|
||||
index: 2,
|
||||
rect: [76.0, 22.0, 150.0, 20.0]
|
||||
}
|
||||
);
|
||||
// One in two columns spreads over both.
|
||||
let one = columns(&[100.0], 2, 0);
|
||||
assert_eq!(one.w, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_waiting_screens_button_counts_down() {
|
||||
assert!(!waiting_button(0).1);
|
||||
assert_eq!(
|
||||
waiting_button(20).0,
|
||||
Text::translate(
|
||||
"gui.waitingForResponse.button.inactive",
|
||||
&[Text::literal("4")]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
waiting_button(100),
|
||||
(Text::translate("gui.back", &[]), true, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,11 @@ impl Cost {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Enchantment {
|
||||
pub id: Arc<str>,
|
||||
/// The name the pinned English map gave at load: the fallback for a
|
||||
/// language that has no entry for the key below.
|
||||
pub description: String,
|
||||
/// `description`'s translation key (`enchantment.minecraft.<name>`).
|
||||
pub description_key: String,
|
||||
pub max_level: u32,
|
||||
pub weight: u32,
|
||||
pub anvil_cost: u32,
|
||||
@@ -224,6 +228,7 @@ impl Enchantments {
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| id.trim_start_matches("minecraft:").replace('_', " ")),
|
||||
description_key: key.to_owned(),
|
||||
max_level: v["max_level"].as_u64().unwrap_or(1) as u32,
|
||||
weight: v["weight"].as_u64().unwrap_or(1) as u32,
|
||||
anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32,
|
||||
@@ -329,16 +334,26 @@ impl Enchantments {
|
||||
}
|
||||
/// `Enchantment.getFullname` as plain text and whether it is a curse (red).
|
||||
pub fn full_name(&self, id: &str, level: u32) -> (String, bool) {
|
||||
let language = crate::lang::current();
|
||||
self.full_name_in(language.as_deref(), id, level)
|
||||
}
|
||||
/// `full_name` against `language`; `None` (no language loaded: a
|
||||
/// dedicated server, the probes) keeps the English names loaded above.
|
||||
pub fn full_name_in(
|
||||
&self,
|
||||
language: Option<&crate::lang::Language>,
|
||||
id: &str,
|
||||
level: u32,
|
||||
) -> (String, bool) {
|
||||
let tr = |key: &str| language.and_then(|l| l.get(key).map(str::to_owned));
|
||||
let Some(e) = self.get(id) else {
|
||||
return (format!("{id} {}", level_name(level)), false);
|
||||
};
|
||||
let mut name = e.description.clone();
|
||||
let mut name = tr(&e.description_key).unwrap_or_else(|| e.description.clone());
|
||||
if level != 1 || e.max_level != 1 {
|
||||
name.push(' ');
|
||||
name.push_str(
|
||||
self.lang
|
||||
.get(&format!("enchantment.level.{level}"))
|
||||
.map_or(&level_name(level), |s| s),
|
||||
&tr(&format!("enchantment.level.{level}")).unwrap_or_else(|| level_name(level)),
|
||||
);
|
||||
}
|
||||
(name, self.is_curse(id))
|
||||
@@ -771,7 +786,7 @@ pub(crate) mod tests {
|
||||
let defs = [
|
||||
(
|
||||
"minecraft:sharpness",
|
||||
json!({"anvil_cost":1,"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
|
||||
json!({"anvil_cost":1,"description":{"translate":"enchantment.minecraft.sharpness"},"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
|
||||
),
|
||||
(
|
||||
"minecraft:smite",
|
||||
@@ -795,6 +810,7 @@ pub(crate) mod tests {
|
||||
.map(|(id, v)| Enchantment {
|
||||
id: Arc::from(*id),
|
||||
description: id.trim_start_matches("minecraft:").into(),
|
||||
description_key: v["description"]["translate"].as_str().unwrap_or("").into(),
|
||||
max_level: v["max_level"].as_u64().unwrap() as u32,
|
||||
weight: v["weight"].as_u64().unwrap() as u32,
|
||||
anvil_cost: v["anvil_cost"].as_u64().unwrap() as u32,
|
||||
@@ -1088,4 +1104,33 @@ pub(crate) mod tests {
|
||||
assert!(!e.compatible("minecraft:sharpness", "minecraft:smite"));
|
||||
assert!(e.compatible("minecraft:sharpness", "minecraft:unbreaking"));
|
||||
}
|
||||
|
||||
/// The enchantment's name and its level come from the selected language,
|
||||
/// with the English name loaded above as the fallback.
|
||||
#[test]
|
||||
fn names_follow_the_language() {
|
||||
struct Mem(HashMap<String, Vec<u8>>);
|
||||
impl crate::lang::ResourceSource for Mem {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.get(path).cloned()
|
||||
}
|
||||
}
|
||||
let ru = Mem(HashMap::from([(
|
||||
"assets/minecraft/lang/ru_ru.json".to_owned(),
|
||||
r#"{"enchantment.minecraft.sharpness":"Острота","enchantment.level.5":"V"}"#
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
)]));
|
||||
let ru = crate::lang::Language::load(&[&ru], "ru_ru");
|
||||
let e = sample();
|
||||
assert_eq!(
|
||||
e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0,
|
||||
"Острота V"
|
||||
);
|
||||
// mending carries no key in the fixture: the English name stays.
|
||||
assert_eq!(
|
||||
e.full_name_in(Some(&ru), "minecraft:mending", 1).0,
|
||||
"mending"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,20 @@ impl Culler {
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
let hidden = self.hidden(vertices, start, opaque);
|
||||
if hidden {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Whether this entity's completed camera-relative mesh (`vertices[start..]`) is hidden.
|
||||
pub fn hidden(
|
||||
&self,
|
||||
vertices: &[crate::mesher::Vertex],
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if start >= vertices.len() || self.remaining.get() == 0 {
|
||||
return false;
|
||||
@@ -42,11 +56,7 @@ impl Culler {
|
||||
}
|
||||
}
|
||||
// Cover floating-point and rasterization edges as well as the vertices.
|
||||
if self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque) {
|
||||
vertices.truncate(start);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
|
||||
}
|
||||
|
||||
/// Returns true only when a complete opaque block plane covers the bounds.
|
||||
|
||||
@@ -617,9 +617,23 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.head_pitch = pitch;
|
||||
m.on_ground = flag(tag, "OnGround");
|
||||
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
if let Some(h) = float(tag.get("Health")) {
|
||||
m.health = h as f32;
|
||||
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
|
||||
// the health, the max health when there is none.
|
||||
if let Some(Tag::List(attrs)) = tag.get("attributes") {
|
||||
for a in attrs {
|
||||
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
@@ -735,12 +749,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:jump_strength" => m.extra.vector[0] = v,
|
||||
_ => {}
|
||||
if id == "minecraft:jump_strength" {
|
||||
m.extra.vector[0] = f64::from_bits(*bits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
|
||||
//! Horizon's optional extras (minimap, lighting, inventory tools): their
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
@@ -6,17 +6,8 @@
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
/// The name the options screen uses while the zoom key waits for a key.
|
||||
pub const ZOOM_KEY: &str = "horizon.zoom";
|
||||
/// The same for the sort key.
|
||||
/// The name the options screen uses while the sort key waits for a key.
|
||||
pub const SORT_KEY: &str = "horizon.sort";
|
||||
/// Zoom levels the default cycles through, and the wheel's limits.
|
||||
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
|
||||
pub const ZOOM_MIN: f32 = 1.25;
|
||||
/// `Camera.tickFov` keeps the field-of-view modifier at 0.1 or more.
|
||||
pub const ZOOM_MAX: f32 = 10.0;
|
||||
/// Each wheel step multiplies the zoom by this.
|
||||
const ZOOM_STEP: f32 = 1.25;
|
||||
|
||||
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
|
||||
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
@@ -81,6 +72,9 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now
|
||||
/// (`moved_extras`): their old settings stay here until the game has
|
||||
/// installed the mod for a player who had one on.
|
||||
pub fps: bool,
|
||||
pub fps_corner: Corner,
|
||||
/// Also show the minimum and 1% low.
|
||||
@@ -89,6 +83,9 @@ pub struct Extras {
|
||||
pub keystrokes_corner: Corner,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub keystrokes_cps: bool,
|
||||
/// The official mods installed for the extras they replace, by mod id
|
||||
/// (`movedToMods`): each is done once.
|
||||
pub moved: Vec<String>,
|
||||
pub minimap: bool,
|
||||
pub minimap_corner: Corner,
|
||||
/// Side in GUI pixels (`MINIMAP_SIZES`).
|
||||
@@ -132,6 +129,7 @@ impl Default for Extras {
|
||||
keystrokes: false,
|
||||
keystrokes_corner: Corner::TopLeft,
|
||||
keystrokes_cps: true,
|
||||
moved: Vec::new(),
|
||||
minimap: false,
|
||||
minimap_corner: Corner::TopRight,
|
||||
minimap_size: 96,
|
||||
@@ -177,7 +175,7 @@ impl Extras {
|
||||
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
|
||||
"zoomLevel" => {
|
||||
if let Ok(v) = value.parse::<u8>() {
|
||||
e.zoom_level = v.clamp(2, ZOOM_MAX as u8);
|
||||
e.zoom_level = v.clamp(2, 10);
|
||||
}
|
||||
}
|
||||
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
|
||||
@@ -190,6 +188,14 @@ impl Extras {
|
||||
e.keystrokes_corner = Corner::parse(value, e.keystrokes_corner)
|
||||
}
|
||||
"keystrokesCps" => e.keystrokes_cps = flag(e.keystrokes_cps),
|
||||
"movedToMods" => {
|
||||
e.moved = value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
"minimap" => e.minimap = flag(e.minimap),
|
||||
"minimapCorner" => e.minimap_corner = Corner::parse(value, e.minimap_corner),
|
||||
"minimapSize" => {
|
||||
@@ -219,7 +225,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -233,6 +239,7 @@ impl Extras {
|
||||
bool_text(self.keystrokes),
|
||||
self.keystrokes_corner.name(),
|
||||
bool_text(self.keystrokes_cps),
|
||||
self.moved.join(","),
|
||||
bool_text(self.minimap),
|
||||
self.minimap_corner.name(),
|
||||
self.minimap_size,
|
||||
@@ -254,11 +261,10 @@ impl Extras {
|
||||
bool_text(self.discord_presence),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
/// another name (a vanilla mapping).
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
ZOOM_KEY => self.zoom_key = value,
|
||||
SORT_KEY => self.sort_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
@@ -275,87 +281,12 @@ impl Extras {
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom key's state: held or not, and the zoom the wheel has set.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Zoom {
|
||||
held: bool,
|
||||
factor: f32,
|
||||
}
|
||||
impl Default for Zoom {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
held: false,
|
||||
factor: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Zoom {
|
||||
/// The key went down: start at the default level.
|
||||
pub fn press(&mut self, level: u8) {
|
||||
self.held = true;
|
||||
self.factor = f32::from(level).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
}
|
||||
pub fn release(&mut self) {
|
||||
self.held = false;
|
||||
}
|
||||
/// A press or release of the zoom key. Only a press while `playing` (no
|
||||
/// screen or text field open) zooms; anywhere else the key belongs to
|
||||
/// the screen, and the zoom lets go.
|
||||
pub fn key(&mut self, pressed: bool, repeat: bool, playing: bool, level: u8) {
|
||||
if !pressed || !playing {
|
||||
self.release();
|
||||
} else if !repeat {
|
||||
self.press(level);
|
||||
}
|
||||
}
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
/// Wheel steps while held (positive zooms in); false when not zooming.
|
||||
pub fn scroll(&mut self, steps: f32) -> bool {
|
||||
if !self.held {
|
||||
return false;
|
||||
}
|
||||
self.factor = (self.factor * ZOOM_STEP.powf(steps)).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
true
|
||||
}
|
||||
/// The zoom in effect: 1 when the key is up or the spyglass is in use
|
||||
/// (scoping keeps vanilla's spyglass view).
|
||||
pub fn factor(&self, scoping: bool) -> f32 {
|
||||
if self.held && !scoping {
|
||||
self.factor
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla key mappings bound to the zoom key. The key still reaches them
|
||||
/// (a key bound twice triggers both, as vanilla's do), so the Extras page
|
||||
/// says so.
|
||||
pub fn zoom_key_conflicts(e: &Extras, keys: &crate::keybinds::KeyBinds) -> Vec<&'static str> {
|
||||
if e.zoom_key == "key.keyboard.unknown" {
|
||||
return Vec::new();
|
||||
}
|
||||
crate::keybinds::MAPPINGS
|
||||
.iter()
|
||||
.map(|m| m.0)
|
||||
.filter(|name| keys.get(name) == e.zoom_key)
|
||||
.collect()
|
||||
}
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by the zoom. A zoom of 1 leaves it as is.
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
|
||||
/// zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
}
|
||||
/// The mouse turning scale while zoomed.
|
||||
pub fn turn_scale(zoom: f32, lower_sensitivity: bool) -> f64 {
|
||||
if lower_sensitivity && zoom > 1.0 {
|
||||
1.0 / f64::from(zoom)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame times over the last few seconds: frames per second, the minimum
|
||||
/// and the 1% low, recomputed twice a second.
|
||||
pub struct FpsCounter {
|
||||
@@ -447,17 +378,6 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
Zoom,
|
||||
ZoomKey,
|
||||
ZoomLevel,
|
||||
ZoomSmooth,
|
||||
ZoomSensitivity,
|
||||
Fps,
|
||||
FpsCorner,
|
||||
FpsLows,
|
||||
Keystrokes,
|
||||
KeystrokesCorner,
|
||||
KeystrokesCps,
|
||||
Minimap,
|
||||
MinimapCorner,
|
||||
MinimapSize,
|
||||
@@ -482,12 +402,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Zoom"),
|
||||
Ok(Zoom),
|
||||
Ok(ZoomKey),
|
||||
Ok(ZoomLevel),
|
||||
Ok(ZoomSmooth),
|
||||
Ok(ZoomSensitivity),
|
||||
Err("Minimap"),
|
||||
Ok(Minimap),
|
||||
Ok(MinimapCorner),
|
||||
@@ -495,14 +409,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(MinimapShape),
|
||||
Ok(MinimapZoom),
|
||||
Ok(MinimapRotate),
|
||||
Err("FPS counter"),
|
||||
Ok(Fps),
|
||||
Ok(FpsCorner),
|
||||
Ok(FpsLows),
|
||||
Err("Keystrokes"),
|
||||
Ok(Keystrokes),
|
||||
Ok(KeystrokesCorner),
|
||||
Ok(KeystrokesCps),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -532,13 +438,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::Zoom => "Zoom",
|
||||
Item::ZoomKey => "Zoom key",
|
||||
Item::ZoomLevel => "Starting zoom",
|
||||
Item::ZoomSmooth => "Smooth zoom",
|
||||
Item::ZoomSensitivity => "Slower turning",
|
||||
Item::Fps => "FPS counter",
|
||||
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => "Corner",
|
||||
Item::MinimapCorner => "Corner",
|
||||
Item::Minimap => "Minimap",
|
||||
Item::MinimapSize => "Size",
|
||||
Item::MinimapShape => "Shape",
|
||||
@@ -549,9 +449,6 @@ impl Item {
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
@@ -561,14 +458,11 @@ impl Item {
|
||||
pub fn parent(self) -> Option<Item> {
|
||||
use Item::*;
|
||||
Some(match self {
|
||||
ZoomKey | ZoomLevel | ZoomSmooth | ZoomSensitivity => Zoom,
|
||||
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
|
||||
FpsCorner | FpsLows => Fps,
|
||||
KeystrokesCorner | KeystrokesCps => Keystrokes,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence => {
|
||||
Fullbright | DynamicLights | Minimap | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -580,15 +474,7 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
|
||||
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
|
||||
Item::ZoomLevel => "The zoom the key starts at.",
|
||||
Item::ZoomSmooth => "Eases in and out like the spyglass.",
|
||||
Item::ZoomSensitivity => "Turns slower the more you zoom in.",
|
||||
Item::Fps => "Frames per second in a corner of the screen.",
|
||||
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => {
|
||||
"Overlays in one corner stack."
|
||||
}
|
||||
Item::MinimapCorner => "Overlays in one corner stack.",
|
||||
Item::Minimap => "A map of the loaded terrain around you, in map colours.",
|
||||
Item::MinimapSize => "How big the map is on screen.",
|
||||
Item::MinimapShape => "Square or round.",
|
||||
@@ -597,7 +483,6 @@ impl Item {
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
Item::FpsLows => "The slowest frame and the 1% low of the last 5 seconds.",
|
||||
Item::MouseTweaks => {
|
||||
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
|
||||
}
|
||||
@@ -610,8 +495,6 @@ impl Item {
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
@@ -627,7 +510,7 @@ impl Item {
|
||||
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
@@ -638,13 +521,6 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::Zoom => e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity,
|
||||
Item::Fps => e.fps,
|
||||
Item::FpsLows => e.fps_lows,
|
||||
Item::Keystrokes => e.keystrokes,
|
||||
Item::KeystrokesCps => e.keystrokes_cps,
|
||||
Item::Minimap => e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
@@ -660,11 +536,7 @@ impl Item {
|
||||
/// The value of the other items, as their button shows it.
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::ZoomLevel => format!("{}x", e.zoom_level),
|
||||
Item::FpsCorner => e.fps_corner.label().into(),
|
||||
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
|
||||
Item::MinimapCorner => e.minimap_corner.label().into(),
|
||||
Item::MinimapSize => MINIMAP_SIZES
|
||||
.iter()
|
||||
@@ -679,23 +551,11 @@ impl Item {
|
||||
},
|
||||
}
|
||||
}
|
||||
/// A click (`back` for the right button). The zoom key binds instead.
|
||||
/// A click (`back` for the right button). The sort key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::Zoom => e.zoom = !e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
|
||||
Item::Fps => e.fps = !e.fps,
|
||||
Item::FpsLows => e.fps_lows = !e.fps_lows,
|
||||
Item::Keystrokes => e.keystrokes = !e.keystrokes,
|
||||
Item::KeystrokesCps => e.keystrokes_cps = !e.keystrokes_cps,
|
||||
Item::ZoomLevel => e.zoom_level = next(&ZOOM_LEVELS, e.zoom_level, back),
|
||||
Item::FpsCorner => e.fps_corner = next(&Corner::ALL, e.fps_corner, back),
|
||||
Item::KeystrokesCorner => {
|
||||
e.keystrokes_corner = next(&Corner::ALL, e.keystrokes_corner, back)
|
||||
}
|
||||
Item::Minimap => e.minimap = !e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
@@ -712,13 +572,12 @@ impl Item {
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
/// The key name the options screen waits on for a key item.
|
||||
pub fn key_name(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Item::ZoomKey => Some(ZOOM_KEY),
|
||||
Item::SortKey => Some(SORT_KEY),
|
||||
_ => None,
|
||||
}
|
||||
@@ -750,6 +609,7 @@ mod tests {
|
||||
e.fps_corner = Corner::BottomRight;
|
||||
e.keystrokes = true;
|
||||
e.keystrokes_cps = false;
|
||||
e.moved = vec!["fps_overlay".into(), "keystrokes".into()];
|
||||
e.minimap = true;
|
||||
e.minimap_size = 128;
|
||||
e.minimap_round = true;
|
||||
@@ -775,43 +635,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_zoom_key_is_free_in_vanilla() {
|
||||
let key = Extras::default().zoom_key;
|
||||
assert!(crate::keybinds::key_code(&key).is_some());
|
||||
assert!(
|
||||
crate::keybinds::MAPPINGS.iter().all(|m| m.1 != key),
|
||||
"{key} is a vanilla binding"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_is_vanilla_unless_held_and_not_scoping() {
|
||||
let mut z = Zoom::default();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
assert_eq!(fov_target(1.1, z.factor(false)), 1.1);
|
||||
assert!(!z.scroll(1.0), "the wheel is the hotbar's when not zooming");
|
||||
z.press(4);
|
||||
assert_eq!(z.factor(false), 4.0);
|
||||
assert_eq!(z.factor(true), 1.0, "the spyglass wins");
|
||||
assert!((fov_target(1.0, 4.0) - 0.25).abs() < 1e-6);
|
||||
assert!(z.scroll(1.0));
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
for _ in 0..40 {
|
||||
z.scroll(1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MAX);
|
||||
for _ in 0..80 {
|
||||
z.scroll(-1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MIN);
|
||||
z.release();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
z.press(2);
|
||||
assert_eq!(z.factor(false), 2.0, "each press starts at the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_target_eases_like_the_spyglass() {
|
||||
fn a_mods_zoom_eases_like_the_spyglass() {
|
||||
// Camera.tickFov halves the distance to the target each tick.
|
||||
let mut camera = crate::camera::Camera::default();
|
||||
camera.tick(None, 1.0);
|
||||
@@ -835,55 +659,6 @@ mod tests {
|
||||
assert!((floor - 7.0).abs() < 1e-3, "{floor}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_zoom_key_only_zooms_while_playing() {
|
||||
let mut z = Zoom::default();
|
||||
// In chat, a sign, a text field or any screen: no zoom.
|
||||
z.key(true, false, false, 4);
|
||||
assert!(!z.held());
|
||||
z.key(true, false, true, 4);
|
||||
assert!(z.held());
|
||||
// A screen opening while held lets go (its key presses are the screen's).
|
||||
z.key(true, true, false, 4);
|
||||
assert!(!z.held());
|
||||
// Key repeats do not restart a zoom the wheel changed.
|
||||
z.key(true, false, true, 4);
|
||||
z.scroll(1.0);
|
||||
z.key(true, true, true, 4);
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
z.key(false, false, true, 4);
|
||||
assert!(!z.held());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_key_conflicts_name_the_vanilla_mappings() {
|
||||
let mut e = Extras::default();
|
||||
let mut keys = crate::keybinds::KeyBinds::default();
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
e.zoom_key = "key.keyboard.c".into();
|
||||
assert_eq!(
|
||||
zoom_key_conflicts(&e, &keys),
|
||||
[
|
||||
"key.saveToolbarActivator",
|
||||
"key.debug.crash",
|
||||
"key.debug.copyLocation"
|
||||
]
|
||||
);
|
||||
keys.set("key.drop", "key.keyboard.z");
|
||||
e.zoom_key = "key.keyboard.z".into();
|
||||
assert_eq!(zoom_key_conflicts(&e, &keys), ["key.drop"]);
|
||||
e.zoom_key = "key.keyboard.unknown".into();
|
||||
keys.set("key.smoothCamera", "key.keyboard.unknown");
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_scales_with_zoom() {
|
||||
assert_eq!(turn_scale(1.0, true), 1.0);
|
||||
assert_eq!(turn_scale(4.0, false), 1.0);
|
||||
assert_eq!(turn_scale(4.0, true), 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_counter_reports_rate_minimum_and_low() {
|
||||
let mut c = FpsCounter::default();
|
||||
@@ -937,16 +712,8 @@ mod tests {
|
||||
#[test]
|
||||
fn items_cycle_through_their_values() {
|
||||
let mut e = Extras::default();
|
||||
Item::ZoomLevel.cycle(&mut e, false);
|
||||
assert_eq!(e.zoom_level, 6);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
assert_eq!(e.zoom_level, 3);
|
||||
Item::FpsCorner.cycle(&mut e, true);
|
||||
assert_eq!(e.fps_corner, Corner::BottomRight);
|
||||
Item::Zoom.cycle(&mut e, false);
|
||||
assert_eq!(Item::Zoom.switch(&e), Some(true));
|
||||
assert_eq!(Item::ZoomKey.value(&e), "Z");
|
||||
Item::MinimapCorner.cycle(&mut e, true);
|
||||
assert_eq!(e.minimap_corner, Corner::TopLeft);
|
||||
Item::MinimapZoom.cycle(&mut e, true);
|
||||
assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel");
|
||||
Item::MinimapSize.cycle(&mut e, false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The extras' HUD overlays (FPS counter, keystrokes): small boxes in the
|
||||
//! vanilla font, in GUI pixels, stacked per corner (`docs/extras.md`).
|
||||
//! HUD boxes in the corners: the minimap's place and mods' panels, small
|
||||
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
|
||||
use crate::{
|
||||
extras::{Corner, FpsReading},
|
||||
extras::Corner,
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
};
|
||||
|
||||
@@ -99,36 +99,6 @@ pub fn draw_minimap(
|
||||
}
|
||||
}
|
||||
|
||||
/// The FPS counter's text.
|
||||
pub fn fps_text(r: FpsReading, lows: bool) -> String {
|
||||
if lows {
|
||||
format!("{} fps min {} 1% {}", r.fps, r.min, r.low)
|
||||
} else {
|
||||
format!("{} fps", r.fps)
|
||||
}
|
||||
}
|
||||
pub fn draw_fps(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
reading: FpsReading,
|
||||
lows: bool,
|
||||
) {
|
||||
let text = fps_text(reading, lows);
|
||||
let w = HudBatch::text_width(atlas, &text) + 4.0;
|
||||
let (x, y) = corners.place(corner, w, 12.0);
|
||||
batch.fill(atlas, x, y, w, 12.0, BACKGROUND);
|
||||
batch.text(
|
||||
atlas,
|
||||
&text,
|
||||
x + 2.0,
|
||||
y + 2.0,
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// A box of text lines (a mod's HUD panel): `(text, 0xRRGGBB)` each.
|
||||
pub fn draw_lines(
|
||||
batch: &mut HudBatch,
|
||||
@@ -155,95 +125,6 @@ pub fn draw_lines(
|
||||
}
|
||||
}
|
||||
|
||||
/// What the keystroke overlay shows.
|
||||
pub struct Keystrokes {
|
||||
/// Forward, left, back, right: held, and the bound key's name.
|
||||
pub moves: [(bool, String); 4],
|
||||
pub jump: bool,
|
||||
pub attack: bool,
|
||||
pub use_: bool,
|
||||
/// Clicks per second of the left and right buttons, when shown.
|
||||
pub cps: Option<(usize, usize)>,
|
||||
}
|
||||
const KEY: f32 = 22.0;
|
||||
const GAP: f32 = 2.0;
|
||||
/// The overlay's size.
|
||||
pub fn keystrokes_size() -> (f32, f32) {
|
||||
(3.0 * KEY + 2.0 * GAP, 3.0 * KEY + 3.0 * GAP + 10.0)
|
||||
}
|
||||
fn key_box(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4], held: bool, lines: &[&str]) {
|
||||
let [x, y, w, h] = rect;
|
||||
let (fill, ink) = if held {
|
||||
(rgba(255, 255, 255, 176), rgba(20, 20, 20, 255))
|
||||
} else {
|
||||
(BACKGROUND, rgba(255, 255, 255, 255))
|
||||
};
|
||||
batch.fill(atlas, x, y, w, h, fill);
|
||||
let total = lines.len() as f32 * 9.0 - 1.0;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let tw = HudBatch::text_width(atlas, line);
|
||||
let tx = (x + (w - tw) / 2.0).round();
|
||||
let ty = (y + (h - total) / 2.0).round() + i as f32 * 9.0;
|
||||
batch.text(atlas, line, tx, ty, ink, !held);
|
||||
}
|
||||
}
|
||||
pub fn draw_keystrokes(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
k: &Keystrokes,
|
||||
) {
|
||||
let (w, h) = keystrokes_size();
|
||||
let (x, y) = corners.place(corner, w, h);
|
||||
let step = KEY + GAP;
|
||||
let [forward, left, back, right] = &k.moves;
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + step, y, KEY, KEY],
|
||||
forward.0,
|
||||
&[&forward.1],
|
||||
);
|
||||
for (i, key) in [left, back, right].into_iter().enumerate() {
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + i as f32 * step, y + step, KEY, KEY],
|
||||
key.0,
|
||||
&[&key.1],
|
||||
);
|
||||
}
|
||||
let mw = (w - GAP) / 2.0;
|
||||
let my = y + 2.0 * step;
|
||||
for (i, (held, label, cps)) in [
|
||||
(k.attack, "LMB", k.cps.map(|c| c.0)),
|
||||
(k.use_, "RMB", k.cps.map(|c| c.1)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let rate = cps.map(|n| format!("{n} CPS"));
|
||||
let lines: Vec<&str> = std::iter::once(label).chain(rate.as_deref()).collect();
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + i as f32 * (mw + GAP), my, mw, KEY],
|
||||
held,
|
||||
&lines,
|
||||
);
|
||||
}
|
||||
// The space bar: a line in a short key.
|
||||
let sy = my + step;
|
||||
key_box(batch, atlas, [x, sy, w, 10.0], k.jump, &[]);
|
||||
let ink = if k.jump {
|
||||
rgba(20, 20, 20, 255)
|
||||
} else {
|
||||
rgba(255, 255, 255, 255)
|
||||
};
|
||||
batch.fill(atlas, x + w / 2.0 - 12.0, sy + 4.0, 24.0, 2.0, ink);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -259,15 +140,4 @@ mod tests {
|
||||
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
|
||||
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_text_with_and_without_lows() {
|
||||
let r = FpsReading {
|
||||
fps: 144,
|
||||
min: 61,
|
||||
low: 98,
|
||||
};
|
||||
assert_eq!(fps_text(r, false), "144 fps");
|
||||
assert_eq!(fps_text(r, true), "144 fps min 61 1% 98");
|
||||
}
|
||||
}
|
||||
@@ -891,8 +891,11 @@ impl Font {
|
||||
}
|
||||
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
|
||||
}
|
||||
/// Font.width of a component: `§` codes in the spans are formatting,
|
||||
/// not glyphs.
|
||||
pub fn width_text(&self, t: &Text) -> f32 {
|
||||
t.chars()
|
||||
t.resolved()
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -390,6 +390,9 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn screen_label(&self) -> &'static str {
|
||||
if self.dialog_open() {
|
||||
return "dialog";
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return "title";
|
||||
}
|
||||
@@ -416,6 +419,7 @@ impl Game<'_> {
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -469,7 +473,8 @@ impl Game<'_> {
|
||||
"flying": p.flying,
|
||||
"fall_flying": p.fall_flying,
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"sprinting": p.sprinting,
|
||||
"fov_modifier": self.view.fov_modifier,
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
@@ -483,6 +488,7 @@ impl Game<'_> {
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": self.title.as_ref().map(|_| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
@@ -680,6 +686,18 @@ impl Game<'_> {
|
||||
v["craft_size"] = json!(self.craft_size);
|
||||
v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind)));
|
||||
v["container"] = json!(self.container);
|
||||
// The title a server gave the menu, span by span as it is drawn.
|
||||
if let Some(title) = self.remote_menu_title() {
|
||||
let spans = title.spans.iter().map(|s| {
|
||||
json!({
|
||||
"text": s.text,
|
||||
"color": s.style.color.map(|c| format!("#{c:06X}")),
|
||||
"bold": s.style.is_bold(),
|
||||
"italic": s.style.is_italic(),
|
||||
})
|
||||
});
|
||||
v["title"] = json!(spans.collect::<Vec<_>>());
|
||||
}
|
||||
// The recipe book's buttons (GUI pixels): the book button, then
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
@@ -1598,8 +1616,12 @@ impl Game<'_> {
|
||||
if self.screen != Screen::Dead {
|
||||
return Err("respawn needs the death screen".into());
|
||||
}
|
||||
let label =
|
||||
horizon_client::text::Text::translate("deathScreen.respawn", &[]).plain();
|
||||
let key = if self.level.hardcore {
|
||||
"deathScreen.spectate"
|
||||
} else {
|
||||
"deathScreen.respawn"
|
||||
};
|
||||
let label = horizon_client::text::Text::translate(key, &[]).plain();
|
||||
let w = widgets::find(&widgets::last(), &label)?;
|
||||
if !w.enabled {
|
||||
// DeathScreen enables its buttons after 20 ticks.
|
||||
|
||||
@@ -519,7 +519,12 @@ impl Game<'_> {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
&[(horizon_client::text::Text::literal(text), u32::MAX)],
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
|
||||
|
||||
@@ -8,10 +8,15 @@ use horizon_client::{
|
||||
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
containers::BlockEntity,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save::number,
|
||||
skins,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// The handle of the local player among a bell's gathered entities.
|
||||
@@ -84,6 +89,16 @@ impl Game<'_> {
|
||||
self.lid_openers_changed(other, open);
|
||||
}
|
||||
}
|
||||
_ if self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name
|
||||
.as_ref()
|
||||
== "minecraft:barrel" =>
|
||||
{
|
||||
self.barrel_openers_changed(key, open);
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
@@ -102,15 +117,48 @@ impl Game<'_> {
|
||||
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
|
||||
/// and for the guests.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
let count = self.lid_openers.entry(key).or_default();
|
||||
*count = (*count + if open { 1 } else { -1 }).max(0);
|
||||
let count = *count;
|
||||
if count == 0 {
|
||||
self.lid_openers.remove(&key);
|
||||
}
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
let pos = [key.0, key.1, key.2];
|
||||
self.block_entity_event(pos, 1, count);
|
||||
self.lan_block_event(pos, 1, count);
|
||||
// TrappedChestBlockEntity.signalOpenCount: its signal is the count
|
||||
// (`TrappedChestBlock.getSignal`), so the blocks around it and
|
||||
// below it hear of a change.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if before != count && self.res.facts().get(state).name.as_ref() == "minecraft:trapped_chest"
|
||||
{
|
||||
let p = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
let block = b.block(state);
|
||||
b.update_neighbors_at(level, ticks, p, block);
|
||||
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
|
||||
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
if (before == 0) != (count == 0) {
|
||||
self.set_bool_prop(
|
||||
key,
|
||||
"open",
|
||||
count > 0,
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// One opener more (`open`) or less at `key`: the count before and after
|
||||
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
|
||||
fn count_opener(&mut self, key: (i32, i32, i32), open: bool) -> (i32, i32) {
|
||||
let count = self.lid_openers.entry(key).or_default();
|
||||
let before = *count;
|
||||
*count = (before + if open { 1 } else { -1 }).max(0);
|
||||
let after = *count;
|
||||
if after == 0 {
|
||||
self.lid_openers.remove(&key);
|
||||
}
|
||||
(before, after)
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
|
||||
/// box meets the bell's block box inflated by 48.
|
||||
@@ -392,4 +440,25 @@ impl Game<'_> {
|
||||
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
self.be_anims.lid(pos, partial)
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin`: the skin sprite of the profile of
|
||||
/// the player head at `pos`. The skin download is started here; while it
|
||||
/// is in flight (or when it cannot be got) the profile's default skin
|
||||
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
|
||||
let Some(BlockEntity::Skull(skull)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let skin = skull.skin.as_ref()?;
|
||||
if let Some((url, location)) = &skin.download {
|
||||
self.skin_downloads
|
||||
.borrow_mut()
|
||||
.state(skins::Kind::Skin, url);
|
||||
if self.gui.atlas.sprites.contains_key(location) {
|
||||
return Some(location);
|
||||
}
|
||||
}
|
||||
Some(skin.fallback)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ use horizon_world::registry::BlockStateId;
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_block_entities(&mut self) -> Result<()> {
|
||||
self.tick_mount_menu();
|
||||
self.tick_craft_table();
|
||||
self.tick_container_reach();
|
||||
self.load_entity_types();
|
||||
if !self.world.block_entities.has_states() {
|
||||
let states = self.ticking_states();
|
||||
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
pub(super) const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! (game rules, the world clock, difficulty), bound to the game.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
chat::{
|
||||
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
|
||||
SYSTEM_INDICATOR,
|
||||
},
|
||||
command::Registries,
|
||||
gamerules::GameRules,
|
||||
hud::HudBatch,
|
||||
@@ -18,7 +21,7 @@ fn chat_tree<'a>(
|
||||
mods: &'a super::mods::ModsState,
|
||||
) -> &'a horizon_client::command::Dispatcher {
|
||||
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
|
||||
Some(tree) => tree,
|
||||
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
|
||||
None => mods.dispatcher(),
|
||||
}
|
||||
}
|
||||
@@ -99,6 +102,8 @@ pub struct UiState {
|
||||
/// Death screen cause and when the player died (buttons wait 20 ticks).
|
||||
pub death_text: Option<Text>,
|
||||
pub died_at: Option<std::time::Instant>,
|
||||
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
|
||||
pub death_time: u32,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -423,6 +428,7 @@ impl Game<'_> {
|
||||
self.ui.chat.recent.len(),
|
||||
b.width as i32,
|
||||
b.height as i32,
|
||||
self.chat_abilities(),
|
||||
);
|
||||
screen.auto_suggestions = self.settings.auto_suggestions;
|
||||
self.ui.chat_screen = Some(screen);
|
||||
@@ -441,6 +447,10 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `Minecraft.computeChatAbilities` from the Chat option.
|
||||
pub(super) fn chat_abilities(&self) -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
|
||||
}
|
||||
/// A chat message from the game (ChatComponent.addServerSystemMessage).
|
||||
pub(super) fn system_message(&mut self, text: Text) {
|
||||
if let Some(capture) = self.chat_capture.as_mut() {
|
||||
@@ -450,7 +460,11 @@ impl Game<'_> {
|
||||
// Connected to a server (single player's own included), the chat on
|
||||
// screen is the connection's: the game's own messages join it.
|
||||
if self.remote.is_some() {
|
||||
self.chat_line(text);
|
||||
self.chat_line(text, ChatSource::Server);
|
||||
return;
|
||||
}
|
||||
// The Chat option's Hidden level takes the server's messages too.
|
||||
if !self.ui.chat.shows(ChatSource::Server) {
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -459,6 +473,7 @@ impl Game<'_> {
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
Some(SYSTEM_INDICATOR),
|
||||
ChatSource::Server,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
@@ -568,7 +583,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// ChatScreen.handleChatInput: history, then a command or a chat line.
|
||||
/// The Chat option's restrictions stop the message here too, so a
|
||||
/// control-socket `chat` cannot send what the player's setting hides.
|
||||
pub(super) fn handle_chat_input(&mut self, message: &str) {
|
||||
if !self.chat_abilities().allows(message) {
|
||||
return;
|
||||
}
|
||||
self.ui.chat.add_recent(message);
|
||||
// On a server (single player's own included) the server runs it,
|
||||
// unless a client mod owns the command.
|
||||
@@ -586,13 +606,18 @@ impl Game<'_> {
|
||||
} else {
|
||||
let line = Text::translate(
|
||||
"chat.type.text",
|
||||
&[Text::literal(PLAYER_NAME), Text::literal(message)],
|
||||
&[self.player_display_name(), Text::literal(message)],
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.running().then(|| line.plain());
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
ChatSource::Player,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
if let Some(line) = heard {
|
||||
self.mods_chat(&line);
|
||||
}
|
||||
@@ -896,9 +921,14 @@ impl Game<'_> {
|
||||
Err(e) => Text::translate("screenshot.failure", &[Text::literal(e.clone())]),
|
||||
};
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, message, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
message,
|
||||
None,
|
||||
ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! The window's own ambient effects of entities, which a client makes for
|
||||
//! itself (`level().isClientSide()` branches): endermen's and endermites'
|
||||
//! portal particles, blazes' smoke and burn, eyes of ender's trail. They run
|
||||
//! on the game that has the window, in-frame or following a server, never on
|
||||
//! a server: nothing of them goes over the network.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::{MobEvent, ambient};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_client_ambient(&mut self) {
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
|
||||
ambient::client_ai_step(m, &mut events);
|
||||
}
|
||||
for event in events {
|
||||
match event {
|
||||
MobEvent::ParticleAt { kind, at, velocity } => {
|
||||
if let Some(ty) = ParticleType::from_name(kind) {
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ty), at, velocity);
|
||||
}
|
||||
}
|
||||
// Each guest of a LAN host makes its own.
|
||||
MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
.play_unshared(&format!("minecraft:{name}"), Some(at), volume, pitch)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.eye_particles();
|
||||
}
|
||||
}
|
||||
@@ -97,15 +97,25 @@ impl Game<'_> {
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
/// Typing or a backspace in the creative search box.
|
||||
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>, backspace: bool) {
|
||||
/// A backspace in the creative search box: with Ctrl, the whole word
|
||||
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
|
||||
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
|
||||
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
let cleared = self.text_selection.take(CREATIVE_SEARCH, search);
|
||||
if backspace {
|
||||
if !cleared {
|
||||
if !self.text_selection.take(CREATIVE_SEARCH, search) {
|
||||
if ctrl {
|
||||
horizon_client::ui::delete_words(search, -1);
|
||||
} else {
|
||||
search.pop();
|
||||
}
|
||||
} else if let Some(text) = typed {
|
||||
}
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
/// Typing in the creative search box.
|
||||
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
self.text_selection.take(CREATIVE_SEARCH, search);
|
||||
if let Some(text) = typed {
|
||||
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
|
||||
}
|
||||
self.creative_screen.scroll = 0.0;
|
||||
|
||||
@@ -191,8 +191,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage` for a player whose display name is
|
||||
/// `me`: a server's guest is not `PLAYER_NAME`.
|
||||
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
|
||||
self.recheck_combat();
|
||||
let me = Text::literal(PLAYER_NAME);
|
||||
let entries = std::mem::take(&mut self.combat.entries);
|
||||
self.combat.in_combat = false;
|
||||
let Some(killing) = entries.last() else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Headless command runner for `tools/test_command_exec_parity.py`: with
|
||||
//! `HORIZON_COMMAND_SCRIPT` and `HORIZON_COMMAND_OUTPUT` set, the opened world
|
||||
//! runs without a window. Each script line is a JSON operation: `cmd` runs a
|
||||
//! runs on its server thread with a connected, headless client. Each script
|
||||
//! line is a JSON operation: `cmd` runs a
|
||||
//! command as the player (Commands.performPrefixedCommand) and records every
|
||||
//! system message it produced, as styled spans, with the results reported to
|
||||
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
|
||||
@@ -41,30 +42,100 @@ pub(super) fn spans(text: &Text) -> Value {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Pump the real client's connection, without predicting movement: the
|
||||
/// oracle's player stays where commands put it. Errors cross the server
|
||||
/// boundary through Integrated::stop, including script and save failures.
|
||||
pub(super) fn wait_command_harness(&mut self) -> Result<()> {
|
||||
let server = self.integrated.take().ok_or("command harness: no server")?;
|
||||
while !server.stopped() {
|
||||
self.poll_stationary_client()?;
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
server.stop()
|
||||
}
|
||||
|
||||
/// The owner's authoritative body and level, using the same swaps as a
|
||||
/// server command. Always put them back, also when a script operation fails.
|
||||
fn harness_owner<T>(&mut self, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let owner = self
|
||||
.serving
|
||||
.as_ref()
|
||||
.ok_or("command harness: not a server")?
|
||||
.owner;
|
||||
let mut host = self.lan.take().ok_or("command harness: no hub")?;
|
||||
let guest = host.hub.guests.iter().find(|g| g.uuid == owner && g.placed);
|
||||
let Some(guest) = guest else {
|
||||
self.lan = Some(host);
|
||||
return Err("command harness: owner is not connected".into());
|
||||
};
|
||||
let (conn, dim, back) = (guest.conn, super::Dim::from_id(&guest.dimension), self.dim);
|
||||
if let Err(error) = self.enter_level(Some(&mut host), dim) {
|
||||
self.lan = Some(host);
|
||||
return Err(error);
|
||||
}
|
||||
let swap = self
|
||||
.enter_guest(&mut host, conn)
|
||||
.expect("the owner is not already acting");
|
||||
self.lan = Some(host);
|
||||
let result = f(self);
|
||||
let mut host = self.lan.take().expect("the operation puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
self.run_level_teleports(&mut host);
|
||||
let restored = self.enter_level(Some(&mut host), back);
|
||||
self.lan = Some(host);
|
||||
restored?;
|
||||
result
|
||||
}
|
||||
|
||||
/// The player's portal process finished in the nether portal it stands
|
||||
/// in (the oracle's player is not ticked, so neither is the wait).
|
||||
fn harness_portal(&mut self) -> Result<()> {
|
||||
let Some(p) = &self.player else {
|
||||
return Err("command harness: portal without a player".into());
|
||||
};
|
||||
let entry = horizon_core::position::BlockPos::containing(p.position);
|
||||
let block = self.world.block(entry.x, entry.y, entry.z);
|
||||
if &*self.res.facts().get(block).name != "minecraft:nether_portal" {
|
||||
return Err("command harness: the player is not in a nether portal".into());
|
||||
}
|
||||
self.player_through_nether_portal(entry)
|
||||
let (conn, plan) = self.harness_owner(|g| {
|
||||
let p = g
|
||||
.player
|
||||
.as_ref()
|
||||
.ok_or("command harness: portal without a player")?;
|
||||
let entry = horizon_core::position::BlockPos::containing(p.position);
|
||||
let block = g.world.block(entry.x, entry.y, entry.z);
|
||||
if &*g.res.facts().get(block).name != "minecraft:nether_portal" {
|
||||
return Err("command harness: the player is not in a nether portal".into());
|
||||
}
|
||||
let plan = g.portal_plan(
|
||||
entry,
|
||||
p.position,
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
true,
|
||||
);
|
||||
Ok((
|
||||
g.lan_acting.ok_or("command harness: no acting owner")?,
|
||||
plan,
|
||||
))
|
||||
})?;
|
||||
// Start travel after restoring the owner's body: change_level updates
|
||||
// its hub record, which leave_guest would otherwise overwrite.
|
||||
self.in_owner_level(|g| {
|
||||
let mut host = g.lan.take().ok_or("command harness: no hub")?;
|
||||
g.start_server_trip(&mut host, conn, plan.dim, Some(plan));
|
||||
g.lan = Some(host);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
/// Load terrain until the player stands in lit chunks.
|
||||
fn harness_settle(&mut self, deadline: Instant) -> Result<()> {
|
||||
loop {
|
||||
self.update_world();
|
||||
self.spawn_player()?;
|
||||
if let Some(p) = &self.player {
|
||||
let (cx, cz) = (
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
);
|
||||
if (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
// Finish requested terrain before inspecting it, so worker
|
||||
// timing cannot change which command positions are loaded.
|
||||
if !self.streaming.busy()
|
||||
&& (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
|
||||
{
|
||||
// The oracle's settle also waits for the entities saved
|
||||
// in those chunks (areEntitiesActuallyLoadedAndTicking):
|
||||
// after a dimension change they come back from the save.
|
||||
@@ -95,7 +166,19 @@ impl Game<'_> {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(600),
|
||||
);
|
||||
self.harness_settle(deadline)?;
|
||||
// Join/configuration uses the normal hub, but never advances the world.
|
||||
while !self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.placed && g.loaded))
|
||||
{
|
||||
self.tick_server(true)?;
|
||||
if Instant::now() > deadline {
|
||||
return Err("command harness: owner did not connect".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
let mut out = String::new();
|
||||
for line in std::fs::read_to_string(script)?.lines() {
|
||||
if line.trim().is_empty() {
|
||||
@@ -107,9 +190,12 @@ impl Game<'_> {
|
||||
let command = op["cmd"].as_str().unwrap_or("");
|
||||
self.chat_capture = Some(Vec::new());
|
||||
self.cmds.results = Some(Vec::new());
|
||||
self.as_console(|g| {
|
||||
g.run_command(command.strip_prefix('/').unwrap_or(command))
|
||||
});
|
||||
self.harness_owner(|g| {
|
||||
g.as_console(|g| {
|
||||
g.run_command(command.strip_prefix('/').unwrap_or(command))
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
let results = self.cmds.results.take().unwrap_or_default();
|
||||
if op["record"].as_bool() != Some(false) {
|
||||
@@ -124,17 +210,8 @@ impl Game<'_> {
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
// The oracle's player has no client moving it: hold the
|
||||
// player where commands put it while the game ticks.
|
||||
let held = self.player.as_ref().map(|p| p.position);
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
self.update_world();
|
||||
self.tick()?;
|
||||
if let (Some(p), Some(at)) = (self.player.as_mut(), held) {
|
||||
p.position = at;
|
||||
p.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0);
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.tick_server(false)?;
|
||||
}
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
if op["record"].as_bool() == Some(true) {
|
||||
@@ -147,7 +224,17 @@ impl Game<'_> {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"settle" => self.harness_settle(deadline)?,
|
||||
"settle" => {
|
||||
// Finish portal arrivals using connection-only passes too.
|
||||
while !self.server_trips.is_empty() {
|
||||
self.tick_server(true)?;
|
||||
if Instant::now() > deadline {
|
||||
return Err("command harness: portal did not arrive".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
}
|
||||
"portal" => self.harness_portal()?,
|
||||
other => return Err(format!("command harness: unknown op {other}").into()),
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod items;
|
||||
mod misc;
|
||||
mod place;
|
||||
mod players;
|
||||
pub(super) mod pregen;
|
||||
mod raid;
|
||||
mod terrain_cmds;
|
||||
mod text_commands;
|
||||
@@ -214,7 +215,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Player.getDisplayName: the name (team-formatted), suggesting a
|
||||
/// `/tell`, showing the player on hover, inserting the name.
|
||||
fn player_display_name(&self) -> Text {
|
||||
pub(super) fn player_display_name(&self) -> Text {
|
||||
let name = self.acting_player_name().to_owned();
|
||||
let Some(uuid) = self.lan_acting_uuid else {
|
||||
return self.target_name(Target::Player);
|
||||
@@ -444,7 +445,7 @@ impl Game<'_> {
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| i.stack.item.to_string())
|
||||
.unwrap_or_default();
|
||||
Text::literal(self.pack.names.item(&item))
|
||||
Text::literal(self.pack.names.display_item(&item))
|
||||
}
|
||||
Target::Mob(id)
|
||||
if self
|
||||
@@ -751,13 +752,15 @@ impl Game<'_> {
|
||||
&& !id.ends_with("get")
|
||||
&& let Ok(t) = self.entities(ctx, "target", false)
|
||||
{
|
||||
self.cmds.waypoint_dirty.push(t[0]);
|
||||
let target = self.waypoint_target(t[0]);
|
||||
self.cmds.waypoint_dirty.push(target);
|
||||
}
|
||||
result
|
||||
}
|
||||
"ride.mount" | "ride.dismount" => self.cmd_ride(id, ctx),
|
||||
"spreadplayers" => self.cmd_spreadplayers(ctx),
|
||||
id if id.starts_with("forceload.") => self.cmd_forceload(id, ctx),
|
||||
id if id.starts_with("pregen.") => self.cmd_pregen(id, ctx),
|
||||
"fillbiome" => self.cmd_fillbiome(ctx),
|
||||
id if id.starts_with("worldborder.") => self.cmd_worldborder(id, ctx),
|
||||
id if id.starts_with("loot.") => self.cmd_loot(id, ctx),
|
||||
@@ -2499,12 +2502,14 @@ impl Game<'_> {
|
||||
};
|
||||
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
|
||||
let counting = max == 0;
|
||||
// Inventory.clearOrCountMatchingItems: main, armor, crafting grid, carried.
|
||||
// Inventory slots 0..40: main, armor feet to head, off hand;
|
||||
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
|
||||
let mut removed = 0i32;
|
||||
let mut budget = if max < 0 { i32::MAX } else { max };
|
||||
let all = |g: &Self| -> Vec<Option<ItemStack>> {
|
||||
let mut v: Vec<Option<ItemStack>> = g.inventory.clone();
|
||||
v.extend(g.armor.iter().cloned());
|
||||
v.extend(g.armor.iter().rev().cloned());
|
||||
v.push(g.offhand.clone());
|
||||
v.extend(g.craft_grid.iter().cloned());
|
||||
v.push(g.carried.clone());
|
||||
v
|
||||
@@ -2518,7 +2523,8 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
|
||||
slots.extend(self.inventory.iter_mut());
|
||||
slots.extend(self.armor.iter_mut());
|
||||
slots.extend(self.armor.iter_mut().rev());
|
||||
slots.push(&mut self.offhand);
|
||||
slots.extend(self.craft_grid.iter_mut());
|
||||
slots.push(&mut self.carried);
|
||||
for (slot, matches) in slots.into_iter().zip(matching) {
|
||||
@@ -2809,7 +2815,7 @@ impl Game<'_> {
|
||||
let name = match self.command_source.map(|s| s.entity) {
|
||||
Some(Some(t)) => self.target_name(t),
|
||||
Some(None) => Text::literal("Server"),
|
||||
None => Text::literal(self.acting_player_name()),
|
||||
None => self.player_display_name(),
|
||||
};
|
||||
let line = match id {
|
||||
"say" => Text::translate("chat.type.announcement", &[name, message]),
|
||||
@@ -2819,14 +2825,14 @@ impl Game<'_> {
|
||||
// Whispering to yourself: the incoming and outgoing lines.
|
||||
let out = Text::translate(
|
||||
"commands.message.display.outgoing",
|
||||
&[Text::literal(self.acting_player_name()), message.clone()],
|
||||
&[self.player_display_name(), message.clone()],
|
||||
)
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.system_message(out);
|
||||
Text::translate(
|
||||
"commands.message.display.incoming",
|
||||
&[Text::literal(self.acting_player_name()), message],
|
||||
&[self.player_display_name(), message],
|
||||
)
|
||||
.with_color(text::GRAY)
|
||||
.italic()
|
||||
@@ -2836,9 +2842,14 @@ impl Game<'_> {
|
||||
capture.push(line.clone());
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Player,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
Ok(1)
|
||||
}
|
||||
/// MessageArgument: `@p`-style selectors in a message become names.
|
||||
|
||||
@@ -606,10 +606,7 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => match entity {
|
||||
Target::Player => self.riding.map(Target::Mob).into_iter().collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
|
||||
Some("controller") | Some("passengers") => match entity {
|
||||
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
|
||||
vec![Target::Player]
|
||||
|
||||
@@ -57,6 +57,8 @@ pub(in crate::game) struct CommandExtras {
|
||||
/// The `CommandResultCallback` results of the outermost source, when a
|
||||
/// harness records them.
|
||||
pub results: Option<Vec<(bool, i32)>>,
|
||||
/// The running `/pregen` task.
|
||||
pub pregen: Option<super::pregen::PregenTask>,
|
||||
}
|
||||
|
||||
use horizon_core::version::{BUILD_TIME, DATA_VERSION, STABLE, VANILLA_VERSION, VERSION_ID};
|
||||
@@ -212,8 +214,22 @@ impl Game<'_> {
|
||||
|
||||
// ---- /waypoint ------------------------------------------------------
|
||||
|
||||
/// A guest's body is only `Player` during its turn; retained command state
|
||||
/// must use its connection so the next server tick still finds that body.
|
||||
pub(super) fn waypoint_target(&self, t: Target) -> Target {
|
||||
match t {
|
||||
Target::Player => self.lan_acting.map_or(t, Target::Guest),
|
||||
_ => t,
|
||||
}
|
||||
}
|
||||
|
||||
/// A living entity's `waypoint_transmit_range` (`isTransmittingWaypoint`).
|
||||
fn transmit_range(&mut self, t: Target) -> f64 {
|
||||
if matches!(t, Target::Guest(_)) {
|
||||
return self
|
||||
.for_player(t, |g| g.transmit_range(Target::Player))
|
||||
.unwrap_or(0.0);
|
||||
}
|
||||
if t == Target::Player {
|
||||
return self
|
||||
.player
|
||||
@@ -242,7 +258,7 @@ impl Game<'_> {
|
||||
fn waypoint_transmitters(&mut self) -> Vec<Target> {
|
||||
if self.cmds.waypoints.is_none() {
|
||||
let tracked = if self.transmit_range(Target::Player) > 0.0 {
|
||||
vec![Target::Player]
|
||||
vec![self.waypoint_target(Target::Player)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -255,6 +271,12 @@ impl Game<'_> {
|
||||
.collect();
|
||||
self.cmds.waypoints = Some(alive.clone());
|
||||
alive
|
||||
.into_iter()
|
||||
.map(|t| match t {
|
||||
Target::Guest(conn) if self.lan_acting == Some(conn) => Target::Player,
|
||||
_ => t,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The waypoint part of a living entity's tick: dirty transmit ranges
|
||||
/// track or untrack it.
|
||||
@@ -277,7 +299,7 @@ impl Game<'_> {
|
||||
fn waypoint_icon(&self, t: Target) -> WaypointIcon {
|
||||
self.cmds
|
||||
.waypoint_icons
|
||||
.get(&t)
|
||||
.get(&self.waypoint_target(t))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -387,6 +409,7 @@ impl Game<'_> {
|
||||
&[Text::colored(name, rgb)],
|
||||
)
|
||||
};
|
||||
let t = self.waypoint_target(t);
|
||||
self.cmds.waypoint_icons.insert(t, icon);
|
||||
// WaypointCommand.mutateIcon: untrack, change, track again.
|
||||
self.waypoint_transmitters();
|
||||
@@ -606,6 +629,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| lan.hub.singleplayer_owner != Some(g.uuid))
|
||||
.filter(|g| match &s.player_name {
|
||||
Some(name) => g.name.eq_ignore_ascii_case(name),
|
||||
None => s.kind == 'a' || s.kind == 'e',
|
||||
@@ -617,7 +641,7 @@ impl Game<'_> {
|
||||
if host.is_empty() && guests.is_empty() {
|
||||
return Err(err("argument.entity.notfound.player", &[]));
|
||||
}
|
||||
if self.lan.is_none() {
|
||||
if self.lan.is_none() || (self.serving() && self.published_port().is_none()) {
|
||||
return Err(err("commands.kick.singleplayer.failed", &[]));
|
||||
}
|
||||
let reason_raw = match ctx.get("reason") {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! `/pregen`, Horizon's own command: generate, light and save every chunk of a
|
||||
//! square or a circle ahead of the players (docs/pregeneration.md). The task
|
||||
//! lives on the server and hands its band to the level's streaming, whose
|
||||
//! workers do the work; chunks behind the band unload and are saved as any
|
||||
//! chunk the players leave.
|
||||
use super::{CommandError, Context, Dim, Game, R};
|
||||
use horizon_client::{
|
||||
lan::data::component_nbt,
|
||||
pregen::{Pregen, Shape},
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the progress is reported.
|
||||
const REPORT_EVERY: Duration = Duration::from_secs(5);
|
||||
/// How long the band may stay put before the task looks for what holds it.
|
||||
const STALL: Duration = Duration::from_secs(30);
|
||||
|
||||
pub(in crate::game) struct PregenTask {
|
||||
pregen: Pregen,
|
||||
dim: Dim,
|
||||
paused: bool,
|
||||
/// Time spent running, up to the last pause, and when it last resumed.
|
||||
ran: Duration,
|
||||
resumed: Instant,
|
||||
reported: Instant,
|
||||
/// When the band last moved, to notice a task that stopped advancing.
|
||||
moved: Instant,
|
||||
/// The player who started it, told the progress too (a guest's connection).
|
||||
owner: Option<u32>,
|
||||
}
|
||||
|
||||
impl PregenTask {
|
||||
fn running_for(&self) -> Duration {
|
||||
if self.paused {
|
||||
self.ran
|
||||
} else {
|
||||
self.ran + self.resumed.elapsed()
|
||||
}
|
||||
}
|
||||
fn progress(&self) -> Text {
|
||||
let p = &self.pregen;
|
||||
let seconds = self.running_for().as_secs_f64();
|
||||
let rate = p.done as f64 / seconds.max(1e-3);
|
||||
let left = p.total - p.done;
|
||||
let eta = if rate > 0.0 {
|
||||
duration(left as f64 / rate)
|
||||
} else {
|
||||
"unknown".into()
|
||||
};
|
||||
Text::literal(format!(
|
||||
"Pregenerating {}: {} of {} chunks ({:.1}%), {rate:.1} chunks/s, {} left",
|
||||
self.dim.id(),
|
||||
p.done,
|
||||
p.total,
|
||||
100.0 * p.done as f64 / p.total.max(1) as f64,
|
||||
if p.done == p.total {
|
||||
"nothing".into()
|
||||
} else {
|
||||
eta
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(message: &str) -> CommandError {
|
||||
CommandError::new(Text::literal(message))
|
||||
}
|
||||
|
||||
/// `1h 02m 03s`.
|
||||
fn duration(seconds: f64) -> String {
|
||||
let s = seconds.round() as u64;
|
||||
match (s / 3600, s / 60 % 60, s % 60) {
|
||||
(0, 0, s) => format!("{s}s"),
|
||||
(0, m, s) => format!("{m}m {s:02}s"),
|
||||
(h, m, s) => format!("{h}h {m:02}m {s:02}s"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn cmd_pregen(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
match id {
|
||||
"pregen.start" => {
|
||||
if self.cmds.pregen.is_some() {
|
||||
return Err(failure(
|
||||
"Pregeneration is already running: /pregen cancel stops it",
|
||||
));
|
||||
}
|
||||
let radius = Self::int(ctx, "radius").unwrap_or(0);
|
||||
let shape = if ctx.literals.iter().any(|l| l == "circle") {
|
||||
Shape::Circle
|
||||
} else {
|
||||
Shape::Square
|
||||
};
|
||||
let [x, z] = if ctx.get("center").is_some() {
|
||||
self.column_arg(ctx, "center")?
|
||||
} else {
|
||||
let p = self.source_position();
|
||||
[p[0].floor() as i32, p[2].floor() as i32]
|
||||
};
|
||||
let pregen = Pregen::new((x >> 4, z >> 4), radius >> 4, shape);
|
||||
let now = Instant::now();
|
||||
let total = pregen.total;
|
||||
self.feedback(Text::literal(format!(
|
||||
"Pregenerating {total} chunks of {}: a {} of radius {radius} around {x} {z}",
|
||||
self.dim.id(),
|
||||
match shape {
|
||||
Shape::Square => "square",
|
||||
Shape::Circle => "circle",
|
||||
},
|
||||
)));
|
||||
self.cmds.pregen = Some(PregenTask {
|
||||
pregen,
|
||||
dim: self.dim,
|
||||
paused: false,
|
||||
ran: Duration::ZERO,
|
||||
resumed: now,
|
||||
reported: now,
|
||||
moved: now,
|
||||
owner: self.lan_acting,
|
||||
});
|
||||
Ok(total as i32)
|
||||
}
|
||||
"pregen.pause" | "pregen.continue" => {
|
||||
let pause = id == "pregen.pause";
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.as_mut()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
if task.paused != pause {
|
||||
task.paused = pause;
|
||||
if pause {
|
||||
task.ran += task.resumed.elapsed();
|
||||
} else {
|
||||
task.resumed = Instant::now();
|
||||
}
|
||||
}
|
||||
let (progress, dim) = (task.progress(), task.dim);
|
||||
if pause && self.dim == dim {
|
||||
self.streaming.set_band(None);
|
||||
}
|
||||
self.feedback(
|
||||
Text::literal(if pause { "Paused. " } else { "Continuing. " }).append(progress),
|
||||
);
|
||||
Ok(1)
|
||||
}
|
||||
"pregen.cancel" => {
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.take()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
if self.dim == task.dim {
|
||||
self.streaming.set_band(None);
|
||||
}
|
||||
self.feedback(Text::literal("Cancelled. ").append(task.progress()));
|
||||
Ok(task.pregen.done as i32)
|
||||
}
|
||||
_ => {
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.as_ref()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
let progress = task.progress();
|
||||
let done = task.pregen.done as i32;
|
||||
self.feedback(progress);
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the task's band past the rows that are lit and hand it to the
|
||||
/// level's streaming, and report the progress now and then. Runs before
|
||||
/// each streaming update of the level in place.
|
||||
pub(in crate::game) fn tick_pregen(&mut self) {
|
||||
let Some(task) = self.cmds.pregen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if task.dim != self.dim || task.paused {
|
||||
return;
|
||||
}
|
||||
// A row is done once its chunks are lit and post-processed
|
||||
// (`postprocess_chunks`, on the tick), as vanilla's are by the time
|
||||
// they are FULL: then they may unload and be saved.
|
||||
let (streaming, chunks) = (&mut self.streaming, &self.world.chunks);
|
||||
if task.pregen.advance(|x, z| {
|
||||
streaming.is_lit(x, z)
|
||||
&& chunks
|
||||
.get(&(x, z))
|
||||
.is_some_and(|c| c.postprocess.is_empty())
|
||||
}) {
|
||||
task.moved = Instant::now();
|
||||
}
|
||||
streaming.set_band(task.pregen.band());
|
||||
if task.moved.elapsed() >= STALL {
|
||||
// Nothing advanced for a while: say what the first row waits
|
||||
// for, and queue light again for chunks that lost their job.
|
||||
task.moved = Instant::now();
|
||||
let row = task.pregen.first_row();
|
||||
let waiting = row
|
||||
.iter()
|
||||
.filter(|k| chunks.get(k).is_some_and(|c| !c.postprocess.is_empty()))
|
||||
.count();
|
||||
let [missing, lighting, unlit] = streaming.unstick_light(&mut self.world, &row);
|
||||
log::warn!(
|
||||
"Horizon: pregeneration waits on row {:?}: {missing} not loaded, {lighting} being lit, \
|
||||
{unlit} unlit with no light job (queued again), {waiting} not post-processed; {:?}",
|
||||
row.first(),
|
||||
streaming.stages()
|
||||
);
|
||||
}
|
||||
let finished = task.pregen.finished();
|
||||
if finished || task.reported.elapsed() >= REPORT_EVERY {
|
||||
task.reported = Instant::now();
|
||||
let (owner, mut message) = (task.owner, task.progress());
|
||||
if finished {
|
||||
message = Text::literal("Done. ")
|
||||
.append(message)
|
||||
.with_color(text::GREEN);
|
||||
self.cmds.pregen = None;
|
||||
}
|
||||
self.pregen_report(owner, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the level in place has a running task, which the server pumps
|
||||
/// between its ticks (`pump_pregen`).
|
||||
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
|
||||
self.cmds
|
||||
.pregen
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.dim == self.dim && !t.paused)
|
||||
}
|
||||
|
||||
/// The level a running task needs ticked, whoever is in it.
|
||||
pub(in crate::game) fn pregen_level(&self) -> Option<Dim> {
|
||||
self.cmds.pregen.as_ref().map(|t| t.dim)
|
||||
}
|
||||
|
||||
/// Keep the workers fed until `until`: collect what they finished and
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
while self.pregen_runs_here() {
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
break;
|
||||
}
|
||||
self.streaming
|
||||
.wait((until - now).min(Duration::from_millis(5)));
|
||||
self.update_world();
|
||||
}
|
||||
}
|
||||
|
||||
/// The console and the player who started the task get the progress.
|
||||
fn pregen_report(&mut self, owner: Option<u32>, message: Text) {
|
||||
if self.options.dedicated.is_some() {
|
||||
super::super::dedicated::log("INFO", &message.plain());
|
||||
} else {
|
||||
log::info!("{}", message.plain());
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
if let Some(g) = owner.and_then(|c| host.hub.guest(c)) {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
content: component_nbt(&message.component()),
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
} else if self.options.dedicated.is_none() {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ fn ticks_to_seconds(ticks: i64) -> String {
|
||||
impl Game<'_> {
|
||||
// ---- /forceload ----------------------------------------------------------
|
||||
|
||||
fn column_arg(&self, ctx: &Context, name: &str) -> R<[i32; 2]> {
|
||||
pub(super) fn column_arg(&self, ctx: &Context, name: &str) -> R<[i32; 2]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos2d.incomplete", &[]))?;
|
||||
|
||||
@@ -1074,15 +1074,15 @@ impl Game<'_> {
|
||||
};
|
||||
let name = self.target_name(target);
|
||||
if id == "ride.dismount" {
|
||||
let vehicle = match target {
|
||||
Target::Player => self.riding.map(Target::Mob),
|
||||
_ => None,
|
||||
};
|
||||
let Some(v) = vehicle else {
|
||||
let Some(v) = self.ride_vehicle(target) else {
|
||||
return Err(err("commands.ride.not_riding", &[&name.plain()]));
|
||||
};
|
||||
let vehicle_name = self.target_name(v);
|
||||
self.dismount_mount();
|
||||
match target {
|
||||
Target::Player => self.dismount_mount(),
|
||||
Target::Mob(rider) => self.dismount_mob(rider),
|
||||
_ => {}
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.dismount.success",
|
||||
&[name, vehicle_name],
|
||||
@@ -1094,11 +1094,8 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let vehicle_name = self.target_name(vehicle);
|
||||
if target == Target::Player && self.riding.is_some() {
|
||||
let current = self
|
||||
.riding
|
||||
.map(Target::Mob)
|
||||
.map_or_else(Text::empty, |v| self.target_name(v));
|
||||
if let Some(current) = self.ride_vehicle(target) {
|
||||
let current = self.target_name(current);
|
||||
return Err(err(
|
||||
"commands.ride.already_riding",
|
||||
&[&name.plain(), ¤t.plain()],
|
||||
@@ -1107,22 +1104,42 @@ impl Game<'_> {
|
||||
if vehicle == Target::Player {
|
||||
return Err(err("commands.ride.mount.failure.cant_ride_players", &[]));
|
||||
}
|
||||
if target == vehicle {
|
||||
return Err(err("commands.ride.mount.failure.loop", &[]));
|
||||
// getSelfAndPassengers: the vehicle is the target or rides it.
|
||||
let mut up = Some(vehicle);
|
||||
while let Some(v) = up {
|
||||
if v == target {
|
||||
return Err(err("commands.ride.mount.failure.loop", &[]));
|
||||
}
|
||||
up = self.ride_vehicle(v);
|
||||
}
|
||||
// The player can ride mobs that carry a rider.
|
||||
// Players and mobs can ride mobs.
|
||||
let Target::Mob(mob_id) = vehicle else {
|
||||
return Err(err(
|
||||
"commands.ride.mount.failure.generic",
|
||||
&[&name.plain(), &vehicle_name.plain()],
|
||||
));
|
||||
};
|
||||
if target != Target::Player || !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) {
|
||||
let rider = match target {
|
||||
Target::Mob(id) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
if !(target == Target::Player || rider.is_some())
|
||||
|| !self.mobs.iter().any(|m| m.id == mob_id && m.alive())
|
||||
{
|
||||
return Err(err(
|
||||
"commands.ride.mount.failure.generic",
|
||||
&[&name.plain(), &vehicle_name.plain()],
|
||||
));
|
||||
}
|
||||
if let Some(rider) = rider {
|
||||
// Entity.startRiding.
|
||||
self.mount_mob(rider, mob_id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) {
|
||||
m.extra.flag = false;
|
||||
m.extra.counter = 0;
|
||||
@@ -1138,6 +1155,47 @@ impl Game<'_> {
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// Entity.getVehicle: the player's mount, or a mob's.
|
||||
pub(super) fn ride_vehicle(&self, t: Target) -> Option<Target> {
|
||||
let id = match t {
|
||||
Target::Player => return self.riding.map(Target::Mob),
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(if self.boats.iter().any(|b| b.id == id) {
|
||||
Target::Boat(id)
|
||||
} else {
|
||||
Target::Mob(id)
|
||||
})
|
||||
}
|
||||
/// LivingEntity.stopRiding for a mob: off its vehicle
|
||||
/// (Entity.removePassenger), then to where the vehicle lets it out
|
||||
/// (LivingEntity.dismountVehicle).
|
||||
fn dismount_mob(&mut self, rider: u32) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
|
||||
return;
|
||||
};
|
||||
let Some(v) = self.mobs[i].extra.mount.take() else {
|
||||
return;
|
||||
};
|
||||
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
|
||||
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
|
||||
self.boat_dismount_target(b, width, yaw)
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == v) {
|
||||
m.extra.rider = None;
|
||||
// Entity.getDismountLocationForPassenger: on top of the vehicle.
|
||||
let p = m.position;
|
||||
horizon_core::geometry::Vec3::new(p.x, p.y + f64::from(m.height()), p.z)
|
||||
} else {
|
||||
self.mobs[i].position
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = super::super::boat_passengers::BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
|
||||
// ---- /spreadplayers -----------------------------------------------------------------------
|
||||
|
||||
pub(super) fn cmd_spreadplayers(&mut self, ctx: &Context) -> R<i32> {
|
||||
|
||||
@@ -241,6 +241,13 @@ impl Game<'_> {
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
// A server's dialog takes the keys, in configuration too.
|
||||
if self.dialog_open() {
|
||||
if arg(2)? != "up" {
|
||||
self.dialog_key(key, None, None)?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
@@ -288,11 +295,12 @@ impl Game<'_> {
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
}
|
||||
// The zoom extra sees the key as the keyboard hook does.
|
||||
// Mods' keys (capability `input`) see the key as the keyboard
|
||||
// hook does.
|
||||
let action = arg(2)?;
|
||||
self.extras_key(physical, action != "up", action == "repeat");
|
||||
self.mod_key(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.extras_key(physical, false, false);
|
||||
self.mod_key(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
@@ -367,15 +375,19 @@ impl Game<'_> {
|
||||
match arg(2)? {
|
||||
"down" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.play_mouse(button, true)?
|
||||
}
|
||||
"up" => {
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, false)?
|
||||
}
|
||||
"click" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, true)?;
|
||||
self.play_mouse(button, false)?;
|
||||
}
|
||||
@@ -533,13 +545,17 @@ impl Game<'_> {
|
||||
))
|
||||
}
|
||||
"type" => {
|
||||
self.require_world()?;
|
||||
let text = line
|
||||
.trim_start()
|
||||
.strip_prefix("type")
|
||||
.unwrap_or("")
|
||||
.strip_prefix(' ')
|
||||
.unwrap_or("");
|
||||
// A server's dialog, in configuration too.
|
||||
if self.dialog_type(text) {
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
@@ -569,7 +585,7 @@ impl Game<'_> {
|
||||
}
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text), false)
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
@@ -596,6 +612,7 @@ impl Game<'_> {
|
||||
"font" => Page::Font,
|
||||
"packs" => Page::Packs,
|
||||
"mods" => Page::Mods,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
@@ -609,6 +626,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.open_options(self.title.is_some());
|
||||
self.ui.options.show(page);
|
||||
if page == Page::ShaderPacks {
|
||||
self.settings.shader_packs =
|
||||
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"chat-log" => {
|
||||
@@ -799,6 +820,7 @@ impl Game<'_> {
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,6 @@ fn arrow_damage(arrow: &mut Projectile, fallback: &mut impl RandomSource) -> f32
|
||||
#[derive(Default)]
|
||||
pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// Player status effects applied by mobs: (effect, ticks left, amplifier).
|
||||
pub pearl_cooldown: u32,
|
||||
/// The wind charge's use cooldown (ticks).
|
||||
pub wind_cooldown: u32,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
@@ -321,7 +317,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|m| m.distance_sqr(center) <= radius * radius)
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
let mut state = serde_json::json!({
|
||||
"id": m.id,
|
||||
"kind": m.kind.id,
|
||||
"pos": [m.position.x, m.position.y, m.position.z],
|
||||
@@ -370,7 +366,11 @@ impl Game<'_> {
|
||||
"patrol_target": s.patrol_target,
|
||||
"vehicle": m.extra.mount,
|
||||
})),
|
||||
})
|
||||
});
|
||||
// Drawn seated (Entity.isPassenger); set apart because the
|
||||
// json! above is at the macro recursion limit.
|
||||
state["passenger"] = m.extra.passenger.into();
|
||||
state
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "count": self.mobs.len(), "near": list, "projectiles": self.creatures.projectiles.len() })
|
||||
@@ -638,7 +638,6 @@ impl Game<'_> {
|
||||
self.tick_allays();
|
||||
self.tick_breeze_effects();
|
||||
self.tick_sniffer_effects();
|
||||
self.tick_player_effects();
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
@@ -1558,12 +1557,15 @@ impl Game<'_> {
|
||||
id, ticks, amplifier,
|
||||
));
|
||||
}
|
||||
/// Burning applied to the player by mobs.
|
||||
fn tick_player_effects(&mut self) {
|
||||
self.creatures.wind_cooldown = self.creatures.wind_cooldown.saturating_sub(1);
|
||||
if self.creatures.pearl_cooldown > 0 {
|
||||
self.creatures.pearl_cooldown -= 1;
|
||||
/// `WitherRoseBlock.entityInside` for the player: on a server level that
|
||||
/// isn't peaceful, `getBeeInteractionEffect` (Wither, 40 ticks) unless the
|
||||
/// player is invulnerable to wither damage (creative, spectator). A
|
||||
/// server's player gets it from the server.
|
||||
pub(super) fn wither_rose_contact(&mut self) {
|
||||
if self.remote.is_some() || self.ui.difficulty == 0 {
|
||||
return;
|
||||
}
|
||||
self.give_player_effect("minecraft:wither", 40, 0);
|
||||
}
|
||||
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
|
||||
if self.creative {
|
||||
@@ -1694,7 +1696,26 @@ impl Game<'_> {
|
||||
targets: targets_guests,
|
||||
};
|
||||
let guest_entities = self.lan_guest_entities();
|
||||
// ThrownEnderpearl.tick: a dead player's pearl is discarded in
|
||||
// place of its tick while ender_pearls_vanish_on_death is on.
|
||||
let vanish = self.ui.rules.bool("ender_pearls_vanish_on_death");
|
||||
let host_dead = self.player_view().is_some_and(|p| !p.alive);
|
||||
let dead_guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect()
|
||||
});
|
||||
projectiles.retain_mut(|p| {
|
||||
if vanish
|
||||
&& matches!(p.kind, ProjectileKind::EnderPearl)
|
||||
&& p.owner == Some(Who::Player)
|
||||
&& p.guest.map_or(host_dead, |c| dead_guests.contains(&c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
// A player's arrows, snowballs and eggs meet every player; a
|
||||
// guest's flies from that guest (Projectile.getOwner), not
|
||||
@@ -2846,14 +2867,14 @@ impl Game<'_> {
|
||||
let kind = match item {
|
||||
"minecraft:snowball" => ProjectileKind::Snowball,
|
||||
"minecraft:egg" | "minecraft:blue_egg" | "minecraft:brown_egg" => ProjectileKind::Egg,
|
||||
"minecraft:ender_pearl" => {
|
||||
if self.creatures.pearl_cooldown > 0 {
|
||||
return Ok(true);
|
||||
"minecraft:ender_pearl" => ProjectileKind::EnderPearl,
|
||||
"minecraft:wind_charge" => {
|
||||
let thrown = self.throw_wind_charge()?;
|
||||
if thrown {
|
||||
self.apply_use_cooldown(stack);
|
||||
}
|
||||
self.creatures.pearl_cooldown = 20;
|
||||
ProjectileKind::EnderPearl
|
||||
return Ok(thrown);
|
||||
}
|
||||
"minecraft:wind_charge" => return self.throw_wind_charge(),
|
||||
_ if item.ends_with("_spawn_egg") => return self.use_spawn_egg(stack),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
@@ -2878,6 +2899,9 @@ impl Game<'_> {
|
||||
);
|
||||
self.creatures.projectiles.push(proj);
|
||||
self.player_projectile_shot();
|
||||
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
self.apply_use_cooldown(stack);
|
||||
let sound = match kind {
|
||||
ProjectileKind::Snowball => "minecraft:entity.snowball.throw",
|
||||
ProjectileKind::Egg => "minecraft:entity.egg.throw",
|
||||
@@ -2998,6 +3022,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
let light = self.world.light(
|
||||
mob.position.x.floor() as i32,
|
||||
(mob.position.y + mob.eye_height()).floor() as i32,
|
||||
|
||||
@@ -415,6 +415,13 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// 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())
|
||||
});
|
||||
if !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
// tps: DebugEntryTps for local and remote connections.
|
||||
if self.player.is_some() {
|
||||
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
|
||||
|
||||
@@ -155,6 +155,14 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
|
||||
for w in &props.warnings {
|
||||
log("WARN", w);
|
||||
}
|
||||
// Horizon's own commands are the owner's choice, off as on vanilla.
|
||||
if props
|
||||
.raw
|
||||
.get(horizon_client::command::vanilla::PREGEN_PROPERTY)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
|
||||
{
|
||||
horizon_client::command::vanilla::set_horizon_commands(true);
|
||||
}
|
||||
props.store(&settings)?;
|
||||
let eula_file = PathBuf::from("eula.txt");
|
||||
let agreed = match eula::agreed(&eula_file) {
|
||||
@@ -667,7 +675,9 @@ impl Game<'_> {
|
||||
self.poll_server_control(&mut state);
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
if pause_after > 0 {
|
||||
if players == 0 {
|
||||
// A `/pregen` task keeps the server busy as a player would:
|
||||
// paused, nothing would post-process its chunks.
|
||||
if players == 0 && self.pregen_level().is_none() {
|
||||
state.empty_ticks += 1;
|
||||
} else {
|
||||
state.empty_ticks = 0;
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
//! A server's dialogs bound to the game (`ClientCommonPacketListenerImpl.
|
||||
//! showDialog` and `clearDialog`, `DialogScreen`, its `WarningScreen` and the
|
||||
//! `WaitingForResponseScreen`): the screen over whatever is open (the
|
||||
//! connecting screen in configuration), its inputs, and the actions its
|
||||
//! buttons send back.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
clipboard::EditShortcut,
|
||||
confirm_screen::Confirm,
|
||||
dialog::{AfterAction, Click, Control, Dialog, InputValue},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::time::Instant;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// An open `DialogScreen`.
|
||||
struct Open {
|
||||
dialog: Dialog,
|
||||
values: Vec<InputValue>,
|
||||
/// The box of each text input.
|
||||
fields: Vec<Option<ui::EditBox>>,
|
||||
focus: Option<usize>,
|
||||
scroll: ui::Scroll,
|
||||
}
|
||||
|
||||
/// The dialog screens a server can open.
|
||||
#[derive(Default)]
|
||||
pub(super) struct DialogState {
|
||||
open: Option<Open>,
|
||||
/// `WaitingForResponseScreen`, since when.
|
||||
waiting: Option<Instant>,
|
||||
/// `DialogScreen.WarningScreen`, over the dialog.
|
||||
warning: bool,
|
||||
}
|
||||
|
||||
fn warning_texts() -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
(
|
||||
t("menu.custom_screen_info.title"),
|
||||
t("menu.custom_screen_info.contents"),
|
||||
[t("menu.disconnect"), t("gui.back")],
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether a dialog screen takes the input.
|
||||
pub(super) fn dialog_open(&self) -> bool {
|
||||
let d = &self.dialogs;
|
||||
d.open.is_some() || d.waiting.is_some() || d.warning
|
||||
}
|
||||
|
||||
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
|
||||
/// under the warning screen, it is what Back returns to.
|
||||
pub(super) fn show_dialog(&mut self, tag: &Tag) {
|
||||
let Some(dialog) = Dialog::parse(tag) else {
|
||||
log::warn!("Horizon: failed to show dialog for data {tag:?}");
|
||||
return;
|
||||
};
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
let fields = dialog
|
||||
.inputs
|
||||
.iter()
|
||||
.map(|i| match &i.control {
|
||||
Control::Text {
|
||||
width,
|
||||
initial,
|
||||
max_length,
|
||||
..
|
||||
} => {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, *width as f32, 20.0);
|
||||
edit.max_length = (*max_length).max(1) as usize;
|
||||
edit.set_value(initial);
|
||||
edit.focused = false;
|
||||
Some(edit)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.dialogs.open = Some(Open {
|
||||
dialog,
|
||||
values,
|
||||
fields,
|
||||
focus: None,
|
||||
scroll: ui::Scroll::default(),
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.capture(false);
|
||||
}
|
||||
|
||||
/// `clearDialog`: the dialog closes (under the warning screen, Back then
|
||||
/// returns to what was open before it).
|
||||
pub(super) fn clear_dialog(&mut self) {
|
||||
if self.dialogs.open.take().is_some() && !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every dialog screen goes (a disconnect, or the level loading screen).
|
||||
pub(super) fn close_dialogs(&mut self) {
|
||||
self.dialogs = DialogState::default();
|
||||
}
|
||||
|
||||
fn dialog_closed(&mut self) {
|
||||
if self.player.is_some() && self.screen == super::Screen::Playing && self.title.is_none() {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The input values as the controls hold them now.
|
||||
fn dialog_values(open: &mut Open) {
|
||||
for (value, field) in open.values.iter_mut().zip(&open.fields) {
|
||||
if let Some(f) = field {
|
||||
*value = InputValue::Text(f.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
|
||||
/// `after_action` leaves.
|
||||
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
|
||||
match after {
|
||||
AfterAction::None => {}
|
||||
AfterAction::Close => self.dialogs.open = None,
|
||||
AfterAction::WaitForResponse => {
|
||||
self.dialogs.open = None;
|
||||
self.dialogs.waiting = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
match click {
|
||||
Some(Click::Custom { id, payload }) => {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send_custom_click(&id, payload);
|
||||
}
|
||||
}
|
||||
Some(Click::RunCommand(command)) => {
|
||||
// In configuration there is nothing to run it (vanilla warns).
|
||||
if self.player.is_some() {
|
||||
let command = command.strip_prefix('/').unwrap_or(&command);
|
||||
self.send_chat(&format!("/{command}"));
|
||||
} else {
|
||||
log::warn!(
|
||||
"Horizon: commands are not supported in configuration, trying to run '{command}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Click::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
|
||||
Some(Click::CopyToClipboard(value)) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
|
||||
None => {}
|
||||
}
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
|
||||
fn show_dialog_ref(&mut self, dialog: &Tag) {
|
||||
match dialog {
|
||||
Tag::String(id) => {
|
||||
let id = id.to_string_lossy();
|
||||
let name = if id.contains(':') {
|
||||
id.clone()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let found = self.remote.as_ref().and_then(|r| {
|
||||
let entries = r.registries.registries.get("minecraft:dialog")?;
|
||||
entries.iter().find(|(n, _)| *n == name)?.1.clone()
|
||||
});
|
||||
match found {
|
||||
Some(tag) => self.show_dialog(&tag),
|
||||
None => log::warn!("Horizon: unknown dialog {id}"),
|
||||
}
|
||||
}
|
||||
tag => self.show_dialog(tag),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundShowDialogPacket` in play: a registry entry or the dialog.
|
||||
pub(super) fn show_play_dialog(
|
||||
&mut self,
|
||||
dialog: &horizon_protocol::packets::play::DialogHolder,
|
||||
) {
|
||||
use horizon_protocol::packets::play::DialogHolder;
|
||||
match dialog {
|
||||
DialogHolder::Direct(tag) => self.show_dialog(tag),
|
||||
DialogHolder::Registry(id) => {
|
||||
let tag = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.registries.data("minecraft:dialog", *id).cloned());
|
||||
match tag {
|
||||
Some(tag) => self.show_dialog(&tag),
|
||||
None => log::warn!("Horizon: unknown dialog {id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dialog_layout(&self) -> Option<Layout> {
|
||||
let open = self.dialogs.open.as_ref()?;
|
||||
Some(Layout::new(
|
||||
&open.dialog,
|
||||
&self.gui.atlas.font,
|
||||
self.gui_size(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn draw_dialog(&mut self, batch: &mut HudBatch) {
|
||||
if !self.dialog_open() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let cursor = self.cursor;
|
||||
let atlas = &self.gui.atlas;
|
||||
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());
|
||||
if self.dialogs.warning {
|
||||
let (title, message, [disconnect, back]) = warning_texts();
|
||||
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
|
||||
batch,
|
||||
atlas,
|
||||
cursor,
|
||||
[&disconnect, &back],
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
// HeaderAndFooterLayout(33, 0) centred: the title, the button 33 below.
|
||||
let ticks = (since.elapsed().as_millis() / 50) as u32;
|
||||
let (label, visible, active) = waiting_button(ticks);
|
||||
let title = Text::translate("gui.waitingForResponse.title", &[]);
|
||||
let top = ((h - 53.0) / 2.0).trunc();
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
((w - tw) / 2.0).trunc(),
|
||||
top + 12.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
if visible {
|
||||
let (bx, by) = (((w - 200.0) / 2.0).trunc(), top + 33.0);
|
||||
let hover = ui::hovered(cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 200.0, 20.0, &label, active, hover);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let [ax, ay, aw, ah] = layout.area;
|
||||
open.scroll.clamp(ay, ay + ah, layout.content);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&open.dialog.title,
|
||||
layout.title.0,
|
||||
layout.title.1,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
let warn_hover = ui::hovered(cursor, wx, wy, ww, wh);
|
||||
horizon_client::widgets::record("warning", layout.warning, true);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
WARNING_SPRITES[if warn_hover { 2 } else { 0 }],
|
||||
wx,
|
||||
wy,
|
||||
ww,
|
||||
wh,
|
||||
u32::MAX,
|
||||
);
|
||||
let buttons = open.dialog.buttons();
|
||||
let in_area = |y: f32| y >= ay && y < ay + ah;
|
||||
let mut tooltip = None;
|
||||
let mut start = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && start.is_none() {
|
||||
start = Some(batch.vertices.len());
|
||||
} else if !clipped && let Some(s) = start.take() {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
match part {
|
||||
Part::Line(text, x, y) => {
|
||||
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
|
||||
}
|
||||
Part::Field { input, rect } => {
|
||||
if let Some(Some(edit)) = open.fields.get_mut(input) {
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.focused = open.focus == Some(input);
|
||||
edit.draw(batch, atlas, None);
|
||||
}
|
||||
}
|
||||
Part::Check { input, rect } => {
|
||||
let [x, y, cw, ch] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
|
||||
let on = open.values.get(input) == Some(&InputValue::Bool(true));
|
||||
let sprite = match (on, 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",
|
||||
};
|
||||
let Control::Boolean { label, .. } = &open.dialog.inputs[input].control else {
|
||||
continue;
|
||||
};
|
||||
horizon_client::widgets::record(&label.plain(), rect, true);
|
||||
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
label,
|
||||
x + BOX + 4.0,
|
||||
y + 4.0,
|
||||
rgba(224, 224, 224, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Button { index, rect } => {
|
||||
let [x, y, bw, bh] = rect;
|
||||
let hover =
|
||||
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
|
||||
let b = buttons[index];
|
||||
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
|
||||
if hover && let Some(t) = &b.tooltip {
|
||||
tooltip = Some(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
if layout.content > ah {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
ax + aw - 6.0,
|
||||
ay,
|
||||
ay + ah,
|
||||
layout.content,
|
||||
open.scroll.amount,
|
||||
);
|
||||
}
|
||||
if warn_hover {
|
||||
tooltip = Some(Text::translate("menu.custom_screen_info.tooltip", &[]));
|
||||
}
|
||||
if let Some(t) = tooltip {
|
||||
let lines: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&t, 170.0)
|
||||
.into_iter()
|
||||
.map(|l| l.0)
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click on a dialog screen.
|
||||
pub(super) fn dialog_click(&mut self) -> Result<()> {
|
||||
let cursor = self.cursor;
|
||||
if self.dialogs.warning {
|
||||
let (title, message, _) = warning_texts();
|
||||
let answer = Confirm::new(&self.gui.atlas.font, self.gui_size(), &title, &message)
|
||||
.answer_at(cursor);
|
||||
match answer {
|
||||
Some(true) => {
|
||||
self.audio.play_button_click();
|
||||
let reason = Text::translate("menu.custom_screen_info.disconnect", &[]);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
}
|
||||
Some(false) => {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.warning = false;
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
let (w, h) = self.gui_size();
|
||||
let active = waiting_button((since.elapsed().as_millis() / 50) as u32).2;
|
||||
let (bx, by) = (
|
||||
((w - 200.0) / 2.0).trunc(),
|
||||
((h - 53.0) / 2.0).trunc() + 33.0,
|
||||
);
|
||||
if active && ui::hovered(cursor, bx, by, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.waiting = None;
|
||||
self.dialog_closed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return Ok(());
|
||||
};
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
if ui::hovered(cursor, wx, wy, ww, wh) {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.warning = true;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
let mut action = None;
|
||||
open.focus = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
|
||||
continue;
|
||||
}
|
||||
match part {
|
||||
Part::Field {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
open.focus = Some(input);
|
||||
if let Some(Some(edit)) = open.fields.get_mut(input) {
|
||||
edit.focused = true;
|
||||
edit.click(&self.gui.atlas, cursor.0, false, false);
|
||||
}
|
||||
}
|
||||
Part::Check {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
if let Some(InputValue::Bool(b)) = open.values.get_mut(input) {
|
||||
*b = !*b;
|
||||
}
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Button {
|
||||
index,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
action = Some(Some(index));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(action) = action else {
|
||||
return Ok(());
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
let Some(index) = action else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
Self::dialog_values(open);
|
||||
let d = &open.dialog;
|
||||
let click = d.buttons()[index]
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|a| a.click(&d.inputs, &open.values));
|
||||
let after = d.after_action;
|
||||
self.run_dialog_action(click, after)
|
||||
}
|
||||
|
||||
/// The mouse wheel over a dialog: its body scrolls 10 a step.
|
||||
pub(super) fn dialog_scroll(&mut self, delta: f32) {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
}
|
||||
|
||||
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
|
||||
/// moves between the text inputs, the rest goes to the focused one
|
||||
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
|
||||
pub(super) fn dialog_key(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
shortcut: Option<EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let escape = key == KeyCode::Escape;
|
||||
if self.dialogs.warning {
|
||||
if escape {
|
||||
self.dialogs.warning = false;
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
// shouldCloseOnEsc: once Back works.
|
||||
if escape && waiting_button((since.elapsed().as_millis() / 50) as u32).2 {
|
||||
self.dialogs.waiting = None;
|
||||
self.dialog_closed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let ctrl = self.modifiers.control_key() || self.modifiers.super_key();
|
||||
let shift = self.modifiers.shift_key();
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
if escape {
|
||||
if !open.dialog.can_close_with_escape {
|
||||
return Ok(());
|
||||
}
|
||||
// onClose: the cancel action, and the screen closes.
|
||||
Self::dialog_values(open);
|
||||
let d = &open.dialog;
|
||||
let click = d.on_cancel().and_then(|a| a.click(&d.inputs, &open.values));
|
||||
return self.run_dialog_action(click, AfterAction::Close);
|
||||
}
|
||||
if key == KeyCode::Tab {
|
||||
let texts: Vec<usize> = (0..open.fields.len())
|
||||
.filter(|&i| open.fields[i].is_some())
|
||||
.collect();
|
||||
if !texts.is_empty() {
|
||||
let at = open.focus.and_then(|f| texts.iter().position(|&i| i == f));
|
||||
let next = match at {
|
||||
None if shift => texts.len() - 1,
|
||||
None => 0,
|
||||
Some(p) if shift => (p + texts.len() - 1) % texts.len(),
|
||||
Some(p) => (p + 1) % texts.len(),
|
||||
};
|
||||
open.focus = Some(texts[next]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
edit.shortcut(shortcut, &mut self.clipboard);
|
||||
return Ok(());
|
||||
}
|
||||
if edit.key(key, ctrl, shift) {
|
||||
return Ok(());
|
||||
}
|
||||
let typed: String = text
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect();
|
||||
if !typed.is_empty() {
|
||||
edit.insert_text(&typed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `type TEXT` with a dialog open: into the focused text input, or the
|
||||
/// first one.
|
||||
pub(super) fn dialog_type(&mut self, text: &str) -> bool {
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let focus = open
|
||||
.focus
|
||||
.or_else(|| open.fields.iter().position(Option::is_some));
|
||||
let Some(Some(edit)) = focus.and_then(|f| open.fields.get_mut(f)) else {
|
||||
return false;
|
||||
};
|
||||
open.focus = focus;
|
||||
edit.insert_text(text);
|
||||
true
|
||||
}
|
||||
|
||||
/// What `state` reports: the open dialog and its values, or the waiting
|
||||
/// and warning screens.
|
||||
pub(super) fn dialog_status(&self) -> serde_json::Value {
|
||||
let d = &self.dialogs;
|
||||
let screen = if d.warning {
|
||||
Some("warning")
|
||||
} else if d.waiting.is_some() {
|
||||
Some("waiting_for_response")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let open = d.open.as_ref().map(|open| {
|
||||
let mut values = open.values.clone();
|
||||
for (value, field) in values.iter_mut().zip(&open.fields) {
|
||||
if let Some(f) = field {
|
||||
*value = InputValue::Text(f.value.clone());
|
||||
}
|
||||
}
|
||||
open.dialog.json(&values)
|
||||
});
|
||||
serde_json::json!({ "open": open, "screen": screen })
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,10 @@ enum Target {
|
||||
impl Game<'_> {
|
||||
/// Run every case of `spec` ("CASES,OUTPUT") and write the results.
|
||||
pub(super) fn dispense_fixture(&mut self, spec: &str) -> Result<()> {
|
||||
assert!(
|
||||
!self.options.execution_role.is_client(),
|
||||
"fixture requires a server Game"
|
||||
);
|
||||
let (input, output) = spec
|
||||
.split_once(',')
|
||||
.ok_or("HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT")?;
|
||||
|
||||
@@ -336,6 +336,12 @@ enum StrafeRequest {
|
||||
/// Anything else: the player nearest the crystal.
|
||||
Nearest(Vec3),
|
||||
}
|
||||
/// Whether `EndCrystal.hurtServer` creates a blast before notifying the fight.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum CrystalDamage {
|
||||
Direct,
|
||||
Explosion,
|
||||
}
|
||||
/// What `EnderDragon.hurt` made of a hit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Hurt {
|
||||
@@ -867,27 +873,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A crystal took damage: `EndCrystal.hurtServer` blows it up (power 6).
|
||||
fn destroy_crystal(&mut self, index: usize, by: Option<FightWho>) {
|
||||
/// `EndCrystal.hurtServer`: remove it, blast only for non-explosion damage,
|
||||
/// then notify the dragon fight even when no blast was created.
|
||||
fn destroy_crystal(&mut self, index: usize, by: Option<FightWho>, damage: CrystalDamage) {
|
||||
if index >= self.crystals.len() || self.crystals[index].invulnerable {
|
||||
return;
|
||||
}
|
||||
let c = self.crystals.remove(index);
|
||||
if self.fight.respawn.is_some()
|
||||
// Read before the blast, which can take other respawn crystals.
|
||||
let respawn_crystal = self.fight.respawn.is_some()
|
||||
&& self
|
||||
.fight
|
||||
.respawn_crystals
|
||||
.iter()
|
||||
.any(|p| p.distance_squared(c.position) < 0.01)
|
||||
{
|
||||
.any(|p| p.distance_squared(c.position) < 0.01);
|
||||
if damage == CrystalDamage::Direct {
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let _ =
|
||||
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
|
||||
}
|
||||
if respawn_crystal {
|
||||
self.abort_respawn();
|
||||
return;
|
||||
}
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let _ = self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
|
||||
// EnderDragonFight.onCrystalDestroyed.
|
||||
self.count_crystals();
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
@@ -962,7 +970,7 @@ impl Game<'_> {
|
||||
return Hurt::Refused;
|
||||
}
|
||||
let by = by_player.then(|| self.acting_player());
|
||||
self.destroy_crystal(index, by);
|
||||
self.destroy_crystal(index, by, CrystalDamage::Direct);
|
||||
Hurt::Landed { full: true }
|
||||
} else if id >= SPECIAL {
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
|
||||
@@ -1963,9 +1971,10 @@ impl Game<'_> {
|
||||
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.08) as u32);
|
||||
}
|
||||
if t == 1 {
|
||||
// Level event 1028: the death roar, heard everywhere.
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.death", None, 5.0, 1.0);
|
||||
// EnderDragon.tickDeath: global level event 1028, the death
|
||||
// roar, heard everywhere.
|
||||
let at = [p.x, p.y, p.z].map(|v| v.floor() as i32);
|
||||
self.global_level_event(1028, at, 0);
|
||||
}
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
d.position.y += 0.1;
|
||||
@@ -2634,8 +2643,8 @@ impl Crystal {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ServerExplosion.hurtEntities` on the end crystals (each in reach
|
||||
/// blows up in turn) and the dragon (explosions always hurt dragons).
|
||||
/// `ServerExplosion.hurtEntities`: crystals in reach are removed without
|
||||
/// another blast; explosions always hurt dragons.
|
||||
pub(super) fn explosion_hits_special(&mut self, center: [f64; 3], radius: f32) {
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
let in_reach = |p: Vec3| {
|
||||
@@ -2651,9 +2660,9 @@ impl Game<'_> {
|
||||
.map(|c| c.position)
|
||||
.collect();
|
||||
for at in crystals {
|
||||
// One blowing up may already have taken the next.
|
||||
// Look up the current index after each removal.
|
||||
if let Some(i) = self.crystals.iter().position(|c| c.position == at) {
|
||||
self.destroy_crystal(i, None);
|
||||
self.destroy_crystal(i, None, CrystalDamage::Explosion);
|
||||
}
|
||||
}
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
//! inactive until the dragon dies.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
Result, environment::Environment, items::ItemStack, mesher::Vertex, motion::PlayerMotion,
|
||||
Result,
|
||||
environment::Environment,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
vanillagen::VanillaTerrain,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -311,9 +316,10 @@ impl Game<'_> {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Global LevelEvent 1038: END_PORTAL_SPAWN.
|
||||
self.audio
|
||||
.play("minecraft:block.end_portal.spawn", None, 1.0, 1.0);
|
||||
// EnderEyeItem.useOn: global level event 1038 (END_PORTAL_SPAWN),
|
||||
// heard by every player (vanilla places it at the frame's corner
|
||||
// + (1, 0, 1); only its direction matters).
|
||||
self.global_level_event(1038, c, 0);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1122,6 +1128,35 @@ impl Game<'_> {
|
||||
self.end.eyes = eyes;
|
||||
}
|
||||
|
||||
/// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last
|
||||
/// move back), four bubbles in water, else a portal particle.
|
||||
pub(super) fn eye_particles(&mut self) {
|
||||
for i in 0..self.end.eyes.len() {
|
||||
let e = &self.end.eyes[i];
|
||||
let movement = (e.position - e.previous).to_array();
|
||||
let origin = (e.position - (e.position - e.previous) * 0.25).to_array();
|
||||
let block = origin.map(|v| v.floor() as i32);
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(block[0], block[1], block[2]))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
if water {
|
||||
let bubble = ParticleOptions::simple(ParticleType::Bubble);
|
||||
for _ in 0..4 {
|
||||
self.particles.add(&bubble, origin, movement);
|
||||
}
|
||||
} else {
|
||||
let r = self.particles.random();
|
||||
let x = origin[0] + r.next_double() * 0.6 - 0.3;
|
||||
let z = origin[2] + r.next_double() * 0.6 - 0.3;
|
||||
let portal = ParticleOptions::simple(ParticleType::Portal);
|
||||
self.particles
|
||||
.add(&portal, [x, origin[1] - 0.5, z], movement);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// ThrownItemRenderer: the ender eye item facing the camera.
|
||||
fn eye_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
let Some(r) = self.gui.atlas.sprites.get("item/ender_eye").copied() else {
|
||||
|
||||
@@ -15,6 +15,11 @@ fn opaque(facts: &StateFacts) -> bool {
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn prepare_entity_culling(&mut self, camera: glam::DVec3) {
|
||||
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
|
||||
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
|
||||
(Some(r), Some(_)) => r.pack_shadow_casters(),
|
||||
_ => (false, false),
|
||||
};
|
||||
let at = camera.to_array().map(|v| v.floor() as i32);
|
||||
self.entity_culler = (self.settings.horizon.entity_culling
|
||||
// Packs can replace an opaque block's model or texture.
|
||||
@@ -28,24 +33,29 @@ impl Game<'_> {
|
||||
/// Use complete generated geometry so equipment, animation and custom
|
||||
/// models cannot stick out of an assumed collision-sized culling box.
|
||||
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) {
|
||||
if let Some(culler) = &self.entity_culler {
|
||||
culler.cull_vertices(vertices, start, |pos| {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
// Newly edited or not-yet-uploaded terrain is not an occluder.
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
});
|
||||
let Some(culler) = &self.entity_culler else {
|
||||
return;
|
||||
};
|
||||
let hidden = culler.hidden(vertices, start, |pos| {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
// Newly edited or not-yet-uploaded terrain is not an occluder.
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
});
|
||||
// A shader pack's shadow pass still draws what the camera cannot see.
|
||||
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
//! Horizon's extras in the game (`docs/extras.md`): the zoom key and wheel,
|
||||
//! clicks for the keystroke overlay, frame times for the FPS counter, the
|
||||
//! minimap's tiles and image, and their HUD. Each hook does nothing while its
|
||||
//! extra is off.
|
||||
//! Horizon's extras in the game (`docs/extras.md`): the minimap's tiles and
|
||||
//! image, and its HUD; a mod's zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
|
||||
//! Each hook does nothing while its extra is off and no mod runs.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::{
|
||||
extras::{Corner, Cps, FpsCounter, Zoom, fov_target, turn_scale},
|
||||
extras_hud::{self, Corners, Keystrokes},
|
||||
extras::{Corner, Cps, FpsCounter, fov_target},
|
||||
extras_hud::{self, Corners},
|
||||
hud::{HudBatch, MINIMAP_SPRITE},
|
||||
keybinds,
|
||||
minimap::{self, Layer, Minimap, View},
|
||||
multiplayer,
|
||||
reconnect::{Retry, Tracker},
|
||||
title::Mode,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
use winit::event::MouseButton;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ExtrasState {
|
||||
zoom: Zoom,
|
||||
fps: FpsCounter,
|
||||
/// Left and right mouse buttons: held, and their clicks.
|
||||
mouse: [bool; 2],
|
||||
@@ -36,41 +34,16 @@ impl Game<'_> {
|
||||
fn extras_now(&self) -> f64 {
|
||||
self.started.elapsed().as_secs_f64()
|
||||
}
|
||||
fn zoom_key(&self) -> Option<KeyCode> {
|
||||
let e = &self.settings.extras;
|
||||
e.zoom.then(|| keybinds::key_code(&e.zoom_key)).flatten()
|
||||
}
|
||||
/// A key press or release. The zoom key zooms only while playing with
|
||||
/// no text open; the key itself always goes on to the screen and the key
|
||||
/// binds, as a vanilla key mapping's would.
|
||||
pub(super) fn extras_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if self.zoom_key() != Some(key) {
|
||||
return;
|
||||
}
|
||||
let playing = self.screen == Screen::Playing
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none();
|
||||
let level = self.settings.extras.zoom_level;
|
||||
self.extras.zoom.key(pressed, repeat, playing, level);
|
||||
}
|
||||
/// Wheel steps while playing; true when zooming took them from the hotbar.
|
||||
pub(super) fn extras_scroll(&mut self, steps: f32) -> bool {
|
||||
self.settings.extras.zoom && self.extras.zoom.scroll(steps)
|
||||
}
|
||||
/// Mouse buttons while playing, for zoom and the keystroke overlay.
|
||||
/// Mouse buttons while playing, for mods' clicks.
|
||||
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
let e = &self.settings.extras;
|
||||
if e.zoom && e.zoom_key == keybinds::mouse_name(button) {
|
||||
self.extras.zoom.key(pressed, false, true, e.zoom_level);
|
||||
}
|
||||
let i = match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
_ => return,
|
||||
};
|
||||
self.extras.mouse[i] = pressed;
|
||||
if pressed && self.settings.extras.keystrokes {
|
||||
// For mods (`input.clicks-per-second`).
|
||||
if pressed && self.mods.running() {
|
||||
let now = self.extras_now();
|
||||
self.extras.cps[i].click(now);
|
||||
}
|
||||
@@ -80,20 +53,32 @@ impl Game<'_> {
|
||||
// Under agent control the window may lack focus without pausing.
|
||||
let focused = self.focused || self.control.is_some();
|
||||
let playing = self.screen == Screen::Playing && self.title.is_none() && focused;
|
||||
if !playing || !self.settings.extras.zoom {
|
||||
self.extras.zoom.release();
|
||||
}
|
||||
if !playing {
|
||||
self.extras.mouse = [false; 2];
|
||||
}
|
||||
self.update_minimap();
|
||||
if self.settings.extras.fps {
|
||||
// For mods (`render.frame-stats`).
|
||||
if self.mods.running() {
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
} else {
|
||||
self.extras.fps.clear();
|
||||
}
|
||||
}
|
||||
/// The frame counter for mods: fps, min, 1% low.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
|
||||
let r = self.extras.fps.reading();
|
||||
(r.fps, r.min, r.low)
|
||||
}
|
||||
/// Attack and use clicks in the last second, and whether each is held.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn mouse_reading(&mut self) -> ((u32, u32), [bool; 2]) {
|
||||
let now = self.extras_now();
|
||||
let [l, r] = &mut self.extras.cps;
|
||||
let cps = (l.per_second(now) as u32, r.per_second(now) as u32);
|
||||
(cps, self.extras.mouse)
|
||||
}
|
||||
/// Auto-reconnect: a connection to `address` as `name` starts.
|
||||
pub(super) fn extras_connecting(&mut self, address: &str, name: &str) {
|
||||
self.extras.reconnect.connecting(address, name);
|
||||
@@ -143,25 +128,44 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The minimap's view of the player, if it shows.
|
||||
fn minimap_view(&self) -> Option<View> {
|
||||
let e = &self.settings.extras;
|
||||
/// The minimap that shows, the extra's or else a mod's
|
||||
/// (`render.set-minimap`): its corner, its side in GUI pixels and its
|
||||
/// view of the player.
|
||||
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
|
||||
let p = self.player.as_ref()?;
|
||||
let shown = e.minimap && self.title.is_none() && !self.ui.hide_gui;
|
||||
shown.then_some(View {
|
||||
if self.title.is_some() || self.ui.hide_gui {
|
||||
return None;
|
||||
}
|
||||
let e = &self.settings.extras;
|
||||
let (corner, size, rotate, blocks_per_pixel, round) = if e.minimap {
|
||||
let size = f32::from(e.minimap_size);
|
||||
(
|
||||
e.minimap_corner,
|
||||
size,
|
||||
e.minimap_rotate,
|
||||
e.minimap_zoom,
|
||||
e.minimap_round,
|
||||
)
|
||||
} else {
|
||||
let m = self.mod_minimap()?;
|
||||
(m.corner, m.size, m.rotate, m.blocks_per_pixel, m.round)
|
||||
};
|
||||
let view = View {
|
||||
x: p.position.x,
|
||||
z: p.position.z,
|
||||
yaw: self.yaw,
|
||||
rotate: e.minimap_rotate,
|
||||
blocks_per_pixel: e.minimap_zoom,
|
||||
round: e.minimap_round,
|
||||
})
|
||||
rotate,
|
||||
blocks_per_pixel,
|
||||
round,
|
||||
};
|
||||
Some((corner, size, view))
|
||||
}
|
||||
/// Refresh the minimap's tiles within the frame budget, recompose its
|
||||
/// image when the view or a tile changed, and queue the upload.
|
||||
fn update_minimap(&mut self) {
|
||||
let Some(view) = self.minimap_view() else {
|
||||
if !self.settings.extras.minimap || self.player.is_none() {
|
||||
let Some((_, _, view)) = self.minimap_shown() else {
|
||||
let wanted = self.settings.extras.minimap || self.mod_minimap().is_some();
|
||||
if !wanted || self.player.is_none() {
|
||||
self.extras.minimap.clear();
|
||||
self.extras.minimap_dim = None;
|
||||
}
|
||||
@@ -209,45 +213,43 @@ impl Game<'_> {
|
||||
rgba: self.extras.minimap.image.clone(),
|
||||
});
|
||||
}
|
||||
/// The zoom in effect (1 unless the key is held).
|
||||
fn zoom_factor(&self) -> f32 {
|
||||
self.extras.zoom.factor(self.scoping())
|
||||
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
|
||||
/// scale); none is 1.
|
||||
fn zoom_now(&self) -> (f32, bool, f64) {
|
||||
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
|
||||
(z.factor, z.smooth, f64::from(z.turn))
|
||||
})
|
||||
}
|
||||
/// The field-of-view modifier target, divided by a smooth zoom.
|
||||
pub(super) fn zoomed_fov_target(&self, vanilla: f32) -> f32 {
|
||||
if self.settings.extras.zoom_smooth {
|
||||
fov_target(vanilla, self.zoom_factor())
|
||||
} else {
|
||||
vanilla
|
||||
match self.zoom_now() {
|
||||
(factor, true, _) => fov_target(vanilla, factor),
|
||||
_ => vanilla,
|
||||
}
|
||||
}
|
||||
/// The field of view in degrees, divided by an instant zoom.
|
||||
pub(super) fn zoomed_fov_degrees(&self, fov: f32) -> f32 {
|
||||
if self.settings.extras.zoom_smooth {
|
||||
fov
|
||||
} else {
|
||||
fov / self.zoom_factor()
|
||||
match self.zoom_now() {
|
||||
(factor, false, _) => fov / factor,
|
||||
_ => fov,
|
||||
}
|
||||
}
|
||||
/// Mouse turning while zoomed.
|
||||
pub(super) fn zoom_turn_scale(&self) -> f64 {
|
||||
turn_scale(self.zoom_factor(), self.settings.extras.zoom_sensitivity)
|
||||
self.zoom_now().2
|
||||
}
|
||||
/// The FPS counter and keystrokes, in the HUD layer (hidden with F1 by
|
||||
/// the caller). The top left corner gives way to the debug screen.
|
||||
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
|
||||
/// by the caller). The top left corner gives way to the debug screen.
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let e = &self.settings.extras;
|
||||
let mod_panels = self.mods.has_panels();
|
||||
if !e.fps && !e.keystrokes && !e.minimap && !mod_panels {
|
||||
let map = self.minimap_shown();
|
||||
if map.is_none() && !mod_panels {
|
||||
return;
|
||||
}
|
||||
let (fps_on, fps_corner, lows) = (e.fps, e.fps_corner, e.fps_lows);
|
||||
let (keys_on, keys_corner, cps_on) = (e.keystrokes, e.keystrokes_corner, e.keystrokes_cps);
|
||||
let (map_corner, map_size) = (e.minimap_corner, f32::from(e.minimap_size));
|
||||
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
|
||||
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
|
||||
let atlas = &self.gui.atlas;
|
||||
if let Some(view) = self.minimap_view()
|
||||
if let Some((map_corner, map_size, view)) = map
|
||||
&& visible(map_corner)
|
||||
&& !self.extras.minimap.image.is_empty()
|
||||
&& let Some(image) = atlas.sprites.get(MINIMAP_SPRITE).copied()
|
||||
@@ -266,32 +268,6 @@ impl Game<'_> {
|
||||
};
|
||||
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
|
||||
}
|
||||
if fps_on && visible(fps_corner) {
|
||||
let reading = self.extras.fps.reading();
|
||||
extras_hud::draw_fps(batch, atlas, &mut corners, fps_corner, reading, lows);
|
||||
}
|
||||
if keys_on && visible(keys_corner) {
|
||||
let now = self.extras_now();
|
||||
let cps = cps_on.then(|| {
|
||||
let [l, r] = &mut self.extras.cps;
|
||||
(l.per_second(now), r.per_second(now))
|
||||
});
|
||||
let held = |k| self.keys.contains(&k);
|
||||
let label = |name| keybinds::display_name(self.settings.keys.get(name)).plain();
|
||||
let k = Keystrokes {
|
||||
moves: [
|
||||
(held(KeyCode::KeyW), label("key.forward")),
|
||||
(held(KeyCode::KeyA), label("key.left")),
|
||||
(held(KeyCode::KeyS), label("key.back")),
|
||||
(held(KeyCode::KeyD), label("key.right")),
|
||||
],
|
||||
jump: held(KeyCode::Space),
|
||||
attack: self.extras.mouse[0],
|
||||
use_: self.extras.mouse[1],
|
||||
cps,
|
||||
};
|
||||
extras_hud::draw_keystrokes(batch, &self.gui.atlas, &mut corners, keys_corner, &k);
|
||||
}
|
||||
if mod_panels {
|
||||
self.draw_mod_panels(batch, &mut corners);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,15 @@ pub(super) struct Rocket {
|
||||
pub(super) velocity: Vec3,
|
||||
life: i32,
|
||||
lifetime: i32,
|
||||
explosions: Vec<FireworkExplosion>,
|
||||
pub(super) explosions: Vec<FireworkExplosion>,
|
||||
/// Boosting the player (attachedToEntity).
|
||||
pub(super) attached: bool,
|
||||
/// On a server, the player it boosts when not this game's own: a guest's
|
||||
/// connection.
|
||||
pub(super) guest: Option<u32>,
|
||||
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
|
||||
/// boosting happens when it is the local player.
|
||||
pub(super) attached_to: Option<u32>,
|
||||
/// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on
|
||||
/// the first block it hits.
|
||||
pub(super) angled: bool,
|
||||
@@ -48,6 +54,8 @@ impl Rocket {
|
||||
lifetime: i32::MAX,
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item,
|
||||
}
|
||||
@@ -63,7 +71,7 @@ impl Rocket {
|
||||
(self.position, self.velocity)
|
||||
}
|
||||
}
|
||||
fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
// The item's default component: flight 1, no explosions.
|
||||
stack
|
||||
.data()
|
||||
@@ -89,6 +97,8 @@ impl Game<'_> {
|
||||
lifetime,
|
||||
explosions,
|
||||
attached,
|
||||
guest: None,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item: stack.clone(),
|
||||
}
|
||||
@@ -127,7 +137,8 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let rocket = self.new_rocket(at, stack, true);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
@@ -140,7 +151,18 @@ impl Game<'_> {
|
||||
while i < self.rockets.len() {
|
||||
let mut r = self.rockets.remove(i);
|
||||
r.previous = r.position;
|
||||
if r.attached {
|
||||
if r.attached
|
||||
&& let Some(conn) = r.guest
|
||||
{
|
||||
// A guest's rocket follows them; their own client boosts them.
|
||||
let guest = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|host| host.hub.guests.iter().find(|g| g.conn == conn));
|
||||
if let Some(g) = guest {
|
||||
r.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
}
|
||||
} else if r.attached {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.fall_flying {
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
@@ -229,8 +251,19 @@ impl Game<'_> {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
// The player it boosts takes the full damage, a guest as well.
|
||||
if r.attached {
|
||||
self.hurt_player_by("minecraft:fireworks", damage);
|
||||
match r.guest {
|
||||
None => self.hurt_player_by("minecraft:fireworks", damage),
|
||||
Some(conn) => {
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.as_guest(&mut host, conn, |g| {
|
||||
g.hurt_player_by("minecraft:fireworks", damage);
|
||||
});
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = r.position;
|
||||
let hits: Vec<(u32, f32)> = {
|
||||
|
||||
@@ -45,7 +45,8 @@ pub(super) struct RulesScreen {
|
||||
initial: GameRules,
|
||||
/// IntegerRuleEntry's boxes, by rule.
|
||||
numbers: Vec<(&'static str, EditBox)>,
|
||||
scroll: f32,
|
||||
/// RuleList's scrolling: the wheel and the held scrollbar.
|
||||
scroll: ui::Scroll,
|
||||
/// ConfirmScreen "Game Rule Changes" in its place.
|
||||
confirm: bool,
|
||||
}
|
||||
@@ -120,7 +121,7 @@ impl RulesScreen {
|
||||
values: rules.clone(),
|
||||
initial: rules.clone(),
|
||||
numbers,
|
||||
scroll: 0.0,
|
||||
scroll: ui::Scroll::default(),
|
||||
confirm: false,
|
||||
}
|
||||
}
|
||||
@@ -156,12 +157,17 @@ impl RulesScreen {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn max_scroll(rows: usize, h: f32) -> f32 {
|
||||
(ROW * rows as f32 + 4.0 - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
/// Entry `i`'s top: 2 under the list's top, less the scroll.
|
||||
fn row_top(&self, i: usize) -> f32 {
|
||||
HEADER + 2.0 - self.scroll.trunc() + ROW * i as f32
|
||||
HEADER + 2.0 - self.scroll.amount.trunc() + ROW * i as f32
|
||||
}
|
||||
/// The list's scrollbar column (AbstractSelectionList: 6 pixels right of
|
||||
/// the entries) and the pixels it scrolls.
|
||||
fn bar(w: f32) -> f32 {
|
||||
row_left(w) + ROW_WIDTH + 6.0 + 2.0
|
||||
}
|
||||
fn content(&self) -> f32 {
|
||||
ROW * self.rows().len() as f32 + 4.0
|
||||
}
|
||||
/// getEntryAtPosition: the row under the pointer, inside the list.
|
||||
fn hovered(&self, rows: usize, (x, y): (f32, f32), (w, h): (f32, f32)) -> Option<usize> {
|
||||
@@ -212,10 +218,28 @@ impl RulesScreen {
|
||||
/// The mouse wheel (AbstractSelectionList: half an entry a step).
|
||||
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
|
||||
if !self.confirm {
|
||||
let max = Self::max_scroll(self.rows().len(), h);
|
||||
self.scroll = (self.scroll - delta * ROW / 2.0).clamp(0.0, max);
|
||||
let content = self.content();
|
||||
self.scroll
|
||||
.wheel(delta, ROW / 2.0, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
|
||||
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
|
||||
let content = self.content();
|
||||
self.scroll
|
||||
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
|
||||
}
|
||||
/// The pointer moved while the scrollbar is held.
|
||||
pub(super) fn drag(&mut self, y: f32, h: f32) {
|
||||
if self.scroll.held() {
|
||||
let content = self.content();
|
||||
self.scroll.drag(y, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// The button came up: the scrollbar lets go.
|
||||
pub(super) fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
}
|
||||
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
|
||||
if self.search.focused {
|
||||
return Some((None, &mut self.search));
|
||||
@@ -229,7 +253,7 @@ impl RulesScreen {
|
||||
/// number sets its rule when valid (IntegerRuleEntry's responder).
|
||||
fn edited(&mut self, rule: Option<&'static str>) {
|
||||
let Some(name) = rule else {
|
||||
self.scroll = 0.0;
|
||||
self.scroll.amount = 0.0;
|
||||
return;
|
||||
};
|
||||
let Some(r) = gamerules::rule(name) else {
|
||||
@@ -286,6 +310,11 @@ impl RulesScreen {
|
||||
if on(cx, cy, 150.0, 20.0) {
|
||||
return self.close();
|
||||
}
|
||||
// A press on the scrollbar holds it and clicks nothing else.
|
||||
if self.press_scrollbar(cursor, size) {
|
||||
self.unfocus();
|
||||
return None;
|
||||
}
|
||||
// A click moves the focus to the widget under it.
|
||||
let (sx, sy) = search_at(w);
|
||||
if on(sx, sy, SEARCH_WIDTH, 15.0) {
|
||||
@@ -360,7 +389,8 @@ impl RulesScreen {
|
||||
return;
|
||||
}
|
||||
let rows = self.rows();
|
||||
self.scroll = self.scroll.clamp(0.0, Self::max_scroll(rows.len(), h));
|
||||
let content = ROW * rows.len() as f32 + 4.0;
|
||||
self.scroll.clamp(HEADER, h - FOOTER, content);
|
||||
// RuleList: the in-world list background, its entries clipped to it,
|
||||
// then the separators and scrollbar.
|
||||
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
|
||||
@@ -421,16 +451,14 @@ impl RulesScreen {
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
|
||||
let content = ROW * rows.len() as f32 + 4.0;
|
||||
let bar_x = left + ROW_WIDTH + 6.0 + 2.0;
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
bar_x,
|
||||
Self::bar(w),
|
||||
HEADER,
|
||||
h - FOOTER,
|
||||
content,
|
||||
self.scroll.trunc(),
|
||||
self.scroll.amount.trunc(),
|
||||
);
|
||||
// The header: the title centred over the search box.
|
||||
let title = Text::translate("editGamerule.title", &[]);
|
||||
@@ -536,6 +564,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// AbstractScrollArea: a press on the bar holds it, a drag moves the
|
||||
/// list (to the ends past them), a release lets go.
|
||||
#[test]
|
||||
fn the_scroll_bar_can_be_held_dragged_and_let_go() {
|
||||
let mut s = RulesScreen::new(&GameRules::default());
|
||||
let (w, h) = (427.0, 240.0);
|
||||
let (top, bottom, content) = (HEADER, h - FOOTER, s.content());
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the rules overflow a 240-high screen"
|
||||
);
|
||||
let bar = RulesScreen::bar(w);
|
||||
assert!(!s.press_scrollbar((bar - 4.0, top + 5.0), (w, h)));
|
||||
assert!(s.press_scrollbar((bar + 3.0, top + 5.0), (w, h)));
|
||||
s.drag(top + 40.0, h);
|
||||
assert!(s.scroll.amount > 0.0);
|
||||
s.drag(bottom + 10.0, h);
|
||||
assert_eq!(s.scroll.amount, content - (bottom - top));
|
||||
s.release();
|
||||
s.drag(top, h);
|
||||
assert_eq!(s.scroll.amount, content - (bottom - top));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_change_rules_and_bad_numbers_hold_done() {
|
||||
let mut s = RulesScreen::new(&GameRules::default());
|
||||
|
||||
@@ -143,7 +143,7 @@ pub(super) struct Serving {
|
||||
flags: Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
/// The window's profile.
|
||||
owner: u128,
|
||||
pub(super) owner: u128,
|
||||
/// Open to LAN or closed from inside a player's command, while the hub
|
||||
/// is out of its place: applied after the tick.
|
||||
pending: Option<Publish>,
|
||||
@@ -357,6 +357,16 @@ impl Game<'_> {
|
||||
});
|
||||
let _ = ready.send(Ok((pipe, self.level.name.clone())));
|
||||
self.settle_spawn()?;
|
||||
if let (Some(script), Some(output)) = (
|
||||
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
|
||||
std::env::var_os("HORIZON_COMMAND_OUTPUT"),
|
||||
) {
|
||||
// The script owns the clock: connection setup and terrain loading
|
||||
// cannot add wall-clock-dependent world ticks to oracle output.
|
||||
let result = self.run_command_harness(script.as_ref(), output.as_ref());
|
||||
let stopped = self.stop_integrated();
|
||||
return result.and(stopped);
|
||||
}
|
||||
let mut next = Instant::now();
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.run_tasks();
|
||||
@@ -692,6 +702,48 @@ fn serve(
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
let mut server = server_game(res, pack, options, settings)?;
|
||||
server.run_integrated(launch, flags, tasks, ready)
|
||||
}
|
||||
|
||||
/// Component oracles supply their own synthetic levels and entity phases.
|
||||
/// They still execute on a server Game, never the client's world.
|
||||
pub(super) enum ComponentHarness {
|
||||
Dispenser(String),
|
||||
Sculk(String),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn run_component_harness(&self, fixture: ComponentHarness) -> Result<()> {
|
||||
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
|
||||
let mut options = self.options.clone();
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
let result = std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(move || {
|
||||
let run = || -> Result<()> {
|
||||
let mut server = server_game(&res, pack, options, settings)?;
|
||||
match fixture {
|
||||
ComponentHarness::Dispenser(spec) => server.dispense_fixture(&spec),
|
||||
ComponentHarness::Sculk(spec) => server.sculk_fixture(&spec),
|
||||
}
|
||||
};
|
||||
run().map_err(|e| e.to_string())
|
||||
})?
|
||||
.join()
|
||||
.map_err(|_| "the component harness server panicked")?;
|
||||
result.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
fn server_game(
|
||||
res: &Arc<Resources>,
|
||||
pack: PackResources,
|
||||
options: Options,
|
||||
settings: Settings,
|
||||
) -> Result<Game<'_>> {
|
||||
// It draws nothing: only the font, which books and chat measure with.
|
||||
let atlas = HudAtlas::fonts(&options.resources)?;
|
||||
let gui = UiResources {
|
||||
@@ -702,8 +754,7 @@ fn serve(
|
||||
}),
|
||||
};
|
||||
let audio = Audio::silent(&options.resources);
|
||||
let mut server = Game::new(res, pack, gui, options, settings, audio, None)?;
|
||||
server.run_integrated(launch, flags, tasks, ready)
|
||||
Game::new(res, pack, gui, options, settings, audio, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -55,8 +55,8 @@ pub(super) struct LanHost {
|
||||
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
/// Hurt and death state of the mobs guests see.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool)>>,
|
||||
/// Hurt time, death and swing time (while swinging) of the mobs guests see.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
pub mob_players: HashMap<u32, u32>,
|
||||
/// An integrated server's owner (game/integrated.rs).
|
||||
@@ -162,17 +162,28 @@ fn mob_meta(
|
||||
meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the off
|
||||
// hand 2 (a skeleton draws its bow, a witch raises her potion).
|
||||
let using = i8::from(m.extra.using_item > 0) | i8::from(m.extra.using_off_hand) << 1;
|
||||
if using != 0 {
|
||||
meta.push((8, serializer::BYTE, Value::Byte(using)));
|
||||
}
|
||||
defaults.push((8, serializer::BYTE, Value::Byte(0)));
|
||||
// LivingEntity.DATA_HEALTH_ID: defined as 1, then set to the max health.
|
||||
let health = m.health.max(0.0);
|
||||
if health != 1.0 {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
|
||||
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
|
||||
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
|
||||
// weapon).
|
||||
let flags =
|
||||
i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1 | i8::from(m.ai.aggressive) << 2;
|
||||
if flags != 0 {
|
||||
meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
defaults.push((15, serializer::BYTE, Value::Byte(0)));
|
||||
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
|
||||
if let Some(index) = baby_index(kind) {
|
||||
if m.is_baby() {
|
||||
@@ -289,6 +300,21 @@ fn mob_meta(
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose and the held
|
||||
// item's loading animation.
|
||||
"minecraft:pillager" => {
|
||||
if e.flag {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
"minecraft:evoker" | "minecraft:illusioner" => {
|
||||
if e.variant2 != 0 {
|
||||
meta.push((17, serializer::BYTE, Value::Byte(e.variant2 as i8)));
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
@@ -979,6 +1005,7 @@ impl Game<'_> {
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
self.guest_combat.remove(&guest.conn);
|
||||
if self.options.dedicated.is_some() && guest.placed {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
@@ -1109,9 +1136,9 @@ impl Game<'_> {
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, nor bone meal's growth (BoneMealItem.useOn's
|
||||
// levelEvent names no player).
|
||||
let except = if matches!(*id, 2015 | 1505) {
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
@@ -1150,6 +1177,9 @@ impl Game<'_> {
|
||||
for (id, event, at) in std::mem::take(&mut self.lan_entity_events) {
|
||||
host.hub.entity_event(id, event, at);
|
||||
}
|
||||
for (kind, pos, data) in std::mem::take(&mut self.lan_global_events) {
|
||||
host.hub.global_level_event(kind, pos, data);
|
||||
}
|
||||
self.lan_broadcast_effects(host);
|
||||
if host.hub.guests.is_empty() {
|
||||
host.mob_state.clear();
|
||||
@@ -1193,7 +1223,17 @@ impl Game<'_> {
|
||||
if dead && !old.is_some_and(|o| o.1) {
|
||||
host.hub.entity_event(m.id as i32, 3, at);
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead));
|
||||
// LivingEntity.swing on a server (a melee hit, a thrown potion):
|
||||
// ClientboundSwingAnimationPacket for the players around. A swing
|
||||
// started since the last flush, or restarted (its time went back).
|
||||
let swing = m.ai.swinging.then_some(m.ai.swing_time);
|
||||
if let Some(time) = swing
|
||||
&& !dead
|
||||
&& old.and_then(|o| o.2).is_none_or(|before| time < before)
|
||||
{
|
||||
host.hub.swing(m.id as i32, 0, at, None);
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ pub(super) struct GuestPlay {
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// `ServerGamePacketListenerImpl.dropSpamThrottler` (`TickThrottler(20, 1480)`): 20 for each
|
||||
/// item a creative player drops from the creative screen, one less each tick.
|
||||
creative_drops: u32,
|
||||
/// The guest's `Player.getCooldowns()` (`Game::cooldowns` for its turn).
|
||||
pub(super) cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
attack_ticks: u32,
|
||||
/// The main hand's item at the guest's last tick (`Game::attack_item`).
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
@@ -84,6 +89,7 @@ pub(super) struct GuestPlay {
|
||||
equipped: Option<Vec<Option<ItemStack>>>,
|
||||
/// The guest's cast fishing hook (`Player.fishing`).
|
||||
pub(super) hook: Option<super::fishing::Hook>,
|
||||
sculk: super::sculk::PlayerEvents,
|
||||
}
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
@@ -117,6 +123,7 @@ pub(super) struct GuestSwap {
|
||||
host_command_block: Option<super::command_blocks::CommandBlockScreen>,
|
||||
host_mount_menu: super::mount_menu::MountMenu,
|
||||
craft_size: usize,
|
||||
craft_table: Option<[i32; 3]>,
|
||||
craft_grid: Vec<Option<ItemStack>>,
|
||||
container: Option<(i32, i32, i32)>,
|
||||
mining: Option<Mining>,
|
||||
@@ -124,6 +131,8 @@ pub(super) struct GuestSwap {
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
|
||||
cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
attack_ticks: u32,
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
@@ -143,6 +152,8 @@ pub(super) struct GuestSwap {
|
||||
adv_loaded: bool,
|
||||
last_inventory: u64,
|
||||
death_cause: Option<String>,
|
||||
/// The guest's own damage record, in place while they act.
|
||||
combat: super::combat::CombatTracker,
|
||||
/// The guest whose turn this one nests in, if any.
|
||||
acting: (Option<u32>, Option<String>, Option<u128>),
|
||||
/// The packets queued for that guest.
|
||||
@@ -249,6 +260,7 @@ impl Game<'_> {
|
||||
if fresh {
|
||||
// A new body (joined, respawned, changed level): nothing on it yet.
|
||||
play.equipped = None;
|
||||
play.sculk = Default::default();
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
// The guest's body.
|
||||
@@ -256,6 +268,15 @@ impl Game<'_> {
|
||||
host.play.insert(conn, play);
|
||||
return None;
|
||||
};
|
||||
self.sculk.swap_player(&mut play.sculk);
|
||||
// The guest's own CombatTracker: damage to them is not the host's.
|
||||
// A new body starts with no record of the last one.
|
||||
let guest_combat = if fresh {
|
||||
Default::default()
|
||||
} else {
|
||||
self.guest_combat.remove(&conn).unwrap_or_default()
|
||||
};
|
||||
let host_combat = std::mem::replace(&mut self.combat, guest_combat);
|
||||
self.potions.swap_player(&mut g.effects, &mut play.potions);
|
||||
motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
motion.ground.on_ground = g.on_ground;
|
||||
@@ -264,6 +285,8 @@ impl Game<'_> {
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
// FireworkRocketItem.use boosts only a gliding player.
|
||||
motion.fall_flying = g.fall_flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
let _ = motion.set_health(g.health, false);
|
||||
motion.health.food.food = g.food;
|
||||
@@ -307,6 +330,7 @@ impl Game<'_> {
|
||||
// The host's crafting grid stays theirs; a crafting table the guest
|
||||
// opens shows as the 3x3 size.
|
||||
let craft_size = std::mem::replace(&mut self.craft_size, 2);
|
||||
let craft_table = self.craft_table.take();
|
||||
let craft_grid = std::mem::replace(&mut self.craft_grid, vec![None; 9]);
|
||||
let container = self.container.take();
|
||||
let mining = std::mem::replace(&mut self.mining, play.mining.take());
|
||||
@@ -314,6 +338,7 @@ impl Game<'_> {
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
|
||||
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
|
||||
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
|
||||
let swing = std::mem::replace(&mut self.swing, play.swing);
|
||||
@@ -390,6 +415,7 @@ impl Game<'_> {
|
||||
host_command_block,
|
||||
host_mount_menu,
|
||||
craft_size,
|
||||
craft_table,
|
||||
craft_grid,
|
||||
container,
|
||||
mining,
|
||||
@@ -397,6 +423,7 @@ impl Game<'_> {
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -418,6 +445,7 @@ impl Game<'_> {
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
combat: host_combat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,6 +473,7 @@ impl Game<'_> {
|
||||
host_command_block,
|
||||
host_mount_menu,
|
||||
craft_size,
|
||||
craft_table,
|
||||
craft_grid,
|
||||
container,
|
||||
mining,
|
||||
@@ -452,6 +481,7 @@ impl Game<'_> {
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -468,12 +498,17 @@ impl Game<'_> {
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
death_cause,
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
} = swap;
|
||||
self.sculk.swap_player(&mut play.sculk);
|
||||
// The guest's damage record back into their slot, the host's in place.
|
||||
let guest_combat = std::mem::replace(&mut self.combat, combat);
|
||||
self.guest_combat.insert(conn, guest_combat);
|
||||
play.modifiers = std::mem::replace(&mut self.applied_modifiers, modifiers);
|
||||
// What the guest shot is theirs (Projectile.setOwner).
|
||||
for p in self.creatures.projectiles.iter_mut().skip(projectiles) {
|
||||
@@ -487,6 +522,16 @@ impl Game<'_> {
|
||||
for packet in std::mem::replace(&mut self.guest_packets, packets) {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
|
||||
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration,
|
||||
}),
|
||||
);
|
||||
}
|
||||
(self.lan_acting, self.lan_acting_name, self.lan_acting_uuid) = acting;
|
||||
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
|
||||
// An ender chest the guest opened: the menu shows their inventory
|
||||
@@ -495,7 +540,10 @@ impl Game<'_> {
|
||||
|
||||
// What the action opened: handed to the guest instead of the host.
|
||||
let opened = self.container.take();
|
||||
let opened_table = std::mem::replace(&mut self.craft_size, craft_size) == 3;
|
||||
let guest_table = std::mem::replace(&mut self.craft_table, craft_table);
|
||||
let opened_table = (std::mem::replace(&mut self.craft_size, craft_size) == 3)
|
||||
.then_some(guest_table)
|
||||
.flatten();
|
||||
self.craft_grid = craft_grid;
|
||||
let opened_station = std::mem::replace(&mut self.station, host_station);
|
||||
let bed = std::mem::replace(&mut self.sleeping, sleeping);
|
||||
@@ -513,6 +561,7 @@ impl Game<'_> {
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
|
||||
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
|
||||
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
@@ -671,8 +720,8 @@ impl Game<'_> {
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
if opened_table {
|
||||
self.lan_open_crafting(host, conn);
|
||||
if let Some(pos) = opened_table {
|
||||
self.lan_open_crafting(host, conn, pos);
|
||||
}
|
||||
if let Some(mob) = guest_mount {
|
||||
self.lan_open_mount(host, conn, mob);
|
||||
@@ -812,6 +861,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.tick`'s `stillValid`: a guest's block container closes
|
||||
/// once the guest is out of reach of its block (a removed block closes it
|
||||
/// in the hub's `sync_containers`).
|
||||
fn lan_containers_tick(&mut self, host: &mut LanHost) {
|
||||
let far: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| {
|
||||
g.menu.as_ref().is_some_and(|m| {
|
||||
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
|
||||
})
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in far {
|
||||
self.guest_container_closed(host, conn);
|
||||
host.hub.close_container(conn);
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -1015,6 +1089,35 @@ impl Game<'_> {
|
||||
}
|
||||
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
|
||||
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
|
||||
// handleSetCreativeModeSlot's drop: a slot below 0 is a stack thrown out of the
|
||||
// creative screen (`handleCreativeModeItemDrop`), dropped from the player's hand.
|
||||
S::SetCreativeModeSlot(p) if p.slot < 0 => {
|
||||
if !host.hub.guests[gi].creative() {
|
||||
return;
|
||||
}
|
||||
let Some(stack) = self.item_from_network(host, &p.item) else {
|
||||
return;
|
||||
};
|
||||
if stack.count
|
||||
> self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.creative_drops >= 1480 {
|
||||
eprintln!(
|
||||
"Player {} was dropping items too fast in creative mode, ignoring.",
|
||||
host.hub.guests[gi].name
|
||||
);
|
||||
return;
|
||||
}
|
||||
play.creative_drops += 20;
|
||||
self.as_guest(host, conn, |g| g.player_drop(stack));
|
||||
}
|
||||
S::SetCreativeModeSlot(p) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !g.creative() || !(0..46).contains(&p.slot) {
|
||||
@@ -1075,7 +1178,20 @@ impl Game<'_> {
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(self.res.items());
|
||||
let slots = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| m.window == p.container)
|
||||
.map(|m| m.slots);
|
||||
let before = slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi]));
|
||||
let drops = host.hub.container_click(conn, &p, &mut self.world, &rules);
|
||||
// A click that changed the container's slots: BlockEntity.setChanged,
|
||||
// so comparators read it again (a double chest's other half follows).
|
||||
if let Some(Slots::Block(key) | Slots::Double(key, _)) = slots
|
||||
&& slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi])) != before
|
||||
{
|
||||
self.container_changed(key);
|
||||
}
|
||||
self.drop_guest_items(host, gi, drops);
|
||||
}
|
||||
S::ContainerClose(p) => {
|
||||
@@ -1216,29 +1332,35 @@ impl Game<'_> {
|
||||
host: &LanHost,
|
||||
stack: &Option<horizon_protocol::item::Stack>,
|
||||
) -> Option<ItemStack> {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
let s = stack.as_ref()?;
|
||||
let name = host
|
||||
.hub
|
||||
.item_name(s.item)
|
||||
.unwrap_or_else(|| "minecraft:air".into());
|
||||
let mut out = ItemStack::new(&name, s.count.max(1) as u32);
|
||||
out.damage = s.damage().max(0) as u32;
|
||||
if let Some(Component::Enchantments(list)) = s.component(component::ENCHANTMENTS) {
|
||||
for (id, level) in list {
|
||||
if let Some(e) = host.hub.enchantment_name(*id) {
|
||||
out.enchant(&e, *level as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = host.hub.stack_from_network(s);
|
||||
out.count = out.count.max(1);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `LivingEntity.tickDeath`: 20 ticks after dying the player is removed,
|
||||
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
|
||||
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
|
||||
/// sooner removes it too.
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.death_time += 1;
|
||||
if g.death_time >= 20 {
|
||||
self.guest_drop_carried(host, gi);
|
||||
}
|
||||
}
|
||||
|
||||
fn guest_drop_carried(&mut self, host: &mut LanHost, gi: usize) {
|
||||
if let Some(c) = host.hub.guests[gi].carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed`: what a closing player held on the
|
||||
/// cursor goes back into their inventory (`placeItemBackInInventory`,
|
||||
/// dropped only if it doesn't fit), and their client's cursor empties
|
||||
/// with the inventory it is sent.
|
||||
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
|
||||
pub(super) fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
|
||||
if let Some(c) = host.hub.guests[gi].carried.take() {
|
||||
self.lan_place_back(host, gi, vec![c]);
|
||||
host.hub.send_inventory(gi);
|
||||
@@ -1573,6 +1695,8 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
// PlayerList.respawn removes the dead player (Player.remove).
|
||||
self.guest_drop_carried(host, i);
|
||||
// PlayerList.respawn: a server's player comes back in its respawn
|
||||
// point's level: the Overworld, where the world spawn and beds are,
|
||||
// or another one's anchor or forced point, which its block resolves
|
||||
@@ -1613,6 +1737,7 @@ impl Game<'_> {
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
g.death_time = 0;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.saturation = 5.0;
|
||||
@@ -1649,6 +1774,11 @@ impl Game<'_> {
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.level.hardcore {
|
||||
host.hub.set_game_mode(conn, SPECTATOR);
|
||||
}
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
}
|
||||
@@ -1680,6 +1810,8 @@ impl Game<'_> {
|
||||
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
|
||||
self.lan_merchant_tick(host);
|
||||
self.lan_stations_tick(host);
|
||||
self.lan_tables_tick(host);
|
||||
self.lan_containers_tick(host);
|
||||
self.tick_lan_mount_menus(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
@@ -1694,13 +1826,29 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
self.guest_sleep_tick(host, gi);
|
||||
if host.hub.guests[gi].dead || self.on_server_trip(conn) {
|
||||
if host.hub.guests[gi].dead {
|
||||
self.guest_tick_death(host, gi);
|
||||
continue;
|
||||
}
|
||||
if self.on_server_trip(conn) {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled).
|
||||
// (the shield an axe disabled, a thrown pearl).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
play.creative_drops = play.creative_drops.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1726,14 +1874,13 @@ impl Game<'_> {
|
||||
g.use_delay -= 1;
|
||||
}
|
||||
});
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a creative
|
||||
// player's START breaks the block once (destroyAndAck) and
|
||||
// leaves nothing being destroyed; its client sends no STOP.
|
||||
// Kept, the dig broke whatever was put there next, every
|
||||
// destroy delay, for as long as the player broke nothing else.
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: creative and
|
||||
// instant survival STARTs finish without a STOP from the client.
|
||||
// tick_mining clears mining on completion; forget the START too,
|
||||
// so a flower or crop planted here later is not broken again.
|
||||
if digging.is_some()
|
||||
&& host.hub.guests[gi].creative()
|
||||
&& let Some(play) = host.play.get_mut(&conn)
|
||||
&& (host.hub.guests[gi].creative() || play.mining.is_none())
|
||||
{
|
||||
play.digging = None;
|
||||
}
|
||||
@@ -1792,31 +1939,34 @@ impl Game<'_> {
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
let message = match cause {
|
||||
// ServerPlayer.die: the death message, to everyone unless
|
||||
// the player's team hides it.
|
||||
Some(key) if self.ui.rules.bool("show_death_messages") => {
|
||||
let text = horizon_client::text::Text::translate(
|
||||
&key,
|
||||
&[self.player_text(&name, uuid)],
|
||||
);
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
message
|
||||
}
|
||||
Some(_) => literal(""),
|
||||
None => literal(&format!("{name} died")),
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -1832,6 +1982,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.die`'s message from a guest's own `CombatTracker`, which
|
||||
/// sits in `guest_combat` between turns.
|
||||
fn guest_death_message(
|
||||
&mut self,
|
||||
conn: u32,
|
||||
me: horizon_client::text::Text,
|
||||
) -> horizon_client::text::Text {
|
||||
let mut tracker = self.guest_combat.remove(&conn).unwrap_or_default();
|
||||
std::mem::swap(&mut self.combat, &mut tracker);
|
||||
let text = self.combat_death_message_for(me);
|
||||
std::mem::swap(&mut self.combat, &mut tracker);
|
||||
self.guest_combat.insert(conn, tracker);
|
||||
text
|
||||
}
|
||||
|
||||
/// `Player.tick`'s sleep counter, and `LivingEntity.tick`'s wake-up of a
|
||||
/// sleeper who died or whose bed is gone.
|
||||
fn guest_sleep_tick(&mut self, host: &mut LanHost, gi: usize) {
|
||||
@@ -1942,6 +2107,7 @@ impl Game<'_> {
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let freeze_immune = game.wears_freeze_immune();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2005,11 +2171,24 @@ impl Game<'_> {
|
||||
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
|
||||
// lit campfires and grown sweet berry bushes hurt (#1305).
|
||||
w.player.shift_key_down = sneaking;
|
||||
w.player.freeze_immune = freeze_immune;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
|
||||
for event in &events {
|
||||
if let MotionEvent::CauldronExtinguish { pos } = event {
|
||||
game.cauldron_extinguish(*pos);
|
||||
}
|
||||
}
|
||||
// WitherRoseBlock.entityInside along those moves.
|
||||
for event in &events {
|
||||
if matches!(event, MotionEvent::InsideWitherRose) {
|
||||
game.wither_rose_contact();
|
||||
}
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleMovePlayer:
|
||||
// Player.checkMovementStatistics for each move.
|
||||
for d in travelled {
|
||||
@@ -2033,14 +2212,19 @@ impl Game<'_> {
|
||||
resources: game.motion,
|
||||
}
|
||||
.server_travel();
|
||||
// Landing damage from this travel reaches the tracker too.
|
||||
events.append(&mut player.events);
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
(events, marked.then_some(player.velocity))
|
||||
let velocity = marked.then_some(player.velocity);
|
||||
game.record_combat(&events);
|
||||
(events, velocity)
|
||||
});
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
|
||||
@@ -19,8 +19,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
pub(super) struct GuestCrafting {
|
||||
/// The inventory menu; its grid lives in the hub's guest.
|
||||
inventory: Option<Box<Menu>>,
|
||||
/// The open crafting table: its window, state id and menu.
|
||||
table: Option<(i32, i32, Box<Menu>)>,
|
||||
/// The open crafting table: its window, state id, menu and block.
|
||||
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
|
||||
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
|
||||
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
|
||||
/// container menu keeps the client in sync.
|
||||
@@ -225,6 +225,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
|
||||
_ => return false,
|
||||
};
|
||||
let furnace = matches!(kind, Kind::Furnace(_));
|
||||
// CrafterBlockEntity.containerData: the disabled slots, then triggered.
|
||||
let crafter_data = match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Crafter(c)) => Some((c.disabled, c.triggered)),
|
||||
@@ -284,6 +285,12 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// FurnaceResultSlot.checkTakeAchievements: the guest took from the output.
|
||||
let count = |s: Option<&ItemStack>| s.map_or(0, |s| s.count);
|
||||
if furnace && count(menu.slot(2)) < count(items[2].as_ref()) {
|
||||
let at = host.hub.guests[gi].position;
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
host.hub.inventory_clicked(conn, p, &self.world, &changed);
|
||||
true
|
||||
@@ -383,17 +390,17 @@ impl Game<'_> {
|
||||
host.hub.send_inventory(gi);
|
||||
placed
|
||||
} else {
|
||||
let Some((window, state, mut menu)) = host
|
||||
let Some((window, state, mut menu, pos)) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|play| play.crafting.table.take_if(|(w, _, _)| *w == p.container))
|
||||
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == p.container))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
});
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
|
||||
self.lan_send_table_content(host, conn);
|
||||
placed
|
||||
};
|
||||
@@ -403,7 +410,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
@@ -413,7 +420,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let menu = Box::new(Menu::new(Kind::Crafting));
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, 0, menu));
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, 0, menu, pos));
|
||||
self.lan_send_table_content(host, conn);
|
||||
}
|
||||
|
||||
@@ -427,31 +434,72 @@ impl Game<'_> {
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return false;
|
||||
};
|
||||
let Some((window, state, mut menu)) = play
|
||||
let Some((window, state, mut menu, pos)) = play
|
||||
.crafting
|
||||
.table
|
||||
.take_if(|(window, _, _)| *window == p.container)
|
||||
.take_if(|(window, ..)| *window == p.container)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// A table that is no longer valid takes no clicks (handleContainerClick).
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
&& self.lan_table_valid(&host.hub.guests[gi], pos)
|
||||
{
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
});
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
|
||||
self.lan_send_table_content(host, conn);
|
||||
true
|
||||
}
|
||||
|
||||
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
|
||||
/// guest is within reach of it.
|
||||
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name;
|
||||
&**name == "minecraft:crafting_table" && g.within_block_range(pos, 4.0)
|
||||
}
|
||||
|
||||
/// `ServerPlayer.tick`'s `stillValid`: a guest's crafting table closes
|
||||
/// once it is gone or out of reach, giving its grid back.
|
||||
pub(super) fn lan_tables_tick(&mut self, host: &mut LanHost) {
|
||||
let invalid: Vec<(u32, i32)> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter_map(|(c, p)| p.crafting.table.as_ref().map(|t| (*c, t.0, t.3)))
|
||||
.filter(|&(c, _, pos)| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == c)
|
||||
.is_some_and(|g| host.hub.here(g) && !self.lan_table_valid(g, pos))
|
||||
})
|
||||
.map(|(c, window, _)| (c, window))
|
||||
.collect();
|
||||
for (conn, window) in invalid {
|
||||
self.lan_crafting_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerClose(play::ContainerClose { container: window }),
|
||||
);
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole crafting table menu for the client (`sendAllDataToRemote`).
|
||||
fn lan_send_table_content(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let Some((window, state, menu)) = host
|
||||
let Some((window, state, menu, _)) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.crafting.table.as_mut())
|
||||
@@ -499,9 +547,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
match crafting
|
||||
.table
|
||||
.take_if(|(w, _, _)| window.is_none_or(|window| *w == window))
|
||||
.take_if(|(w, ..)| window.is_none_or(|window| *w == window))
|
||||
{
|
||||
Some((_, _, menu)) => (1..10).filter_map(|s| menu.slot(s).cloned()).collect(),
|
||||
Some((_, _, menu, _)) => (1..10).filter_map(|s| menu.slot(s).cloned()).collect(),
|
||||
None => return false,
|
||||
}
|
||||
};
|
||||
@@ -527,7 +575,7 @@ impl Game<'_> {
|
||||
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
|
||||
g.craft_result = None;
|
||||
if let Some(play) = play {
|
||||
if let Some((_, _, menu)) = play.crafting.table {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
}
|
||||
// A workstation's inputs too.
|
||||
|
||||
@@ -404,11 +404,19 @@ impl Game<'_> {
|
||||
Value::Item(hub.stack(&Some(r.item.clone()))),
|
||||
));
|
||||
}
|
||||
let boosted = match r.guest {
|
||||
Some(conn) => hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| g.entity_id),
|
||||
None => host_entity,
|
||||
};
|
||||
if r.attached
|
||||
&& let Some(host) = host_entity
|
||||
&& let Some(player) = boosted
|
||||
{
|
||||
e.meta
|
||||
.push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(host + 1)));
|
||||
.push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(player + 1)));
|
||||
}
|
||||
if r.angled {
|
||||
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
|
||||
|
||||
@@ -171,6 +171,19 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The same guests' feet and whether they stand on the ground, for the
|
||||
/// blocks that also ask that (`BigDripleafBlock.canEntityTilt`).
|
||||
pub(super) fn lan_guest_feet(&self) -> Vec<([f64; 3], bool)> {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.map(|g| (g.position, g.on_ground))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The guests a projectile can hit, under their entity ids.
|
||||
pub(super) fn lan_projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
|
||||
@@ -161,12 +161,12 @@ impl Game<'_> {
|
||||
+ (self.particles.random().next_float() - self.particles.random().next_float())
|
||||
* 0.2)
|
||||
* 0.7;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.explode",
|
||||
Some(p.center),
|
||||
4.0,
|
||||
pitch,
|
||||
);
|
||||
// The packet's explosionSound: a wind charge's burst, a bed's
|
||||
// or anchor's blast, a modded sound.
|
||||
let sound = self
|
||||
.remote_sound_name(&p.sound)
|
||||
.unwrap_or_else(|| "minecraft:entity.generic.explode".to_owned());
|
||||
self.audio.play(&sound, Some(p.center), 4.0, pitch);
|
||||
}
|
||||
if let Some(options) = self.received_particle(&p.particle, p.center) {
|
||||
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
|
||||
|
||||
@@ -388,7 +388,10 @@ impl Restrictions {
|
||||
1 => Some("chat_restriction.chat_disabled_by_options"),
|
||||
_ => None,
|
||||
};
|
||||
let (chat, commands) = (chat_visibility == 0, chat_visibility < 2);
|
||||
let abilities = horizon_client::chat::ChatAbilities::new(
|
||||
horizon_client::chat::ChatVisibility::from_option(chat_visibility),
|
||||
);
|
||||
let (chat, commands) = (abilities.send_messages, abilities.send_commands);
|
||||
// The body's widgets: text blocks (lines, top) and the action's top.
|
||||
let mut blocks = Vec::new();
|
||||
let mut action = None;
|
||||
@@ -802,6 +805,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer moved while a list's scrollbar is held: the game rules'
|
||||
/// list follows it (AbstractSelectionList.mouseDragged).
|
||||
pub(super) fn world_options_drag(&mut self) {
|
||||
let h = self.gui_size().1;
|
||||
if let Some(o) = self.world_options.as_mut()
|
||||
&& o.view == View::GameRules
|
||||
&& let Some(rules) = o.rules.as_mut()
|
||||
{
|
||||
rules.drag(self.cursor.1, h);
|
||||
}
|
||||
}
|
||||
|
||||
/// The button came up: a held scrollbar lets go (onRelease).
|
||||
pub(super) fn world_options_release(&mut self) {
|
||||
if let Some(o) = self.world_options.as_mut()
|
||||
&& let Some(rules) = o.rules.as_mut()
|
||||
{
|
||||
rules.release();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn world_options_click(&mut self) {
|
||||
let Some(mut o) = self.world_options.take() else {
|
||||
return;
|
||||
|
||||
@@ -588,7 +588,7 @@ impl Game<'_> {
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
let block = StationKind::from_block(name) == Some(gs.kind);
|
||||
if !(block && within_reach(g, gs.pos)) {
|
||||
if !(block && g.within_block_range(gs.pos, 4.0)) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
@@ -631,16 +631,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some()
|
||||
);
|
||||
// Container.stillValidBlockEntity: the interaction range and its buffer.
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
|
||||
if has_book && near {
|
||||
self.lan_send_lectern(host, conn, false);
|
||||
} else {
|
||||
@@ -660,18 +651,3 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
|
||||
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
|
||||
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
|
||||
/// block's box.
|
||||
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
(0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach
|
||||
}
|
||||
@@ -79,6 +79,8 @@ impl Game<'_> {
|
||||
if horizon_client::mobs::leash::remove(m).is_none() {
|
||||
return;
|
||||
}
|
||||
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
|
||||
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if sound {
|
||||
self.lead_sound("break", at);
|
||||
|
||||
@@ -101,6 +101,39 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
|
||||
/// (an end portal opening, the dragon's death roar): here, and sent to
|
||||
/// every guest whatever their dimension.
|
||||
pub(super) fn global_level_event(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.global_level_event_sound(id, pos);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_global_events.push((id, pos, data));
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler.globalLevelEvent`: the sound two blocks from the
|
||||
/// camera towards the event, so it is heard from anywhere.
|
||||
pub(super) fn global_level_event_sound(&mut self, id: i32, pos: [i32; 3]) {
|
||||
let (sound, volume) = match id {
|
||||
1023 => ("minecraft:entity.wither.spawn", 1.0),
|
||||
1028 => ("minecraft:entity.ender_dragon.death", 5.0),
|
||||
1038 => ("minecraft:block.end_portal.spawn", 1.0),
|
||||
_ => return,
|
||||
};
|
||||
let at = self.eye_position().map(|eye| {
|
||||
let d = [
|
||||
f64::from(pos[0]) + 0.5 - eye[0],
|
||||
f64::from(pos[1]) + 0.5 - eye[1],
|
||||
f64::from(pos[2]) + 0.5 - eye[2],
|
||||
];
|
||||
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if length < 1.0e-4 {
|
||||
return eye;
|
||||
}
|
||||
[0, 1, 2].map(|k| eye[k] + d[k] / length * 2.0)
|
||||
});
|
||||
// Each guest plays it from the global packet.
|
||||
self.audio.play_unshared(sound, at, volume, 1.0);
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -21,7 +21,7 @@ use super::{
|
||||
use horizon_client::{
|
||||
Result, block_entity_anim::BlockEntityAnims, random_tick_sections::RandomTickSections,
|
||||
save::SaveDir, streaming::Streaming, tick_chunk_membership::TickChunkMembership,
|
||||
ticket_levels::ChunkTickets, world::World,
|
||||
ticket_levels::ChunkTickets, vanillagen::VanillaTerrain, world::World,
|
||||
};
|
||||
use horizon_world::level::{LevelEvent, Ticks};
|
||||
use std::{
|
||||
@@ -39,6 +39,7 @@ const IDLE_TICKS: u32 = 300;
|
||||
pub(super) struct Parked {
|
||||
world: World,
|
||||
streaming: Streaming,
|
||||
locator: Option<Arc<VanillaTerrain>>,
|
||||
save: Arc<SaveDir>,
|
||||
block_ticks: Ticks,
|
||||
tick_chunks: TickChunkMembership,
|
||||
@@ -109,13 +110,21 @@ pub(super) struct Levels {
|
||||
impl Game<'_> {
|
||||
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
|
||||
fn open_level(&self, dim: Dim) -> Result<Parked> {
|
||||
let (streaming, save) =
|
||||
self.make_streaming(&self.world_root.clone(), dim, self.level.seed)?;
|
||||
let (streaming, save, locator) = super::world_loading::make_streaming(
|
||||
&self.res,
|
||||
&self.pack,
|
||||
&self.options,
|
||||
&self.settings,
|
||||
&self.world_root,
|
||||
dim,
|
||||
self.level.seed,
|
||||
)?;
|
||||
let mut world = World::new(self.res.facts().air, self.level.seed);
|
||||
world.fast_lava = dim == Dim::Nether;
|
||||
Ok(Parked {
|
||||
world,
|
||||
streaming,
|
||||
locator,
|
||||
save,
|
||||
block_ticks: Ticks::new(self.level.seed as i32),
|
||||
tick_chunks: Default::default(),
|
||||
@@ -163,6 +172,9 @@ impl Game<'_> {
|
||||
use std::mem::swap;
|
||||
swap(&mut self.world, &mut p.world);
|
||||
swap(&mut self.streaming, &mut p.streaming);
|
||||
let mut locator = self.end.locator();
|
||||
swap(&mut locator, &mut p.locator);
|
||||
self.end.set_locator(locator);
|
||||
swap(&mut self.save, &mut p.save);
|
||||
swap(&mut self.block_ticks, &mut p.block_ticks);
|
||||
swap(&mut self.tick_chunks, &mut p.tick_chunks);
|
||||
@@ -365,7 +377,12 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
// A `/pregen` task keeps the workers fed until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
}
|
||||
} else if now - *next > Duration::from_secs(2) {
|
||||
*next = now;
|
||||
}
|
||||
@@ -373,7 +390,7 @@ impl Game<'_> {
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
/// (the Overworld, the Nether, the End): every level with a player in
|
||||
/// it, or the Overworld while nobody is online.
|
||||
/// it or a `/pregen` task, or the Overworld while nobody is online.
|
||||
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
|
||||
let guests = self
|
||||
.lan
|
||||
@@ -381,7 +398,9 @@ impl Game<'_> {
|
||||
.map_or(&[][..], |h| h.hub.guests.as_slice());
|
||||
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.filter(|d| guests.iter().any(|g| g.dimension == d.id()))
|
||||
.filter(|d| {
|
||||
guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d)
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
dims.push(Dim::Overworld);
|
||||
|
||||
@@ -218,7 +218,7 @@ impl Game<'_> {
|
||||
oy: f32,
|
||||
draws: &[ScreenDraw],
|
||||
background: bool,
|
||||
) -> Option<Vec<(String, u32)>> {
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut tooltip = None;
|
||||
let mut sheet: Option<String> = None;
|
||||
@@ -261,7 +261,10 @@ impl Game<'_> {
|
||||
..
|
||||
} => self.draw_banner_result(batch, ox + *x0 as f32, oy + *y0 as f32, base, layers),
|
||||
ScreenDraw::Tooltip { key } => {
|
||||
tooltip = Some(vec![(self.translate(key, key), u32::MAX)]);
|
||||
tooltip = Some(vec![(
|
||||
horizon_client::text::Text::literal(self.translate(key, key)),
|
||||
u32::MAX,
|
||||
)]);
|
||||
}
|
||||
ScreenDraw::SpriteOf { id } => sheet = Some(id.clone()),
|
||||
ScreenDraw::Fill {
|
||||
@@ -314,7 +317,7 @@ impl Game<'_> {
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
local: (f32, f32),
|
||||
) -> Option<Vec<(String, u32)>> {
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let s = self.station.as_ref()?;
|
||||
let l = &s.loom;
|
||||
let dye = s.inputs[1]
|
||||
|
||||
@@ -889,13 +889,31 @@ impl Game<'_> {
|
||||
let key_is_cart = self
|
||||
.container
|
||||
.is_some_and(|k| k.1 == super::minecarts::CART_KEY_Y);
|
||||
let (texture, height, title) = match screen {
|
||||
Screen::Hopper if key_is_cart => {
|
||||
("gui/container/hopper", 133.0, "Minecart with Hopper")
|
||||
}
|
||||
Screen::Hopper => ("gui/container/hopper", 133.0, "Item Hopper"),
|
||||
Screen::Dispenser => ("gui/container/dispenser", 166.0, "Dispenser"),
|
||||
Screen::Dropper => ("gui/container/dispenser", 166.0, "Dropper"),
|
||||
let (texture, height, key, fallback) = match screen {
|
||||
Screen::Hopper if key_is_cart => (
|
||||
"gui/container/hopper",
|
||||
133.0,
|
||||
"entity.minecraft.hopper_minecart",
|
||||
"Minecart with Hopper",
|
||||
),
|
||||
Screen::Hopper => (
|
||||
"gui/container/hopper",
|
||||
133.0,
|
||||
"container.hopper",
|
||||
"Item Hopper",
|
||||
),
|
||||
Screen::Dispenser => (
|
||||
"gui/container/dispenser",
|
||||
166.0,
|
||||
"container.dispenser",
|
||||
"Dispenser",
|
||||
),
|
||||
Screen::Dropper => (
|
||||
"gui/container/dispenser",
|
||||
166.0,
|
||||
"container.dropper",
|
||||
"Dropper",
|
||||
),
|
||||
};
|
||||
if let Some(mut r) = atlas.sprites.get(texture).copied() {
|
||||
let dv = (r.v1 - r.v0) / r.h as f32;
|
||||
@@ -905,23 +923,18 @@ impl Game<'_> {
|
||||
batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX);
|
||||
}
|
||||
let grey = horizon_client::hud::rgba(64, 64, 64, 255);
|
||||
let title = &self
|
||||
.remote_menu_title()
|
||||
.unwrap_or_else(|| self.container_title(title.to_owned()));
|
||||
let title = &self.remote_menu_title().unwrap_or_else(|| {
|
||||
horizon_client::text::Text::literal(self.container_title(self.translate(key, fallback)))
|
||||
.resolve_legacy()
|
||||
});
|
||||
let title_x = if screen == Screen::Hopper {
|
||||
8.0
|
||||
} else {
|
||||
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, title)) / 2.0
|
||||
(176.0 - atlas.font.width_text(title)) / 2.0
|
||||
};
|
||||
batch.text(atlas, title, ox + title_x, oy + 6.0, grey, false);
|
||||
batch.text(
|
||||
atlas,
|
||||
"Inventory",
|
||||
ox + 8.0,
|
||||
oy + height - 94.0,
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
batch.text_styled(atlas, title, ox + title_x, oy + 6.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + height - 94.0, grey, false);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ fn recipe_matches(condition: &Value, id: &str) -> bool {
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Level.setBlock` of one boolean property of the block at `pos`.
|
||||
fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
|
||||
pub(crate) fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let old = self.world.block(pos.0, pos.1, pos.2);
|
||||
let Some(mut state) = registry.state(old).cloned() else {
|
||||
@@ -256,7 +256,8 @@ impl Game<'_> {
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
self.paint_screen(batch, ox, oy, rest, true);
|
||||
}
|
||||
/// `gui.togglable_slot` over an empty, enabled grid slot.
|
||||
|
||||
@@ -1463,6 +1463,7 @@ impl Game<'_> {
|
||||
lid: &lid,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
};
|
||||
self.res
|
||||
|
||||
Loaded 100 of 489 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user