mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 08:07:53 -10:00
Compare commits
173
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3149d43ef | ||
|
|
e1a7892361 | ||
|
|
e9f93c656a | ||
|
|
eec2e87a15 | ||
|
|
373d9c08c0 | ||
|
|
911ca721de | ||
|
|
5034e4557d | ||
|
|
40c08c2d12 | ||
|
|
73da2f04dd | ||
|
|
39efe6ef39 | ||
|
|
29c09b4bf2 | ||
|
|
2189baea6d | ||
|
|
836c1ee3b9 | ||
|
|
cc7534341c | ||
|
|
21d8e53034 | ||
|
|
6df37324f5 | ||
|
|
d58d26c793 | ||
|
|
93e3096e06 | ||
|
|
e05933169a | ||
|
|
bb3497bea0 | ||
|
|
6ef8deedca | ||
|
|
d63d0c1744 | ||
|
|
f5e97deac2 | ||
|
|
116a7f7dca | ||
|
|
31e9e2da97 | ||
|
|
74768d2eeb | ||
|
|
a4a709c2d6 | ||
|
|
b4345b1c2b | ||
|
|
ed11f614f8 | ||
|
|
da0056c248 | ||
|
|
2364bd3504 | ||
|
|
82b0051a3c | ||
|
|
e0abe90c5c | ||
|
|
982034f5f8 | ||
|
|
2761349d98 | ||
|
|
21d8b0702c | ||
|
|
d0f61d07cf | ||
|
|
4e8269276f | ||
|
|
fd56c1f358 | ||
|
|
fa2ae64d83 | ||
|
|
c5d816cc12 | ||
|
|
99a954d665 | ||
|
|
f3efe2802c | ||
|
|
f65750baed | ||
|
|
21203de7f0 | ||
|
|
731d88a1d8 | ||
|
|
1a16e35aaa | ||
|
|
b3e400a526 | ||
|
|
903da4f1ab | ||
|
|
54bb3e4fbe | ||
|
|
03a5cbdcd8 | ||
|
|
b7b4a26164 | ||
|
|
8b4336013a | ||
|
|
1c3be171ce | ||
|
|
befec1995f | ||
|
|
05396b4edf | ||
|
|
3d8a8f7d02 | ||
|
|
bc09558dfa | ||
|
|
7fd1f3118f | ||
|
|
4309d96924 | ||
|
|
478b8c19c4 | ||
|
|
2af75ba9f6 | ||
|
|
89888e9b07 | ||
|
|
1110a28521 | ||
|
|
f6e451162c | ||
|
|
6ce6577886 | ||
|
|
8f2aa7f7e9 | ||
|
|
4513af8995 | ||
|
|
8177a6f450 | ||
|
|
f6b882c9da | ||
|
|
9e248a3214 | ||
|
|
781b7b877b | ||
|
|
50f4c913a5 | ||
|
|
9ec5f1ddbc | ||
|
|
e555c4ee48 | ||
|
|
f5adaec209 | ||
|
|
406fbd7793 | ||
|
|
c0567a323c | ||
|
|
f969415c7a | ||
|
|
0713ee5ec0 | ||
|
|
f9d85d2e38 | ||
|
|
b4f12543ca | ||
|
|
4604491fcb | ||
|
|
b3840adccd | ||
|
|
90eda915d5 | ||
|
|
bdbc816486 | ||
|
|
6f8cad0020 | ||
|
|
13508cbd9c | ||
|
|
72b9daabf0 | ||
|
|
1087739079 | ||
|
|
d8ed068db8 | ||
|
|
2ec2964962 | ||
|
|
bb414b263c | ||
|
|
f324251563 | ||
|
|
f2832f83ef | ||
|
|
5ee1130719 | ||
|
|
9fe77c7a56 | ||
|
|
6443847633 | ||
|
|
b873f30c29 | ||
|
|
8c54e18587 | ||
|
|
0dfc6d8700 | ||
|
|
b983282247 | ||
|
|
a495fcf7a7 | ||
|
|
83e8cbc4b6 | ||
|
|
b729cfc833 | ||
|
|
0298d10988 | ||
|
|
8942d00ace | ||
|
|
0609fb2beb | ||
|
|
e37770bbfe | ||
|
|
70aff6e90b | ||
|
|
b9d2d93ccb | ||
|
|
df23e1bfed | ||
|
|
75951a769a | ||
|
|
94bc569a7f | ||
|
|
50727eb5c3 | ||
|
|
65d3edfc3a | ||
|
|
ee74553314 | ||
|
|
6d3cd330d1 | ||
|
|
204859a2cb | ||
|
|
af7a685b5c | ||
|
|
fdfa3d568d | ||
|
|
d9360338f6 | ||
|
|
feec1b67e8 | ||
|
|
bf81ae6358 | ||
|
|
5bfc649edd | ||
|
|
0c017678eb | ||
|
|
10efca9e6c | ||
|
|
436c2fefb4 | ||
|
|
f48cfb7966 | ||
|
|
0481dbdb81 | ||
|
|
c5a640dd56 | ||
|
|
8ffd595575 | ||
|
|
13e83e4e63 | ||
|
|
3843a9a4aa | ||
|
|
467277ef6d | ||
|
|
4bfa496173 | ||
|
|
18ec4145c4 | ||
|
|
ed7d88b213 | ||
|
|
5fb0cad6e7 | ||
|
|
4b5fa2ea6b | ||
|
|
a9e6818d2a | ||
|
|
e9d2c10417 | ||
|
|
a2fccf0205 | ||
|
|
b457f6382a | ||
|
|
dbafda06ed | ||
|
|
e181b17e17 | ||
|
|
f56785c2f5 | ||
|
|
68deac8533 | ||
|
|
2f1f0150eb | ||
|
|
806f7d87f3 | ||
|
|
41b799042a | ||
|
|
df626cf2f3 | ||
|
|
40c77c02f0 | ||
|
|
9e21868dfa | ||
|
|
be378bb773 | ||
|
|
4032e162ce | ||
|
|
1ec6d6d734 | ||
|
|
1437537cbf | ||
|
|
c879c22a6a | ||
|
|
39904c8051 | ||
|
|
e7ca664e01 | ||
|
|
11035336a0 | ||
|
|
c99de82044 | ||
|
|
5262effa7e | ||
|
|
c2c8df4542 | ||
|
|
f08546fd74 | ||
|
|
e7f85e5bfd | ||
|
|
272e230371 | ||
|
|
90ae953747 | ||
|
|
da150409ea | ||
|
|
348675c538 | ||
|
|
372f918e44 | ||
|
|
d4b2e24535 |
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 }}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
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
|
||||
@@ -37,13 +37,56 @@ jobs:
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: 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: |
|
||||
|
||||
@@ -82,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):
|
||||
|
||||
Generated
+97
@@ -1116,12 +1116,44 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
|
||||
dependencies = [
|
||||
"khronos_api",
|
||||
"log",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.30.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"slotmap",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin_wgl_sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
|
||||
dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
@@ -1352,6 +1384,13 @@ dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods-test-ui-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1805,6 +1844,23 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libloading",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos_api"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -2677,6 +2733,13 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predator-missile"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3142,6 +3205,15 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "slotmap"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.2"
|
||||
@@ -4267,6 +4339,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"wgpu-core-deps-apple",
|
||||
"wgpu-core-deps-emscripten",
|
||||
"wgpu-core-deps-windows-linux-android",
|
||||
"wgpu-hal",
|
||||
"wgpu-naga-bridge",
|
||||
@@ -4282,6 +4355,15 @@ dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-emscripten"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "30.0.1"
|
||||
@@ -4304,13 +4386,18 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"glow",
|
||||
"glutin_wgl_sys",
|
||||
"gpu-allocator",
|
||||
"hashbrown 0.17.1",
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"naga",
|
||||
"naga-types",
|
||||
"ndk-sys",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -4330,10 +4417,14 @@ dependencies = [
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen",
|
||||
"wayland-sys",
|
||||
"web-sys",
|
||||
"wgpu-naga-bridge",
|
||||
"wgpu-types",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-result 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5074,6 +5165,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/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", "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", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+638
-13
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: 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.
|
||||
**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.
|
||||
|
||||
@@ -80,6 +80,16 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
|
||||
|
||||
</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
|
||||
@@ -94,6 +104,40 @@ 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
|
||||
@@ -130,6 +174,16 @@ More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](http
|
||||
|
||||
</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
|
||||
@@ -160,11 +214,65 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
|
||||
|
||||
#### Fresh benchmarks against vanilla
|
||||
|
||||
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.
|
||||
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)). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
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>
|
||||
|
||||
@@ -180,6 +288,136 @@ The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-
|
||||
|
||||
</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
|
||||
@@ -192,6 +430,50 @@ Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (hon
|
||||
|
||||
</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)
|
||||
@@ -206,6 +488,18 @@ 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.
|
||||
@@ -216,29 +510,359 @@ x86_64 game and launcher builds, picked per processor ([#1171](https://github.co
|
||||
|
||||
</details>
|
||||
|
||||
## Later
|
||||
#### Wayland and Hyprland polish
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Following new Minecraft versions
|
||||
|
||||
When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.
|
||||
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>
|
||||
|
||||
The port pipeline detects a release, diffs its classes against 26.3 and writes a port report ([docs/version-ports.md](docs/version-ports.md)); the harnesses then run against the new version.
|
||||
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
|
||||
|
||||
#### An optional AI teammate
|
||||
#### A complete video studio inside the game
|
||||
|
||||
An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.
|
||||
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>
|
||||
|
||||
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)). Local models first; it ships only once the safety evaluation passes.
|
||||
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>
|
||||
|
||||
@@ -330,6 +954,7 @@ Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -224,7 +224,10 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
Output::Feedback { 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(),
|
||||
});
|
||||
}
|
||||
match failed(&mods) {
|
||||
|
||||
@@ -68,6 +68,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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -190,6 +194,7 @@ impl BlockEntity {
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
@@ -483,6 +488,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 +577,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 +608,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))));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -20,6 +20,7 @@ mod bossbars;
|
||||
mod breezes;
|
||||
mod cartography;
|
||||
mod chat;
|
||||
mod client_ambient;
|
||||
mod clipboard;
|
||||
mod combat;
|
||||
mod command_blocks;
|
||||
@@ -482,6 +483,9 @@ enum Screen {
|
||||
CommandBlock,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods
|
||||
/// (game/mods.rs).
|
||||
Mod,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -537,6 +541,8 @@ struct GameLevel<'w> {
|
||||
seed: i64,
|
||||
behaviours: &'w Behaviours,
|
||||
items: &'w Items,
|
||||
/// Cooking recipes' experience, which a removed furnace pays out.
|
||||
recipes: &'w horizon_client::crafting::Recipes,
|
||||
/// Container loot tables (`unpackLootTable`).
|
||||
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
@@ -1168,6 +1174,8 @@ struct Game<'a> {
|
||||
carried: Option<ItemStack>,
|
||||
craft_grid: Vec<Option<ItemStack>>,
|
||||
craft_size: usize,
|
||||
/// The crafting table the 3x3 grid belongs to (`CraftingMenu.access`).
|
||||
craft_table: Option<[i32; 3]>,
|
||||
/// A quick craft drag over inventory slots: (button, game slots so far).
|
||||
inv_drag: Option<(i32, Vec<usize>)>,
|
||||
/// `AbstractContainerScreen`'s quick craft and skip-release state.
|
||||
@@ -1568,6 +1576,8 @@ struct Game<'a> {
|
||||
cmds: commands::extra::CommandExtras,
|
||||
ui: chat::UiState,
|
||||
combat: combat::CombatTracker,
|
||||
/// Each guest's own `CombatTracker`, out of `combat` between turns.
|
||||
guest_combat: std::collections::HashMap<u32, combat::CombatTracker>,
|
||||
mount: mounts::MountState,
|
||||
stats: stats::Stats,
|
||||
score: scoreboard::ScoreState,
|
||||
@@ -1837,6 +1847,19 @@ impl Game<'_> {
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
})
|
||||
}
|
||||
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
|
||||
/// `freeze_immune_wearables`.
|
||||
pub(crate) fn wears_freeze_immune(&self) -> bool {
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
self.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
|
||||
}
|
||||
/// What the window decides for its player's body every tick, in frame and
|
||||
/// on a server alike: the look (Player.travel steers sprint-swimming by
|
||||
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
|
||||
@@ -1863,16 +1886,7 @@ impl Game<'_> {
|
||||
let leather_boots = self.armor[3]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
let freeze_immune = self
|
||||
.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
|
||||
let freeze_immune = self.wears_freeze_immune();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -2173,6 +2187,11 @@ impl Game<'_> {
|
||||
// Player.drop: the drop statistics.
|
||||
self.stats.add("dropped", &thrown.item, thrown.count as i32);
|
||||
self.stats.custom("drop", 1);
|
||||
self.throw_from_eyes(thrown, eye);
|
||||
}
|
||||
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
|
||||
/// along the view direction.
|
||||
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) {
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
@@ -2420,6 +2439,7 @@ impl Game<'_> {
|
||||
seed: self.level.seed,
|
||||
behaviours: self.res.behaviours(),
|
||||
items: self.res.items(),
|
||||
recipes: self.res.recipes(),
|
||||
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
|
||||
registry: self.pack.assets.registry,
|
||||
rules,
|
||||
@@ -2916,6 +2936,7 @@ impl Game<'_> {
|
||||
}
|
||||
if &*name == "minecraft:crafting_table" {
|
||||
self.craft_size = 3;
|
||||
self.craft_table = Some(pos);
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
self.open_ender_chest(key);
|
||||
self.container_opener_event(key, true);
|
||||
@@ -4289,6 +4310,7 @@ impl Game<'_> {
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
|
||||
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
|
||||
MotionEvent::SmashLanding {
|
||||
state,
|
||||
position,
|
||||
@@ -4335,7 +4357,8 @@ impl Game<'_> {
|
||||
// Out of the world.
|
||||
let _ = player.set_health(0.0, false);
|
||||
}
|
||||
if player.health.dead_or_dying() && self.screen != Screen::Dead {
|
||||
let dead = player.health.dead_or_dying();
|
||||
if dead && self.screen != Screen::Dead {
|
||||
// Inventory.dropAll: every stack thrown in a random direction.
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
let at = [feet[0], feet[1] + 1.32, feet[2]];
|
||||
@@ -4369,6 +4392,18 @@ impl Game<'_> {
|
||||
}
|
||||
self.screen = Screen::Dead;
|
||||
self.capture(false);
|
||||
self.ui.death_time = 0;
|
||||
} else if dead {
|
||||
// LivingEntity.tickDeath: 20 ticks after dying the player is
|
||||
// removed, and Player.remove's inventoryMenu.removed drops what
|
||||
// the cursor held.
|
||||
self.ui.death_time += 1;
|
||||
if self.ui.death_time >= 20
|
||||
&& let Some(stack) = self.carried.take()
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.throw_from_eyes(stack, eye);
|
||||
}
|
||||
}
|
||||
self.tick_world(running)
|
||||
}
|
||||
@@ -4447,6 +4482,7 @@ impl Game<'_> {
|
||||
self.tick_block_entity_anims()
|
||||
});
|
||||
horizon_client::spans::time("tick_particles", || self.tick_particles());
|
||||
self.tick_client_ambient();
|
||||
}
|
||||
// ServerBossEvent broadcasts, after this level's entities have ticked.
|
||||
self.publish_boss_bars();
|
||||
@@ -5047,6 +5083,7 @@ impl Game<'_> {
|
||||
self.draw_creative(&mut batch);
|
||||
}
|
||||
Screen::Chat => self.draw_chat_screen(&mut batch),
|
||||
Screen::Mod => self.draw_mod_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
@@ -5304,22 +5341,32 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Chest(_)) if self.draw_machine_screen(batch, ox, oy) => {}
|
||||
Some(BlockEntity::Chest(_)) => self.draw_chest_screen(batch, ox, oy),
|
||||
Some(BlockEntity::Furnace(f)) => {
|
||||
let (tex, title) = match f.kind {
|
||||
let (tex, key, fallback) = match f.kind {
|
||||
horizon_client::containers::FurnaceKind::Furnace => {
|
||||
("gui/container/furnace", "Furnace")
|
||||
}
|
||||
horizon_client::containers::FurnaceKind::BlastFurnace => {
|
||||
("gui/container/blast_furnace", "Blast Furnace")
|
||||
("gui/container/furnace", "container.furnace", "Furnace")
|
||||
}
|
||||
horizon_client::containers::FurnaceKind::BlastFurnace => (
|
||||
"gui/container/blast_furnace",
|
||||
"container.blast_furnace",
|
||||
"Blast Furnace",
|
||||
),
|
||||
horizon_client::containers::FurnaceKind::Smoker => {
|
||||
("gui/container/smoker", "Smoker")
|
||||
("gui/container/smoker", "container.smoker", "Smoker")
|
||||
}
|
||||
};
|
||||
part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
let title = &self.container_title(title.to_owned());
|
||||
let tw = HudBatch::text_width(atlas, title);
|
||||
batch.text(atlas, title, ox + (176.0 - tw) / 2.0, oy + 6.0, grey, false);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let title = self.container_title(self.translate(key, fallback));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
ox + (176.0 - tw) / 2.0,
|
||||
oy + 6.0,
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
if f.lit_remaining > 0 {
|
||||
// AbstractFurnaceScreen: flame height ceil(lit * 13) + 1.
|
||||
let total = if f.lit_total == 0 { 200 } else { f.lit_total };
|
||||
@@ -5373,14 +5420,12 @@ impl Game<'_> {
|
||||
"gui/container/inventory"
|
||||
};
|
||||
part(batch, texture, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
let (title, tx) = if self.craft_size == 3 {
|
||||
("Crafting", 29.0)
|
||||
} else {
|
||||
("Crafting", 97.0)
|
||||
};
|
||||
batch.text(atlas, title, ox + tx, oy + 6.0, grey, false);
|
||||
let title = self.translate("container.crafting", "Crafting");
|
||||
let tx = if self.craft_size == 3 { 29.0 } else { 97.0 };
|
||||
batch.text(atlas, &title, ox + tx, oy + 6.0, grey, false);
|
||||
if self.craft_size == 3 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5425,6 +5470,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.craft_size = 2;
|
||||
self.craft_table = None;
|
||||
if let Some(key) = self.container.take() {
|
||||
self.container_opener_event(key, false);
|
||||
self.post_block_game_event(
|
||||
@@ -5936,6 +5982,12 @@ impl Game<'_> {
|
||||
// Attached after the slots were set: setting them is not a click.
|
||||
menu.merchant = session.map(|(s, _)| s);
|
||||
}
|
||||
// `FurnaceResultSlot.checkTakeAchievements` pays out on what left the output slot.
|
||||
let furnace_before = if matches!(kind, Kind::Furnace(_)) {
|
||||
menu.slot(2).cloned().map_or(0, |s| s.count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let outcome = run(&mut menu, &env, &to_menu);
|
||||
let made = maps.made.take();
|
||||
match outcome {
|
||||
@@ -5953,6 +6005,12 @@ impl Game<'_> {
|
||||
self.merchant_sync_out(s);
|
||||
}
|
||||
let after: Vec<Option<ItemStack>> = (0..total).map(|i| menu.slot(i).cloned()).collect();
|
||||
// Only a furnace has a result slot to compare: an enchanting table has two slots.
|
||||
let furnace_after = if matches!(kind, Kind::Furnace(_)) {
|
||||
after[2].as_ref().map_or(0, |s| s.count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let carried = menu.carried.take();
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let sounds = std::mem::take(&mut menu.sounds);
|
||||
@@ -5977,6 +6035,13 @@ impl Game<'_> {
|
||||
self.player_drop(stack);
|
||||
}
|
||||
self.award_crafted(&crafted);
|
||||
if furnace_before > furnace_after
|
||||
&& self.remote.is_none()
|
||||
&& let (Some(pos), Some(p)) = (self.container, self.player.as_ref())
|
||||
{
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
}
|
||||
if kind == Kind::Loom {
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
@@ -7879,8 +7944,12 @@ impl Game<'_> {
|
||||
(0..width).all(|i| frame([left[0] + d[0] * i, left[1] + height, left[2] + d[2] * i]));
|
||||
((3..=21).contains(&height) && top_ok).then_some((left, width, height))
|
||||
}
|
||||
/// BaseFireBlock.onPlace: lighting fire inside a frame creates a portal.
|
||||
/// BaseFireBlock.onPlace: lighting fire inside a frame creates a portal,
|
||||
/// only in the Overworld and the Nether (`inPortalDimension`).
|
||||
fn try_light_portal(&mut self, at: [i32; 3]) -> Result<bool> {
|
||||
if !matches!(self.dim, Dim::Overworld | Dim::Nether) {
|
||||
return Ok(false);
|
||||
}
|
||||
for x_axis in [true, false] {
|
||||
if let Some((origin, width, height)) = self.portal_shape(at, x_axis) {
|
||||
let d = if x_axis { [1, 0, 0] } else { [0, 0, 1] };
|
||||
@@ -8768,6 +8837,27 @@ impl Game<'_> {
|
||||
self.give_orb_points(gained);
|
||||
}
|
||||
}
|
||||
/// `FurnaceResultSlot.checkTakeAchievements`: the player took from the output of the
|
||||
/// furnace at `pos`, which pops the experience of what it made at them
|
||||
/// (`awardUsedRecipesAndPopExperience`) and forgets it.
|
||||
pub(super) fn award_furnace_recipes(&mut self, pos: (i32, i32, i32), at: [f64; 3]) {
|
||||
let used = match self.world.block_entities.get_mut(&pos) {
|
||||
Some(BlockEntity::Furnace(f)) => {
|
||||
let used = f.recipe_experience(self.res.recipes());
|
||||
f.recipes_used.clear();
|
||||
used
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
for (count, experience) in used {
|
||||
let xp = horizon_client::containers::create_experience(
|
||||
count,
|
||||
experience,
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.spawn_orbs(at, xp);
|
||||
}
|
||||
}
|
||||
/// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up
|
||||
/// sound every fifth level (at most every 100 ticks).
|
||||
pub(super) fn give_orb_points(&mut self, gained: u32) {
|
||||
@@ -9411,6 +9501,11 @@ impl Game<'_> {
|
||||
fn creative_origin(batch: &HudBatch) -> (f32, f32) {
|
||||
batch.centered_origin(Creative::WIDTH, Creative::HEIGHT)
|
||||
}
|
||||
/// A creative tab's title: `itemGroup.*`, else the export's English title.
|
||||
fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
|
||||
horizon_client::creative::item_group_key(&tab.id)
|
||||
.map_or_else(|| tab.title.clone(), |key| self.translate(key, &tab.title))
|
||||
}
|
||||
/// Vanilla tooltip: dark box with a purple gradient border.
|
||||
fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -9480,9 +9575,10 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if !tab.inventory {
|
||||
let title = self.creative_tab_title(&tab);
|
||||
batch.text(
|
||||
atlas,
|
||||
&tab.title,
|
||||
&title,
|
||||
ox + 8.0,
|
||||
oy + 6.0,
|
||||
rgba(64, 64, 64, 255),
|
||||
@@ -9593,7 +9689,12 @@ impl Game<'_> {
|
||||
&& self.cursor.1 >= oy + ty
|
||||
&& self.cursor.1 < oy + ty + 32.0
|
||||
{
|
||||
self.tooltip(batch, &t.title, self.cursor.0, self.cursor.1);
|
||||
self.tooltip(
|
||||
batch,
|
||||
&self.creative_tab_title(t),
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_carried(batch);
|
||||
@@ -10363,6 +10464,7 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::Chat => self.close_chat(),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::Mod => self.close_mod_screen(true),
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -11009,6 +11111,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Options && d != 0.0 {
|
||||
self.options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Mod && d != 0.0 {
|
||||
self.mod_screen_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Creative && d != 0.0 {
|
||||
self.creative_screen.wheel(f64::from(d), &self.pack.names);
|
||||
}
|
||||
@@ -11231,6 +11336,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
self.stats_click(size);
|
||||
}
|
||||
Screen::Mod if pressed && button == MouseButton::Left => {
|
||||
self.mod_screen_click()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -11272,9 +11380,10 @@ impl ApplicationHandler for Game<'_> {
|
||||
// The hovered slot took the key (`ignoreTextInput`).
|
||||
} else if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.creative_search_shortcut(shortcut);
|
||||
} else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) {
|
||||
self.creative_search_backspace(self.has_control_down());
|
||||
} else {
|
||||
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
|
||||
self.creative_search_edit(event.text.as_deref(), backspace);
|
||||
self.creative_search_edit(event.text.as_deref());
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
@@ -11709,7 +11818,11 @@ impl<'a> Game<'a> {
|
||||
let inventory = vec![None; 36];
|
||||
let now = Instant::now();
|
||||
let look = options.look;
|
||||
let particles = horizon_client::particles::ParticleEngine::new(&root, &gui.atlas.sprites);
|
||||
let particles = horizon_client::particles::ParticleEngine::new(
|
||||
&root,
|
||||
&gui.atlas.sprites,
|
||||
&gui.atlas.translucent,
|
||||
);
|
||||
Ok(Game {
|
||||
res: res.clone(),
|
||||
rest_loader: None,
|
||||
@@ -11746,6 +11859,7 @@ impl<'a> Game<'a> {
|
||||
carried: None,
|
||||
craft_grid: vec![None; 9],
|
||||
craft_size: 2,
|
||||
craft_table: None,
|
||||
inv_drag: None,
|
||||
tweaks: Default::default(),
|
||||
inv_clicks: Default::default(),
|
||||
@@ -12001,6 +12115,7 @@ impl<'a> Game<'a> {
|
||||
cmds: Default::default(),
|
||||
ui: Default::default(),
|
||||
combat: Default::default(),
|
||||
guest_combat: Default::default(),
|
||||
mount: Default::default(),
|
||||
stats: Default::default(),
|
||||
score: Default::default(),
|
||||
|
||||
@@ -416,6 +416,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> {
|
||||
@@ -1599,8 +1600,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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,7 +18,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 +99,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<'_> {
|
||||
@@ -586,7 +588,7 @@ 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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -214,7 +214,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 +444,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
|
||||
@@ -2809,7 +2809,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 +2819,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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -569,7 +569,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(
|
||||
@@ -799,6 +799,7 @@ impl Game<'_> {
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,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 +370,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() })
|
||||
@@ -1558,6 +1562,16 @@ impl Game<'_> {
|
||||
id, ticks, amplifier,
|
||||
));
|
||||
}
|
||||
/// `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);
|
||||
}
|
||||
/// Burning applied to the player by mobs.
|
||||
fn tick_player_effects(&mut self) {
|
||||
self.creatures.wind_cooldown = self.creatures.wind_cooldown.saturating_sub(1);
|
||||
@@ -1694,7 +1708,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
|
||||
|
||||
@@ -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)
|
||||
@@ -2634,8 +2642,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 +2659,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::{
|
||||
@@ -1122,6 +1127,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 {
|
||||
|
||||
@@ -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)> = {
|
||||
|
||||
@@ -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",
|
||||
@@ -1193,7 +1220,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);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,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>,
|
||||
@@ -143,6 +144,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.
|
||||
@@ -256,6 +259,14 @@ impl Game<'_> {
|
||||
host.play.insert(conn, play);
|
||||
return None;
|
||||
};
|
||||
// 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 +275,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 +320,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());
|
||||
@@ -390,6 +404,7 @@ impl Game<'_> {
|
||||
host_command_block,
|
||||
host_mount_menu,
|
||||
craft_size,
|
||||
craft_table,
|
||||
craft_grid,
|
||||
container,
|
||||
mining,
|
||||
@@ -418,6 +433,7 @@ impl Game<'_> {
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
combat: host_combat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,6 +461,7 @@ impl Game<'_> {
|
||||
host_command_block,
|
||||
host_mount_menu,
|
||||
craft_size,
|
||||
craft_table,
|
||||
craft_grid,
|
||||
container,
|
||||
mining,
|
||||
@@ -468,12 +485,16 @@ impl Game<'_> {
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
death_cause,
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
} = swap;
|
||||
// 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) {
|
||||
@@ -495,7 +516,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);
|
||||
@@ -671,8 +695,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 +836,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 {
|
||||
@@ -1075,7 +1124,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 +1278,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 +1641,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 +1683,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 +1720,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 +1756,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,7 +1772,11 @@ 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();
|
||||
@@ -1792,31 +1874,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 +1917,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 +2042,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 +2106,18 @@ 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);
|
||||
// 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,10 +2141,14 @@ 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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,17 @@ 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
|
||||
let title = self
|
||||
.remote_menu_title()
|
||||
.unwrap_or_else(|| self.container_title(title.to_owned()));
|
||||
.unwrap_or_else(|| self.container_title(self.translate(key, fallback)));
|
||||
let title_x = if screen == Screen::Hopper {
|
||||
8.0
|
||||
} else {
|
||||
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, title)) / 2.0
|
||||
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, &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(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -259,7 +259,7 @@ impl Game<'_> {
|
||||
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
|
||||
for (item, off_hand) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
|
||||
let Some(item) = item else { continue };
|
||||
let left = off_hand != mob.extra.main_hand_left;
|
||||
let left = off_hand != mob.main_arm_left();
|
||||
let Some(t) =
|
||||
render::held_item_transform(mob, alpha as f32, camera, self.res.mob_data(), left)
|
||||
else {
|
||||
@@ -305,7 +305,7 @@ impl Game<'_> {
|
||||
.player_model
|
||||
.as_ref()
|
||||
.is_some_and(|p| std::ptr::eq(p, mob));
|
||||
owner.main_arm_left = mob.extra.main_hand_left;
|
||||
owner.main_arm_left = mob.main_arm_left();
|
||||
if off_hand == mob.extra.using_off_hand && mob.extra.using_item > 0 {
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = 72_000;
|
||||
|
||||
@@ -20,10 +20,13 @@ use {
|
||||
horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
items::ItemStack,
|
||||
mod_list::Installed,
|
||||
options_ui::Page,
|
||||
text::{self, Text},
|
||||
ui,
|
||||
},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
|
||||
std::time::Instant,
|
||||
};
|
||||
|
||||
@@ -39,6 +42,31 @@ pub(super) struct ModsState {
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
/// `command.run` lines for this window's own world, run on the next tick:
|
||||
/// run at once, their chat lines would reach the mods while their output
|
||||
/// is still being shown, and a mod answering its own lines would recurse.
|
||||
#[cfg(feature = "mods")]
|
||||
commands: Vec<String>,
|
||||
/// The server's command tree with the running mods' commands added.
|
||||
#[cfg(feature = "mods")]
|
||||
remote_tree: Option<Dispatcher>,
|
||||
/// A `ui` mod's screen or settings page while it is up.
|
||||
#[cfg(feature = "mods")]
|
||||
screen: Option<ModScreen>,
|
||||
}
|
||||
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods: the
|
||||
/// widgets the mod pushed, laid out and drawn with the menus' own widgets.
|
||||
#[cfg(feature = "mods")]
|
||||
struct ModScreen {
|
||||
/// The running mod's index in `mods.mods.entries()`.
|
||||
owner: usize,
|
||||
/// Opened from Options > Mods: Done and Esc return there.
|
||||
settings: bool,
|
||||
/// What the mod's screen replaced (Playing, Chat, ...), to restore.
|
||||
under: Screen,
|
||||
/// One scroll per `list` widget, in widget order.
|
||||
scrolls: Vec<ui::Scroll>,
|
||||
}
|
||||
impl ModsState {
|
||||
/// The chat's command tree: vanilla's, plus the running mods' commands.
|
||||
@@ -49,6 +77,14 @@ impl ModsState {
|
||||
}
|
||||
vanilla::game_dispatcher()
|
||||
}
|
||||
/// The server's tree plus the mods' commands, when a mod owns any.
|
||||
pub(super) fn remote_dispatcher(&self) -> Option<&Dispatcher> {
|
||||
#[cfg(feature = "mods")]
|
||||
if let Some(tree) = &self.remote_tree {
|
||||
return Some(tree);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether a mod runs: the chat keeps its lines for them.
|
||||
pub(super) fn running(&self) -> bool {
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -158,6 +194,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn merge_remote_tree(&mut self) {}
|
||||
pub(super) fn sync_mods(&mut self) {}
|
||||
pub(super) fn tick_mods(&mut self) {}
|
||||
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
|
||||
@@ -166,6 +203,11 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn mods_chat(&mut self, _line: &str) {}
|
||||
pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {}
|
||||
pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {}
|
||||
pub(super) fn close_mod_screen(&mut self, _tell: bool) {}
|
||||
pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {}
|
||||
pub(super) fn mod_screen_click(&mut self) {}
|
||||
pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -187,6 +229,8 @@ impl Game<'_> {
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load(&paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
// Entries were replaced wholesale: a screen's owner index is stale.
|
||||
self.mods.screen = None;
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
self.show_mod_output();
|
||||
@@ -212,6 +256,7 @@ impl Game<'_> {
|
||||
description: String::new(),
|
||||
problem,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
};
|
||||
};
|
||||
// What it may do first: the row cuts long descriptions.
|
||||
@@ -227,6 +272,7 @@ impl Game<'_> {
|
||||
description: format!("{may} {}", m.description).trim().to_owned(),
|
||||
problem,
|
||||
dev: dev.contains(&e.path),
|
||||
has_settings: e.has_ui_settings(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -238,7 +284,20 @@ impl Game<'_> {
|
||||
.peek()
|
||||
.is_some()
|
||||
.then(|| vanilla::game_dispatcher().with_mod_commands(names));
|
||||
self.merge_remote_tree();
|
||||
}
|
||||
|
||||
/// The server's command tree with the mods' commands, for the chat.
|
||||
pub(super) fn merge_remote_tree(&mut self) {
|
||||
let names: Vec<String> = self.mods.mods.commands().map(str::to_owned).collect();
|
||||
self.mods.remote_tree = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.commands.as_ref())
|
||||
.filter(|_| !names.is_empty())
|
||||
.map(|t| t.with_mod_commands(names.iter().map(String::as_str)));
|
||||
}
|
||||
|
||||
/// After the options changed: restart the mods if the list of the ones
|
||||
/// turned on did.
|
||||
pub(super) fn sync_mods(&mut self) {
|
||||
@@ -283,6 +342,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// A game tick.
|
||||
pub(super) fn tick_mods(&mut self) {
|
||||
// Here, at the player's level: shown inside a console call (a control
|
||||
// socket command set the hook off), they would run as the console.
|
||||
for line in std::mem::take(&mut self.mods.commands) {
|
||||
self.run_command(&line);
|
||||
}
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
@@ -342,11 +406,36 @@ impl Game<'_> {
|
||||
}
|
||||
Output::Feedback { text, error: false } => Text::literal(text),
|
||||
Output::Feedback { text, error: true } => Text::literal(text).with_color(text::RED),
|
||||
// Vanilla's commands only: a mod running a mod command could loop.
|
||||
Output::Command { line } => {
|
||||
let name = line.split(' ').next().unwrap_or_default();
|
||||
if !vanilla_command(name) {
|
||||
// Dropped.
|
||||
} else if self.remote.is_some() {
|
||||
// The server checks the player's permissions.
|
||||
self.send_chat(&format!("/{line}"));
|
||||
} else {
|
||||
self.mods.commands.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Output::Stopped { name, reason } => {
|
||||
// Its screen goes with it: the entry's run is gone, so
|
||||
// `mod_ui_view` finds no widgets and the screen closes.
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
|
||||
}
|
||||
Output::OpenScreen { owner } => {
|
||||
self.open_mod_screen(owner, false);
|
||||
continue;
|
||||
}
|
||||
Output::CloseScreen { owner } => {
|
||||
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
|
||||
self.close_mod_screen(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Not through system_message: a mod must not hear its own lines.
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -354,6 +443,12 @@ impl Game<'_> {
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
}
|
||||
// A mod may set or clear its settings page in any call, not only
|
||||
// while loading: its row's Settings button follows.
|
||||
let rows = self.settings.mods.installed.iter_mut();
|
||||
for (row, e) in rows.zip(self.mods.mods.entries()) {
|
||||
row.has_settings = e.has_ui_settings();
|
||||
}
|
||||
}
|
||||
/// The mods' HUD panels, stacked with the extras in their corners.
|
||||
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
@@ -376,4 +471,297 @@ impl Game<'_> {
|
||||
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ui` screen's title and widgets as the mod last pushed them; None
|
||||
/// when the surface is gone (the mod stopped or closed it).
|
||||
fn mod_ui_view(&self) -> Option<(String, Vec<UiWidget>)> {
|
||||
let ms = self.mods.screen.as_ref()?;
|
||||
if ms.settings {
|
||||
let widgets = self.mods.mods.settings_widgets(ms.owner);
|
||||
if widgets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self
|
||||
.mods
|
||||
.mods
|
||||
.entries()
|
||||
.get(ms.owner)
|
||||
.and_then(|e| e.manifest.as_ref())
|
||||
.map_or("Mod", |m| m.name.as_str());
|
||||
Some((name.to_string(), widgets.to_vec()))
|
||||
} else {
|
||||
let s: &UiScreen = self.mods.mods.screen(ms.owner)?;
|
||||
Some((s.title.clone(), s.widgets.clone()))
|
||||
}
|
||||
}
|
||||
/// The layout the mod screen's widgets get at `h`: (widget, top,
|
||||
/// height), the column being `COLUMN` wide down the middle.
|
||||
fn mod_ui_layout(&self, h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
|
||||
let mut out = Vec::with_capacity(widgets.len());
|
||||
let mut y = MOD_UI_TOP;
|
||||
for w in widgets {
|
||||
let wh = match w {
|
||||
UiWidget::Text(_) => 12.0,
|
||||
UiWidget::Item(_) => 20.0,
|
||||
UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0,
|
||||
};
|
||||
// Nothing may cover Done; a crowded mod loses what won't fit.
|
||||
if y + wh > h - 34.0 {
|
||||
break;
|
||||
}
|
||||
out.push((y, wh));
|
||||
y += wh + 6.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
/// A mod pushes its screen (`ui.open-screen`), or Options > Mods opens
|
||||
/// its settings page (`settings`, the mod's `installed` index).
|
||||
pub(super) fn open_mod_screen(&mut self, owner: usize, settings: bool) {
|
||||
if self.title.is_some() {
|
||||
return;
|
||||
}
|
||||
if settings {
|
||||
self.mods.mods.ui_settings_opened(owner);
|
||||
if self.mods.mods.settings_widgets(owner).is_empty() {
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.mods.screen = Some(ModScreen {
|
||||
owner,
|
||||
settings,
|
||||
under: self.screen,
|
||||
scrolls: Vec::new(),
|
||||
});
|
||||
self.screen = Screen::Mod;
|
||||
if self.player.is_some() {
|
||||
self.capture(false);
|
||||
}
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Leaves the mod screen: back to Options for a settings page, to what
|
||||
/// it covered for a mod's own. `tell` is Esc/Done, heard as `closed`.
|
||||
pub(super) fn close_mod_screen(&mut self, tell: bool) {
|
||||
let Some(ms) = self.mods.screen.take() else {
|
||||
return;
|
||||
};
|
||||
if ms.settings {
|
||||
self.open_options_at(self.options_from_title, Page::Mods);
|
||||
} else {
|
||||
self.screen = ms.under;
|
||||
if self.screen == Screen::Playing && self.player.is_some() {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
if tell {
|
||||
self.mods.mods.ui_closed(ms.owner);
|
||||
self.show_mod_output();
|
||||
}
|
||||
}
|
||||
/// The mod's screen or settings page: its title over widgets stacked
|
||||
/// down the middle, a Done button, Esc to leave (vanilla's menus).
|
||||
pub(super) fn draw_mod_screen(&mut self, batch: &mut HudBatch) {
|
||||
if self.mods.screen.is_none() {
|
||||
self.screen = Screen::Playing;
|
||||
return;
|
||||
}
|
||||
let Some((title, widgets)) = self.mod_ui_view() else {
|
||||
self.mods.screen = None;
|
||||
self.screen = Screen::Playing;
|
||||
return;
|
||||
};
|
||||
// Item icons draw in a second pass: `draw_item` needs `&mut self`,
|
||||
// so `atlas` may not stay borrowed across it.
|
||||
let mut items: Vec<(String, f32, f32)> = Vec::new();
|
||||
let atlas = &self.gui.atlas;
|
||||
let in_world = self.player.is_some();
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, batch.width, batch.height, in_world);
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(title),
|
||||
0.0,
|
||||
15.0,
|
||||
batch.width,
|
||||
u32::MAX,
|
||||
);
|
||||
let cx = (batch.width / 2.0).floor();
|
||||
let x = cx - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(batch.height, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
match w {
|
||||
UiWidget::Text(t) => {
|
||||
let color = if t.color == 0 {
|
||||
u32::MAX
|
||||
} else {
|
||||
t.color | 0xFF00_0000
|
||||
};
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(t.text.clone()),
|
||||
x,
|
||||
wy + 1.0,
|
||||
MOD_UI_COLUMN,
|
||||
color,
|
||||
);
|
||||
}
|
||||
UiWidget::Item(i) => {
|
||||
if self.res.items().get(&i.item).is_some() {
|
||||
items.push((i.item.clone(), x + 2.0, wy + 2.0));
|
||||
} else {
|
||||
batch.text(atlas, "?", x + 6.0, wy + 6.0, u32::MAX, true);
|
||||
}
|
||||
let name = if i.name.is_empty() {
|
||||
i.item.rsplit(':').next().unwrap_or(&i.item).to_string()
|
||||
} else {
|
||||
i.name.clone()
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(name),
|
||||
x + 22.0,
|
||||
wy + 6.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
UiWidget::List(l) => {
|
||||
let scroll = {
|
||||
let ms = self.mods.screen.as_mut().expect("checked");
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
ms.scrolls[list_i]
|
||||
};
|
||||
list_i += 1;
|
||||
let rows_top = wy + 4.0;
|
||||
let rows_bottom = wy + wh - 4.0;
|
||||
batch.fill(atlas, x, wy, MOD_UI_COLUMN, wh, 0x8000_0000);
|
||||
for (b, yy) in [(x, wy), (x, wy + wh - 1.0)] {
|
||||
batch.fill(atlas, b, yy, MOD_UI_COLUMN, 1.0, 0xFF50_5050);
|
||||
}
|
||||
for (xx, yy) in [(x, wy), (x + MOD_UI_COLUMN - 1.0, wy)] {
|
||||
batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050);
|
||||
}
|
||||
let content = l.rows.len() as f32 * 12.0;
|
||||
let first = (scroll.amount / 12.0) as usize;
|
||||
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1)
|
||||
.min(l.rows.len());
|
||||
for (vi, ri) in (first..last).enumerate() {
|
||||
let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0);
|
||||
if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
|
||||
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
|
||||
}
|
||||
if l.selected == Some(ri as u32) {
|
||||
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
|
||||
}
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(l.rows[ri].clone()),
|
||||
x + 6.0,
|
||||
ry + 2.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + MOD_UI_COLUMN - 5.0,
|
||||
rows_top,
|
||||
rows_bottom,
|
||||
content,
|
||||
scroll.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let hover = ui::hovered(self.cursor, cx - 100.0, batch.height - 27.0, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
cx - 100.0,
|
||||
batch.height - 27.0,
|
||||
200.0,
|
||||
20.0,
|
||||
&Text::literal("Done"),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
for (id, ix, iy) in items {
|
||||
let stack = ItemStack::new(id.as_str(), 1);
|
||||
self.draw_item(batch, &stack, ix, iy);
|
||||
}
|
||||
}
|
||||
/// A click on the mod screen: Done leaves it, a `list` row is the mod's
|
||||
/// `ui-events::select`.
|
||||
pub(super) fn mod_screen_click(&mut self) {
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.close_mod_screen(true);
|
||||
return;
|
||||
}
|
||||
let Some((_, widgets)) = self.mod_ui_view() else {
|
||||
return;
|
||||
};
|
||||
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(h, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
let UiWidget::List(l) = w else { continue };
|
||||
let scroll = self
|
||||
.mods
|
||||
.screen
|
||||
.as_ref()
|
||||
.and_then(|ms| ms.scrolls.get(list_i).copied())
|
||||
.unwrap_or_default();
|
||||
list_i += 1;
|
||||
let row = ((cursor.1 - (wy + 4.0) + scroll.amount) / 12.0) as i64;
|
||||
if ui::hovered(cursor, x + 1.0, wy + 1.0, MOD_UI_COLUMN - 7.0, wh - 2.0)
|
||||
&& row >= 0
|
||||
&& (row as usize) < l.rows.len()
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
self.mods.mods.ui_select(
|
||||
self.mods.screen.as_ref().map_or(0, |ms| ms.owner),
|
||||
l.id,
|
||||
row as u32,
|
||||
);
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The wheel over a `list` scrolls it.
|
||||
pub(super) fn mod_screen_scroll(&mut self, delta: f32) {
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
let Some((_, widgets)) = self.mod_ui_view() else {
|
||||
return;
|
||||
};
|
||||
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(h, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
let UiWidget::List(l) = w else { continue };
|
||||
let over = ui::hovered(cursor, x, wy, MOD_UI_COLUMN, wh);
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
if over {
|
||||
let content = l.rows.len() as f32 * 12.0;
|
||||
ms.scrolls[list_i].wheel(delta, 12.0, wy + 4.0, wy + wh - 4.0, content);
|
||||
return;
|
||||
}
|
||||
list_i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_TOP: f32 = 40.0;
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_COLUMN: f32 = 200.0;
|
||||
@@ -470,7 +470,8 @@ impl Game<'_> {
|
||||
None => String::new(),
|
||||
};
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, 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);
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == open.mob) {
|
||||
self.draw_mob_preview(
|
||||
batch,
|
||||
|
||||
@@ -151,10 +151,13 @@ impl Game<'_> {
|
||||
.clone()
|
||||
.unwrap_or_else(|| Text::literal(&name))
|
||||
};
|
||||
// StringDecomposer.iterateFormatted: `§` codes a server leaves
|
||||
// inside the team prefix or the name itself apply at draw time.
|
||||
let shown = match team {
|
||||
Some(t) => t.formatted_name(member),
|
||||
None => member,
|
||||
};
|
||||
}
|
||||
.resolve_legacy();
|
||||
let mut lines = Vec::new();
|
||||
// EntityRenderer.extractNameTags: the score shows within the
|
||||
// below-name distance (10), under the name.
|
||||
@@ -162,7 +165,7 @@ impl Game<'_> {
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score, 0.0));
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
lines.push((shown, lift));
|
||||
@@ -213,14 +216,15 @@ impl Game<'_> {
|
||||
let shown = match team {
|
||||
Some(t) => t.formatted_name(name.clone()),
|
||||
None => name.clone(),
|
||||
};
|
||||
}
|
||||
.resolve_legacy();
|
||||
let mut lines = Vec::new();
|
||||
let mut lift = 0.0;
|
||||
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score, 0.0));
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
lines.push((shown, lift));
|
||||
|
||||
@@ -237,6 +237,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
}
|
||||
@@ -246,6 +247,7 @@ impl Game<'_> {
|
||||
pub(super) fn load_language(&mut self, code: &str) {
|
||||
let root = self.options.resources.clone();
|
||||
lang::set(lang::Language::load(&[&root], code));
|
||||
self.refresh_remote_language();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
}
|
||||
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
|
||||
|
||||
@@ -203,6 +203,7 @@ impl Game<'_> {
|
||||
horizon_client::mobs::villager_hats::VillagerHats::load(root);
|
||||
horizon_client::ui::load_metadata(root);
|
||||
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
|
||||
self.refresh_remote_language();
|
||||
self.audio.reload(root);
|
||||
self.clear_lod();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
|
||||
@@ -921,8 +921,10 @@ impl Game<'_> {
|
||||
} else if self.book.is_some() {
|
||||
self.book_input(key, text);
|
||||
} else if self.screen == Screen::Creative && self.creative_screen.tab().search {
|
||||
if key != Some(KeyCode::Enter) {
|
||||
self.creative_search_edit(text, backspace);
|
||||
if backspace {
|
||||
self.creative_search_backspace(false);
|
||||
} else if key != Some(KeyCode::Enter) {
|
||||
self.creative_search_edit(text);
|
||||
}
|
||||
} else if self.recipe_search_focused() {
|
||||
if key != Some(KeyCode::Enter) {
|
||||
|
||||
@@ -147,8 +147,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Species::Strider if !baby => self.spawn_strider_jockey(id, at),
|
||||
// ZombieHorse.finalizeSpawn: a zombie with an iron spear.
|
||||
Species::ZombieHorse if natural => {
|
||||
// ZombieHorse.finalizeSpawn: a zombie with an iron spear. The rider
|
||||
// comes from EntityType.create, which makes no zombie in peaceful
|
||||
// (canSpawn), so a peaceful zombie horse spawns without one.
|
||||
Species::ZombieHorse
|
||||
if natural
|
||||
&& (self.ui.difficulty != 0
|
||||
|| horizon_client::mobs::spawn_rules::allowed_in_peaceful(
|
||||
Species::Zombie,
|
||||
)) =>
|
||||
{
|
||||
if let Some(zombie) = self.spawn_mob("minecraft:zombie", at, false) {
|
||||
if let Some(z) = self.mobs.iter_mut().find(|m| m.id == zombie) {
|
||||
z.extra.main_hand = Some("minecraft:iron_spear".into());
|
||||
|
||||
@@ -76,15 +76,14 @@ impl Game<'_> {
|
||||
let pos = ChunkPos::new(chunk.x, chunk.z);
|
||||
let time = self.world.game_time;
|
||||
let mut extras: ChunkExtras = chunk.saved.as_deref().cloned().unwrap_or_default();
|
||||
// A chunk the scheduler hasn't registered yet (one loaded and
|
||||
// unloaded again before the next tick) still holds its saved ticks:
|
||||
// `register_chunk_ticks` empties that list once it takes them.
|
||||
if let Some(t) = self.block_ticks.blocks.chunk(pos) {
|
||||
extras.block_ticks = t.pack(time);
|
||||
} else {
|
||||
extras.block_ticks.clear();
|
||||
}
|
||||
if let Some(t) = self.block_ticks.fluids.chunk(pos) {
|
||||
extras.fluid_ticks = t.pack(time);
|
||||
} else {
|
||||
extras.fluid_ticks.clear();
|
||||
}
|
||||
extras.last_update = time;
|
||||
// TheEndGatewayBlockEntity saves its Age (the server copy's).
|
||||
|
||||
@@ -27,6 +27,11 @@ impl Game<'_> {
|
||||
if ready.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// The ticks the marks schedule go to the chunks' containers, which a
|
||||
// chunk that arrived since the last tick doesn't have yet
|
||||
// (`LevelChunk.registerTickContainerInLevel` comes before
|
||||
// `postProcessGeneration`).
|
||||
self.sync_tick_chunks();
|
||||
for key in ready {
|
||||
let Some(chunk) = self.world.chunks.get_mut(&key) else {
|
||||
continue;
|
||||
|
||||
@@ -755,7 +755,7 @@ impl Game<'_> {
|
||||
image_width,
|
||||
&self.potions.effects.list,
|
||||
self.clock.rate(),
|
||||
&|key| self.pack.names.get(key).map(str::to_owned),
|
||||
&|key| self.pack.names.display_get(key),
|
||||
&|text| self.gui.atlas.font.width(text).ceil() as i32,
|
||||
);
|
||||
if let Some(lines) = inventory_effects::paint(
|
||||
|
||||
@@ -502,7 +502,7 @@ impl Game<'_> {
|
||||
horizon_client::item_tooltip::lines(
|
||||
stack,
|
||||
self.res.items().get(&stack.item),
|
||||
&|k| self.pack.names.get(k).map(str::to_owned),
|
||||
&|k| self.pack.names.display_get(k),
|
||||
)
|
||||
})
|
||||
{
|
||||
@@ -1163,8 +1163,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// Paints recipe book calls; returns the first tooltip set (vanilla keeps
|
||||
/// the first one a frame asks for).
|
||||
fn paint_book(&mut self, batch: &mut HudBatch, calls: &[Drawn]) -> Option<Vec<(String, u32)>> {
|
||||
let mut tooltip: Option<Vec<(String, u32)>> = None;
|
||||
fn paint_book(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
calls: &[Drawn],
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
for d in calls {
|
||||
let start = batch.vertices.len();
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -1259,7 +1263,14 @@ impl Game<'_> {
|
||||
tooltip = Some(
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| (l.iter().map(|r| r.text.as_str()).collect(), u32::MAX))
|
||||
.map(|l| {
|
||||
(
|
||||
horizon_client::text::Text::literal(
|
||||
l.iter().map(|r| r.text.as_str()).collect::<String>(),
|
||||
),
|
||||
u32::MAX,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
@@ -1269,10 +1280,10 @@ impl Game<'_> {
|
||||
let mut lines = self.stack_tooltip(stack);
|
||||
if *more {
|
||||
lines.push((
|
||||
self.translate(
|
||||
horizon_client::text::Text::literal(self.translate(
|
||||
"gui.recipebook.moreRecipes",
|
||||
"Right Click for More",
|
||||
),
|
||||
)),
|
||||
u32::MAX,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -223,30 +223,6 @@ fn face_direction(face: [i32; 3]) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `minecraft:profile` component as the item data JSON the model reads.
|
||||
fn profile_tag(p: &horizon_protocol::item::Profile) -> Tag {
|
||||
let properties = p
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(name, value, signature)| {
|
||||
let mut entries: Vec<(&'static str, Tag)> = vec![
|
||||
("name", Tag::String(name.as_str().into())),
|
||||
("value", Tag::String(value.as_str().into())),
|
||||
];
|
||||
if let Some(signature) = signature {
|
||||
entries.push(("signature", Tag::String(signature.as_str().into())));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", Tag::String(name.as_str().into())));
|
||||
}
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
Tag::compound(entries)
|
||||
}
|
||||
|
||||
/// A protocol number format as the scoreboard's.
|
||||
fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFormat {
|
||||
use horizon_client::scoreboard::{NumberFormat, tag_to_json};
|
||||
@@ -610,164 +586,32 @@ impl Game<'_> {
|
||||
.and_then(|i| i.id(name))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// A stack from the server, decoded as the saved and command item NBT
|
||||
/// are, so tooltips and models see every field.
|
||||
pub(super) fn to_stack(&self, s: &Option<Stack>) -> Option<ItemStack> {
|
||||
let s = s.as_ref()?;
|
||||
let mut out = ItemStack {
|
||||
item: self.item_name(s.item),
|
||||
count: s.count.max(0) as u32,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
// A vanilla `components` compound, then the same decoder the saved
|
||||
// and command item NBT use, so tooltips and models see every field.
|
||||
let components = self.protocol_components(s);
|
||||
let (damage, data) = horizon_client::item_data::decode_components(components.as_ref());
|
||||
out.damage = damage;
|
||||
if let Some(data) = data {
|
||||
*out.data_mut() = *data;
|
||||
}
|
||||
out.tidy();
|
||||
Some(out)
|
||||
}
|
||||
/// The item's patched components as vanilla's `components` compound.
|
||||
fn protocol_components(&self, s: &Stack) -> Option<Tag> {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
let r = self.remote.as_ref()?;
|
||||
let name = |id: i32| r.builtin.get("minecraft:data_component_type")?.name(id);
|
||||
let mut entries: Vec<(&str, Tag)> = Vec::new();
|
||||
for (kind, c) in &s.added {
|
||||
let Some(key) = name(*kind) else {
|
||||
continue;
|
||||
};
|
||||
let value = match c {
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
// Trim materials and patterns are synced registries, as enchantments are.
|
||||
Component::Trim { material, pattern } => {
|
||||
let (Some(material), Some(pattern)) = (
|
||||
r.registries.name("minecraft:trim_material", *material),
|
||||
r.registries.name("minecraft:trim_pattern", *pattern),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
Tag::compound([
|
||||
("material", Tag::String(material.into())),
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
}
|
||||
Component::Int(v) => Tag::Int(*v),
|
||||
Component::Unit => Tag::Compound(Default::default()),
|
||||
Component::Bool(v) => Tag::Byte(i8::from(*v)),
|
||||
Component::Text(t) | Component::Nbt(t) => t.clone(),
|
||||
Component::Ident(v) => Tag::String(v.as_str().into()),
|
||||
Component::Lore(lines) => Tag::List(lines.clone()),
|
||||
Component::WritableBook(pages) => Tag::compound([(
|
||||
"pages",
|
||||
Tag::List(
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| Tag::String(p.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
)]),
|
||||
Component::WrittenBook(b) => Tag::compound([
|
||||
("title", Tag::String(b.title.as_str().into())),
|
||||
("author", Tag::String(b.author.as_str().into())),
|
||||
("generation", Tag::Int(b.generation)),
|
||||
("pages", Tag::List(b.pages.clone())),
|
||||
("resolved", Tag::Byte(i8::from(b.resolved))),
|
||||
]),
|
||||
Component::Enchantments(list) => {
|
||||
// Enchantments are a synced (data-driven) registry: the
|
||||
// ids index the server's RegistryData, not the builtin
|
||||
// report, so a datapack enchantment names correctly.
|
||||
let levels = list.iter().filter_map(|(id, level)| {
|
||||
let name = r.registries.name("minecraft:enchantment", *id)?;
|
||||
Some((name, Tag::Int(*level)))
|
||||
});
|
||||
Tag::compound(levels)
|
||||
}
|
||||
Component::AttackRange(v) => Tag::compound([
|
||||
("min_reach", Tag::Float(v[0].to_bits())),
|
||||
("max_reach", Tag::Float(v[1].to_bits())),
|
||||
("min_attack_momentum", Tag::Float(v[2].to_bits())),
|
||||
("max_attack_momentum", Tag::Float(v[3].to_bits())),
|
||||
("hitbox_margin", Tag::Float(v[4].to_bits())),
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// ItemStackTemplate.CODEC: id, count and components.
|
||||
Component::Stacks(stacks) => Tag::List(
|
||||
stacks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut entries = vec![
|
||||
("id", Tag::String(self.item_name(t.item).as_ref().into())),
|
||||
("count", Tag::Int(t.count)),
|
||||
];
|
||||
if let Some(c) = self.protocol_components(t) {
|
||||
entries.push(("components", c));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Component::CustomModelData {
|
||||
floats,
|
||||
flags,
|
||||
strings,
|
||||
colors,
|
||||
} => Tag::compound([
|
||||
(
|
||||
"floats",
|
||||
Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()),
|
||||
),
|
||||
(
|
||||
"flags",
|
||||
Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()),
|
||||
),
|
||||
(
|
||||
"strings",
|
||||
Tag::List(
|
||||
strings
|
||||
.iter()
|
||||
.map(|s| Tag::String(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"colors",
|
||||
Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()),
|
||||
),
|
||||
]),
|
||||
Component::TooltipDisplay { hide, hidden } => {
|
||||
let hidden = hidden
|
||||
.iter()
|
||||
.filter_map(|id| Some(Tag::String(name(*id)?.into())));
|
||||
Tag::compound([
|
||||
("hide_tooltip", Tag::Byte(i8::from(*hide))),
|
||||
("hidden_components", Tag::List(hidden.collect())),
|
||||
])
|
||||
}
|
||||
};
|
||||
entries.push((key, value));
|
||||
}
|
||||
Some(Tag::compound(entries))
|
||||
Some(horizon_client::item_sync::from_network(
|
||||
s.as_ref()?,
|
||||
&r.builtin,
|
||||
&r.registries,
|
||||
))
|
||||
}
|
||||
/// A stack to the server (`SetCreativeModeSlot`): the same component
|
||||
/// patch a Horizon server sends its clients, with the registries' ids, so
|
||||
/// the server's echo doesn't hand back a bare item.
|
||||
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
|
||||
let s = s.as_ref()?;
|
||||
let mut stack = Stack::new(self.item_id(&s.item), s.count as i32);
|
||||
if s.damage > 0 {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::DAMAGE,
|
||||
horizon_protocol::item::Component::Int(s.damage as i32),
|
||||
));
|
||||
}
|
||||
Some(stack)
|
||||
let r = self.remote.as_ref()?;
|
||||
horizon_client::lan::guest::stack_to_network(
|
||||
|name| self.item_id(name),
|
||||
// Built-in first, as a hub's `SyncData::id` does: potions are one.
|
||||
|registry, name| {
|
||||
r.builtin
|
||||
.get(registry)
|
||||
.and_then(|ids| ids.id(name))
|
||||
.or_else(|| r.registries.id(registry, name))
|
||||
},
|
||||
s,
|
||||
)
|
||||
}
|
||||
/// A line in the connection's chat, with its styles, click and hover
|
||||
/// events (ChatComponent.addMessage).
|
||||
@@ -1576,10 +1420,10 @@ impl Game<'_> {
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
// The vehicle the player rode is gone (Entity.removeVehicle).
|
||||
if self.riding == Some(id as u32) {
|
||||
self.riding = None;
|
||||
}
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
self.remote_set_passengers(id, &[]);
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.dropped.retain(|d| d.id != id as u32);
|
||||
@@ -2046,15 +1890,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
if let (15, Value::Byte(flags)) = (index, value) {
|
||||
m.no_ai = flags & 1 != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
@@ -2069,25 +1904,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS):
|
||||
// another player starts using the item in a hand (eating, a
|
||||
// bow drawn, a shield up), or stops.
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
|
||||
// too, remote_mob_data's apply reads index 8 above.
|
||||
let other = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id) && r.entity_id != p.id);
|
||||
if other && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (8, Value::Byte(flags)) = (index, value) {
|
||||
m.extra.using_off_hand = flags & 2 != 0;
|
||||
if flags & 1 == 0 {
|
||||
m.extra.using_item = 0;
|
||||
} else if m.extra.using_item == 0 {
|
||||
m.extra.using_item = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another
|
||||
// player lies down in their bed (drawn lying in it), or gets up.
|
||||
if other {
|
||||
@@ -2337,12 +2159,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::PlayerCombatKill(p) => {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
let message = plain(&p.message, &lang);
|
||||
// The component resolves in the client's current language, as
|
||||
// the chat line does, not in the language the session joined
|
||||
// with (`Remote.lang` is the vanilla server-language table).
|
||||
let message = remote_text(&p.message).plain();
|
||||
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = Some(message);
|
||||
@@ -2545,6 +2365,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.commands = Some(tree);
|
||||
}
|
||||
self.merge_remote_tree();
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
// The component as sent, translated in the client's own
|
||||
@@ -3239,6 +3060,7 @@ impl Game<'_> {
|
||||
self.tick_block_entity_anims();
|
||||
self.tick_hand();
|
||||
self.tick_particles();
|
||||
self.tick_client_ambient();
|
||||
self.tick_client_effects();
|
||||
// Player.tick's sleep counter (the darkening and the wake-up fade).
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
@@ -3572,25 +3394,28 @@ impl Game<'_> {
|
||||
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
|
||||
*steps -= 1;
|
||||
}
|
||||
// LivingEntity.updatingUsingItem for another player: the ticks the
|
||||
// item has been used before this one.
|
||||
if player && m.extra.using_item > 0 {
|
||||
let item = if m.extra.using_off_hand {
|
||||
m.extra.off_hand.clone()
|
||||
} else {
|
||||
m.extra.main_hand.clone()
|
||||
};
|
||||
if let Some(item) = item {
|
||||
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
using.push((
|
||||
item,
|
||||
m.extra.using_item as u32 - 1,
|
||||
at,
|
||||
eye,
|
||||
m.head_pitch,
|
||||
m.yaw,
|
||||
));
|
||||
// LivingEntity.updatingUsingItem: the ticks the item has been
|
||||
// used before this one. Another player's use particles; a mob's
|
||||
// drawn bow only needs the count (its pull follows it).
|
||||
if m.extra.using_item > 0 {
|
||||
if player {
|
||||
let item = if m.extra.using_off_hand {
|
||||
m.extra.off_hand.clone()
|
||||
} else {
|
||||
m.extra.main_hand.clone()
|
||||
};
|
||||
if let Some(item) = item {
|
||||
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
using.push((
|
||||
item,
|
||||
m.extra.using_item as u32 - 1,
|
||||
at,
|
||||
eye,
|
||||
m.head_pitch,
|
||||
m.yaw,
|
||||
));
|
||||
}
|
||||
}
|
||||
m.extra.using_item += 1;
|
||||
}
|
||||
@@ -4061,8 +3886,14 @@ impl Game<'_> {
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
} else if let Some(stack) = held.as_ref() {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& self.predict_item_on_block(stack, &hit)
|
||||
{
|
||||
// Item.useOn succeeded here (InteractionResult.SUCCESS):
|
||||
// this hand swings and the click ends, so the other hand
|
||||
// (a shield, a torch) is never tried.
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
if held_block {
|
||||
let placed = if reacts {
|
||||
@@ -4092,7 +3923,8 @@ impl Game<'_> {
|
||||
/// `playSound(player, ...)`: the server sends them to the others only):
|
||||
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
|
||||
/// block (stripping, scraping, removing wax, tilling, making a path).
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) {
|
||||
/// Whether the item's use succeeded, which ends the click.
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) -> bool {
|
||||
let at = [
|
||||
f64::from(hit.pos[0]) + 0.5,
|
||||
f64::from(hit.pos[1]) + 0.5,
|
||||
@@ -4111,11 +3943,15 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
|
||||
}
|
||||
waxes
|
||||
} else if let Some((_, sound, _, _, particle)) =
|
||||
self.block_transform(stack, hit.pos, hit.face)
|
||||
{
|
||||
self.transform_particle(&particle, hit.pos);
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4976,6 +4812,7 @@ impl Game<'_> {
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: std::collections::HashMap::new(),
|
||||
}))
|
||||
}
|
||||
// HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers
|
||||
@@ -5060,6 +4897,15 @@ impl Game<'_> {
|
||||
pub(super) fn remote_menu_title(&self) -> Option<String> {
|
||||
self.remote.as_ref()?.menu_title.clone()
|
||||
}
|
||||
/// A language change reaches the server-sent components too: their titles
|
||||
/// are resolved when the packet arrives, with the session's language.
|
||||
pub(super) fn refresh_remote_language(&mut self) {
|
||||
if let Some(lang) = horizon_client::lang::current()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.lang = Arc::new(lang.map().clone());
|
||||
}
|
||||
}
|
||||
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
|
||||
pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> {
|
||||
let r = self.remote.as_ref()?;
|
||||
|
||||
@@ -20,9 +20,28 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
// off hand 2. The flag starts and stops the use; the client ticks
|
||||
// the count itself (a bow's pull follows it).
|
||||
(8, Value::Byte(flags)) => {
|
||||
m.extra.using_off_hand = flags & 2 != 0;
|
||||
if flags & 1 == 0 {
|
||||
m.extra.using_item = 0;
|
||||
} else if m.extra.using_item == 0 {
|
||||
m.extra.using_item = 1;
|
||||
}
|
||||
}
|
||||
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
|
||||
// animation follow it.
|
||||
(9, Value::Float(health)) => m.health = *health,
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4.
|
||||
// Mob.isAggressive draws the illagers' and skeletons' attack
|
||||
// poses and the vindicator's axe.
|
||||
(15, Value::Byte(flags)) => {
|
||||
m.no_ai = flags & 1 != 0;
|
||||
m.extra.left_handed = flags & 2 != 0;
|
||||
m.ai.aggressive = flags & 4 != 0;
|
||||
}
|
||||
(i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 },
|
||||
_ => {}
|
||||
}
|
||||
@@ -45,6 +64,13 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
|
||||
m.extra.variant2 = (*spell).max(0) as u8;
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
|
||||
// Zombie's three): what VillagerProfessionLayer draws.
|
||||
(Species::Villager, 19, Value::Ids(data))
|
||||
@@ -178,6 +204,19 @@ mod tests {
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mob_flags_turn_aggression_on_and_off() {
|
||||
let Some(mut m) = mob("minecraft:vindicator") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(4 | 2))]);
|
||||
assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai);
|
||||
assert!(horizon_client::mobs::illager::shows_item(&m));
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]);
|
||||
assert!(!m.ai.aggressive && !m.extra.left_handed);
|
||||
assert!(!horizon_client::mobs::illager::shows_item(&m));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_villager_takes_its_type_profession_and_level() {
|
||||
let Some(mut m) = mob("minecraft:villager") else {
|
||||
@@ -218,4 +257,42 @@ mod tests {
|
||||
);
|
||||
assert_eq!(name(&z), ("desert", "farmer", 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mob_flags_drive_the_aggressive_poses() {
|
||||
let Some(mut m) = mob("minecraft:skeleton") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(2 | 4))]);
|
||||
assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai);
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]);
|
||||
assert!(!m.ai.aggressive && !m.extra.left_handed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_living_flags_start_and_stop_a_mob_using_an_item() {
|
||||
let Some(mut m) = mob("minecraft:skeleton") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(1))]);
|
||||
assert_eq!(m.extra.using_item, 1);
|
||||
assert!(!m.extra.using_off_hand);
|
||||
// Using in the off hand.
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(3))]);
|
||||
assert!(m.extra.using_off_hand);
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.extra.using_item, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pillager_charges_and_a_spellcaster_casts() {
|
||||
let Some(mut p) = mob("minecraft:pillager") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut p, &[(17, s::BOOLEAN, Value::Bool(true))]);
|
||||
assert!(p.extra.flag);
|
||||
let mut e = mob("minecraft:evoker").unwrap();
|
||||
apply(&mut e, &[(17, s::BYTE, Value::Byte(2))]);
|
||||
assert_eq!(e.extra.variant2, 2);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallStep, FallingBlock, Game};
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
@@ -125,6 +125,37 @@ impl Game<'_> {
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
self.remote_hook_data(id, values);
|
||||
if self.rockets.iter().any(|r| r.id == id) {
|
||||
for (index, _, value) in values {
|
||||
match (index, value) {
|
||||
// FireworkRocketEntity.DATA_ID_FIREWORKS_ITEM: its
|
||||
// explosions for `createFireworks`.
|
||||
(8, Value::Item(stack)) => {
|
||||
if let Some(stack) = self.to_stack(stack)
|
||||
&& let Some(r) = self.rockets.iter_mut().find(|r| r.id == id)
|
||||
{
|
||||
let (_, explosions) = fireworks_of(&stack);
|
||||
r.explosions = explosions;
|
||||
r.item = stack;
|
||||
}
|
||||
}
|
||||
// DATA_ATTACHED_TO_TARGET (OptionalInt: the id plus 1).
|
||||
(9, Value::Id(v)) => {
|
||||
let v = (*v > 0).then(|| (*v - 1) as u32);
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.attached_to = v;
|
||||
}
|
||||
}
|
||||
// DATA_SHOT_AT_ANGLE.
|
||||
(10, Value::Bool(a)) => {
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.angled = *a;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
@@ -213,9 +244,30 @@ impl Game<'_> {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
}
|
||||
// FireworkRocketEntity.tick: a rocket attached to the gliding local
|
||||
// player boosts it client-side too (attachedToEntity.isFallFlying).
|
||||
let me = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok());
|
||||
for r in &mut self.rockets {
|
||||
let id = r.id;
|
||||
r.remote_step(|at| step(id, at));
|
||||
if me.is_some()
|
||||
&& r.attached_to == me
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
&& p.fall_flying
|
||||
{
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
}
|
||||
for c in &mut self.crystals {
|
||||
c.time += 1;
|
||||
|
||||
@@ -332,6 +332,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.load_scoreboard();
|
||||
host.hub.teams.clone_from(&self.score.board.teams);
|
||||
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
if g.scoreboard != view {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
command::args::GameMode,
|
||||
hud::{HudBatch, rgba},
|
||||
text::{self, Text},
|
||||
ui,
|
||||
@@ -17,7 +18,8 @@ impl Screen {
|
||||
| Self::Stats
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends => true,
|
||||
| Self::Friends
|
||||
| Self::Mod => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
| Self::Chat
|
||||
@@ -81,9 +83,15 @@ fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
(x0 + 72.0, y, "gui/sprites/pause_menu/player_reporting"),
|
||||
]
|
||||
}
|
||||
fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
|
||||
/// DeathScreen.init: a hardcore player can only spectate.
|
||||
fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] {
|
||||
let respawn = if hardcore {
|
||||
"deathScreen.spectate"
|
||||
} else {
|
||||
"deathScreen.respawn"
|
||||
};
|
||||
[
|
||||
(w / 2.0 - 100.0, h / 4.0 + 72.0, "deathScreen.respawn", 0),
|
||||
(w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0),
|
||||
(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0,
|
||||
@@ -223,7 +231,12 @@ impl Game<'_> {
|
||||
),
|
||||
);
|
||||
}
|
||||
let title = Text::translate("deathScreen.title", &[]);
|
||||
let title = if self.level.hardcore {
|
||||
"deathScreen.title.hardcore"
|
||||
} else {
|
||||
"deathScreen.title"
|
||||
};
|
||||
let title = Text::translate(title, &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
ui::text_scaled(
|
||||
batch,
|
||||
@@ -258,7 +271,7 @@ impl Game<'_> {
|
||||
.ui
|
||||
.died_at
|
||||
.is_none_or(|t| t.elapsed().as_millis() >= 1000);
|
||||
for (x, y, key, _) in death_buttons(w, h) {
|
||||
for (x, y, key, _) in death_buttons(w, h, self.level.hardcore) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -286,7 +299,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let b = HudBatch::new(size);
|
||||
let hit = death_buttons(b.width, b.height)
|
||||
let hit = death_buttons(b.width, b.height, self.level.hardcore)
|
||||
.into_iter()
|
||||
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
|
||||
.map(|d| d.3);
|
||||
@@ -330,6 +343,11 @@ impl Game<'_> {
|
||||
Some(1) => self.close_world()?,
|
||||
_ => {}
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if hit == Some(0) && self.remote.is_none() && self.level.hardcore {
|
||||
self.set_mode(GameMode::Spectator);
|
||||
}
|
||||
if hit.is_some() {
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ impl Game<'_> {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let near = super::lan_stations::within_reach(g, p.pos);
|
||||
let near = g.within_block_range(p.pos, 4.0);
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -248,13 +248,14 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(16, 16, 16, 160));
|
||||
let title = if sign.hanging {
|
||||
"Edit Hanging Sign Message"
|
||||
let (key, fallback) = if sign.hanging {
|
||||
("hanging_sign.edit", "Edit Hanging Sign Message")
|
||||
} else {
|
||||
"Edit Sign Message"
|
||||
("sign.edit", "Edit Sign Message")
|
||||
};
|
||||
let tw = HudBatch::text_width(atlas, title);
|
||||
batch.text(atlas, title, (w - tw) / 2.0, 40.0, u32::MAX, true);
|
||||
let title = self.translate(key, fallback);
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(atlas, &title, (w - tw) / 2.0, 40.0, u32::MAX, true);
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -373,12 +373,29 @@ impl GameLevel<'_> {
|
||||
let mut neighbour_updates = 0;
|
||||
if side_effects {
|
||||
match kind {
|
||||
PreRemove::Contents { slots, .. } => {
|
||||
PreRemove::Contents { slots, furnace } => {
|
||||
let items = entity.as_ref().map_or(&[][..], BlockEntity::slots);
|
||||
for slot in 0..slots {
|
||||
let stack = items.get(slot).cloned().flatten();
|
||||
self.drop_item_stack(pos, stack);
|
||||
}
|
||||
// AbstractFurnaceBlockEntity.preRemoveSideEffects: then the
|
||||
// experience of what it made, at the block's centre.
|
||||
if furnace && let Some(BlockEntity::Furnace(f)) = &entity {
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
for (count, experience) in f.recipe_experience(self.recipes) {
|
||||
let xp = horizon_client::containers::create_experience(
|
||||
count,
|
||||
experience,
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.award_orbs(at, xp);
|
||||
}
|
||||
}
|
||||
}
|
||||
PreRemove::Campfire => {
|
||||
for _ in 0..4 {
|
||||
|
||||
@@ -383,6 +383,8 @@ impl Loading {
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
|
||||
@@ -277,15 +277,10 @@ fn argb(c: i32) -> u32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn data(&self) -> &stations::StationData {
|
||||
self.res.stations()
|
||||
}
|
||||
/// `Component.translatable`: the selected language (which `lang` layers
|
||||
/// over `en_us`), the caller's English text last.
|
||||
pub(super) fn translate(&self, key: &str, fallback: &str) -> String {
|
||||
self.data()
|
||||
.enchantments
|
||||
.translate(key)
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
horizon_client::lang::get_or(key, fallback)
|
||||
}
|
||||
fn brewing_open(&self) -> bool {
|
||||
matches!(self.container_kind(), Some(BlockEntity::Brewing(_)))
|
||||
@@ -421,7 +416,8 @@ impl Game<'_> {
|
||||
pub(super) fn anvil_item_name(&self, s: &Station) -> String {
|
||||
match &s.inputs[0] {
|
||||
Some(input)
|
||||
if input.custom_name().is_none() && s.name == self.pack.names.item(&input.item) =>
|
||||
if input.custom_name().is_none()
|
||||
&& s.name == self.pack.names.display_item(&input.item) =>
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
@@ -462,7 +458,7 @@ impl Game<'_> {
|
||||
let name = self.anvil_item_name(&s);
|
||||
let hover = s.inputs[0]
|
||||
.as_ref()
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.item(id), i))
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
s.anvil = stations::anvil_result(
|
||||
&data.enchantments,
|
||||
@@ -895,7 +891,7 @@ impl Game<'_> {
|
||||
s.anvil_last = s.inputs[0].clone();
|
||||
s.name = s.inputs[0]
|
||||
.as_ref()
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.item(id), i))
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
self.station_changed();
|
||||
@@ -1096,7 +1092,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
let mut tooltip: Option<Vec<(String, u32)>> = None;
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
{
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(
|
||||
@@ -1345,16 +1341,19 @@ impl Game<'_> {
|
||||
let clue = self
|
||||
.translate("container.enchant.clue", "%s . . . ?")
|
||||
.replace("%s", &name);
|
||||
let mut lines = vec![(clue, u32::MAX)];
|
||||
let mut lines =
|
||||
vec![(horizon_client::text::Text::literal(clue), u32::MAX)];
|
||||
if !self.creative {
|
||||
lines.push((String::new(), u32::MAX));
|
||||
lines.push((horizon_client::text::Text::literal(""), u32::MAX));
|
||||
if level < cost {
|
||||
lines.push((
|
||||
self.translate(
|
||||
"container.enchant.level.requirement",
|
||||
"Level Requirement: %s",
|
||||
)
|
||||
.replace("%s", &cost.to_string()),
|
||||
horizon_client::text::Text::literal(
|
||||
self.translate(
|
||||
"container.enchant.level.requirement",
|
||||
"Level Requirement: %s",
|
||||
)
|
||||
.replace("%s", &cost.to_string()),
|
||||
),
|
||||
rgba(255, 85, 85, 255),
|
||||
));
|
||||
} else {
|
||||
@@ -1372,7 +1371,7 @@ impl Game<'_> {
|
||||
.replace("%s", &n.to_string())
|
||||
};
|
||||
lines.push((
|
||||
lapis,
|
||||
horizon_client::text::Text::literal(lapis),
|
||||
if gold >= n as i32 {
|
||||
rgba(170, 170, 170, 255)
|
||||
} else {
|
||||
@@ -1391,7 +1390,10 @@ impl Game<'_> {
|
||||
)
|
||||
.replace("%s", &n.to_string())
|
||||
};
|
||||
lines.push((levels, rgba(170, 170, 170, 255)));
|
||||
lines.push((
|
||||
horizon_client::text::Text::literal(levels),
|
||||
rgba(170, 170, 170, 255),
|
||||
));
|
||||
}
|
||||
}
|
||||
tooltip = Some(lines);
|
||||
@@ -1500,10 +1502,10 @@ impl Game<'_> {
|
||||
);
|
||||
if inside(local, 65.0, 46.0, 28.0, 21.0) {
|
||||
tooltip = Some(vec![(
|
||||
self.translate(
|
||||
horizon_client::text::Text::literal(self.translate(
|
||||
"container.upgrade.error_tooltip",
|
||||
"Item can't be upgraded this way",
|
||||
),
|
||||
)),
|
||||
u32::MAX,
|
||||
)]);
|
||||
}
|
||||
@@ -1630,9 +1632,12 @@ impl Game<'_> {
|
||||
|
||||
// ---------------------------------------------------------------- tooltips
|
||||
|
||||
/// `ItemStack.getTooltipLines` subset: name in rarity colour, potion
|
||||
/// effects, enchantments (gray, curses red), stored enchantments, trims.
|
||||
pub(super) fn stack_tooltip(&self, stack: &ItemStack) -> Vec<(String, u32)> {
|
||||
/// `ItemStack.getTooltipLines` subset: name in rarity colour, lore,
|
||||
/// potion effects, enchantments (gray, curses red), stored enchantments, trims.
|
||||
pub(super) fn stack_tooltip(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let e: &Enchantments = &self.res.stations().enchantments;
|
||||
let props = e.props(&stack.item);
|
||||
let rarity = horizon_client::item_tooltip::rarity(
|
||||
@@ -1648,39 +1653,43 @@ impl Game<'_> {
|
||||
let name = if let Some(n) = stack.custom_name() {
|
||||
n.to_owned()
|
||||
} else if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
e.translate("block.minecraft.ominous_banner")
|
||||
.map_or_else(|| "Ominous Banner".to_owned(), str::to_owned)
|
||||
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
|
||||
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
|
||||
|| stack.item.ends_with("potion")
|
||||
|| &*stack.item == "minecraft:tipped_arrow"
|
||||
{
|
||||
let key = brewing::potion_name_key(&stack.item, stack.potion());
|
||||
e.translate(&key)
|
||||
.map_or_else(|| self.pack.names.item(&stack.item), str::to_owned)
|
||||
self.pack
|
||||
.names
|
||||
.display_get(&key)
|
||||
.unwrap_or_else(|| self.pack.names.display_item(&stack.item))
|
||||
} else {
|
||||
self.pack.names.item(&stack.item)
|
||||
self.pack.names.display_item(&stack.item)
|
||||
};
|
||||
let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack);
|
||||
let mut lines = Vec::new();
|
||||
if display.name_visible(self.screen == Screen::Creative) {
|
||||
lines.push((name, color));
|
||||
lines.push((horizon_client::text::Text::legacy(&name), color));
|
||||
}
|
||||
let translate = |k: &str| e.translate(k).map(str::to_owned);
|
||||
let translate = |k: &str| self.pack.names.display_get(k);
|
||||
let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| {
|
||||
lines.push((horizon_client::text::Text::legacy(&s), c));
|
||||
};
|
||||
// ItemContainerContents.addToTooltip.
|
||||
let hover_name = |s: &ItemStack| {
|
||||
s.custom_name()
|
||||
.map_or_else(|| self.pack.names.item(&s.item), str::to_owned)
|
||||
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
|
||||
};
|
||||
if display.shows("minecraft:container") {
|
||||
for line in
|
||||
for text in
|
||||
horizon_client::item_tooltip::container_lines(stack, &hover_name, &translate)
|
||||
{
|
||||
lines.push((line, u32::MAX));
|
||||
push(&mut lines, text, u32::MAX);
|
||||
}
|
||||
}
|
||||
// Fireworks / FireworkExplosion.addToTooltip.
|
||||
for line in horizon_client::item_tooltip::firework_lines(stack, &translate) {
|
||||
lines.push((line, rgba(170, 170, 170, 255)));
|
||||
for text in horizon_client::item_tooltip::firework_lines(stack, &translate) {
|
||||
push(&mut lines, text, rgba(170, 170, 170, 255));
|
||||
}
|
||||
if display.shows("minecraft:potion_contents")
|
||||
&& (stack.item.ends_with("potion") || &*stack.item == "minecraft:tipped_arrow")
|
||||
@@ -1690,7 +1699,7 @@ impl Game<'_> {
|
||||
"minecraft:tipped_arrow" => 0.125,
|
||||
_ => 1.0,
|
||||
};
|
||||
for (line, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
|
||||
for (text, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
|
||||
e.translate(k).map(str::to_owned)
|
||||
}) {
|
||||
let c = match cat {
|
||||
@@ -1698,18 +1707,19 @@ impl Game<'_> {
|
||||
brewing::Category::Harmful => rgba(255, 85, 85, 255),
|
||||
brewing::Category::Neutral => rgba(170, 170, 170, 255),
|
||||
};
|
||||
lines.push((line, c));
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:trim")
|
||||
&& let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone())
|
||||
{
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
e.translate("item.minecraft.smithing_template.upgrade")
|
||||
.unwrap_or("Upgrade:")
|
||||
.to_owned(),
|
||||
rgba(170, 170, 170, 255),
|
||||
));
|
||||
);
|
||||
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
|
||||
let p = e
|
||||
.translate(&format!("trim_pattern.minecraft.{}", short(&pattern)))
|
||||
@@ -1717,38 +1727,51 @@ impl Game<'_> {
|
||||
let m = e
|
||||
.translate(&format!("trim_material.minecraft.{}", short(&material)))
|
||||
.map_or_else(|| short(&material), str::to_owned);
|
||||
lines.push((format!(" {p}"), rgba(170, 170, 170, 255)));
|
||||
lines.push((format!(" {m}"), rgba(170, 170, 170, 255)));
|
||||
push(&mut lines, format!(" {p}"), rgba(170, 170, 170, 255));
|
||||
push(&mut lines, format!(" {m}"), rgba(170, 170, 170, 255));
|
||||
}
|
||||
if display.shows("minecraft:stored_enchantments") {
|
||||
for (text, curse) in e.tooltip(stack.stored_enchantments()) {
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
text,
|
||||
if curse {
|
||||
rgba(255, 85, 85, 255)
|
||||
} else {
|
||||
rgba(170, 170, 170, 255)
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:enchantments") {
|
||||
for (text, curse) in e.tooltip(stack.enchantments()) {
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
text,
|
||||
if curse {
|
||||
rgba(255, 85, 85, 255)
|
||||
} else {
|
||||
rgba(170, 170, 170, 255)
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
// BannerPatternLayers.addToTooltip: up to six layer names.
|
||||
if display.shows("minecraft:banner_patterns") {
|
||||
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
|
||||
let key = self.res.stations().banners.layer_key(layer);
|
||||
lines.push((self.translate(&key, &key), rgba(170, 170, 170, 255)));
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(&key, &key),
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ItemLore.addToTooltip: after the enchantments, before the attributes
|
||||
// (ItemStack.addDetailsToTooltip).
|
||||
if display.shows("minecraft:lore") {
|
||||
for text in horizon_client::item_tooltip::lore_texts(stack) {
|
||||
lines.push((text, u32::MAX));
|
||||
}
|
||||
}
|
||||
// Spawner.appendHoverText: what a spawner or trial spawner item spawns.
|
||||
@@ -1762,15 +1785,15 @@ impl Game<'_> {
|
||||
match block_entity_item::spawner_entity(stack) {
|
||||
Some(id) => {
|
||||
let key = format!("entity.{}", id.replacen(':', ".", 1));
|
||||
lines.push((self.translate(&key, &id), gray));
|
||||
push(&mut lines, self.translate(&key, &id), gray);
|
||||
}
|
||||
None => {
|
||||
lines.push((String::new(), u32::MAX));
|
||||
push(&mut lines, String::new(), u32::MAX);
|
||||
let desc1 =
|
||||
self.translate("block.minecraft.spawner.desc1", "Interact with Spawn Egg:");
|
||||
lines.push((desc1, gray));
|
||||
push(&mut lines, desc1, gray);
|
||||
let desc2 = self.translate("block.minecraft.spawner.desc2", "Sets Mob Type");
|
||||
lines.push((format!(" {desc2}"), rgba(85, 85, 255, 255)));
|
||||
push(&mut lines, format!(" {desc2}"), rgba(85, 85, 255, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1798,11 +1821,13 @@ impl Game<'_> {
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED,
|
||||
),
|
||||
];
|
||||
lines.extend(horizon_client::item_tooltip::attribute_lines(
|
||||
for (text, c) in horizon_client::item_tooltip::attribute_lines(
|
||||
&info.modifiers,
|
||||
Some(player_base),
|
||||
&translate,
|
||||
));
|
||||
) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:damage")
|
||||
&& let Some(info) = self.res.items().get(&stack.item)
|
||||
@@ -1810,14 +1835,15 @@ impl Game<'_> {
|
||||
&& stack.damage > 0
|
||||
&& self.options_advanced_tooltips()
|
||||
{
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
format!(
|
||||
"Durability: {} / {}",
|
||||
info.max_damage - stack.damage,
|
||||
info.max_damage
|
||||
),
|
||||
u32::MAX,
|
||||
));
|
||||
);
|
||||
}
|
||||
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
|
||||
if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
|
||||
@@ -1832,7 +1858,11 @@ impl Game<'_> {
|
||||
"Do not use unless you know the exact contents!",
|
||||
),
|
||||
] {
|
||||
lines.push((self.translate(key, fallback), rgba(255, 85, 85, 255)));
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(key, fallback),
|
||||
rgba(255, 85, 85, 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
lines
|
||||
@@ -1863,20 +1893,27 @@ impl Game<'_> {
|
||||
self.settings.file.bool("advancedItemTooltips")
|
||||
}
|
||||
/// `TooltipRenderUtil`: dark box with the purple border, first line spaced.
|
||||
pub(super) fn draw_tooltip_lines(
|
||||
pub(super) fn draw_tooltip_lines<T: Clone + Into<horizon_client::text::Text>>(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
lines: &[(String, u32)],
|
||||
lines: &[(T, u32)],
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lines: Vec<_> = lines
|
||||
.iter()
|
||||
.map(|(text, color)| {
|
||||
let text: horizon_client::text::Text = text.clone().into();
|
||||
(text.resolve_legacy(), *color)
|
||||
})
|
||||
.collect();
|
||||
let atlas = &self.gui.atlas;
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|(t, _)| HudBatch::text_width(atlas, t))
|
||||
.map(|(t, _)| atlas.font.width_text(t))
|
||||
.fold(0.0, f32::max);
|
||||
let h = if lines.len() == 1 {
|
||||
8.0
|
||||
@@ -1929,7 +1966,7 @@ impl Game<'_> {
|
||||
);
|
||||
let mut ty = y;
|
||||
for (i, (text, color)) in lines.iter().enumerate() {
|
||||
batch.text(atlas, text, x, ty, *color, true);
|
||||
batch.text_styled(atlas, text, x, ty, *color, true);
|
||||
ty += if i == 0 { 12.0 } else { 10.0 };
|
||||
}
|
||||
}
|
||||
@@ -1946,7 +1983,12 @@ impl Game<'_> {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(text) = self.crafter_tooltip(slot) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,52 @@ impl Game<'_> {
|
||||
None => vec![own],
|
||||
}
|
||||
}
|
||||
/// `Container.stillValidBlockEntity`: the open block container closes once
|
||||
/// the player's eyes are out of reach of any of its blocks (a 4-block buffer).
|
||||
pub(super) fn tick_container_reach(&mut self) {
|
||||
// A server's menu ends when the server closes it.
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.container
|
||||
.is_none_or(|k| k.1 == super::minecarts::CART_KEY_Y)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let range = self.reach() + 4.0;
|
||||
let near = self.container_parts().iter().all(|&((x, y, z), _)| {
|
||||
horizon_client::lan::guest::within_block_range(eye, range, [x, y, z])
|
||||
});
|
||||
if !near {
|
||||
self.close_container();
|
||||
self.screen = super::Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `CraftingMenu.stillValid`: the open crafting table closes once its
|
||||
/// block is no longer a crafting table or the player's eyes are out of
|
||||
/// reach (a 4-block buffer).
|
||||
pub(super) fn tick_craft_table(&mut self) {
|
||||
// A server's menu ends when the server closes it.
|
||||
if self.remote.is_some() || self.craft_size != 3 {
|
||||
return;
|
||||
}
|
||||
let (Some(pos), Some(eye)) = (self.craft_table, self.eye_position()) else {
|
||||
return;
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let table = &*self.res.facts().get(state).name == "minecraft:crafting_table";
|
||||
let range = self.reach() + 4.0;
|
||||
if !(table && horizon_client::lan::guest::within_block_range(eye, range, pos)) {
|
||||
self.close_container();
|
||||
self.screen = super::Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// The number of slots of the open container.
|
||||
pub(super) fn container_size(&self) -> usize {
|
||||
self.container_parts().iter().map(|p| p.1).sum()
|
||||
@@ -227,7 +273,8 @@ impl Game<'_> {
|
||||
);
|
||||
let title = self.translate("container.shulkerBox", "Shulker Box");
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 73.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false);
|
||||
return;
|
||||
}
|
||||
let rows = self.chest_rows().unwrap_or(3);
|
||||
@@ -255,9 +302,10 @@ impl Game<'_> {
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(
|
||||
atlas,
|
||||
"Inventory",
|
||||
&inventory,
|
||||
ox + 8.0,
|
||||
oy + 114.0 + rows as f32 * 18.0 - 94.0,
|
||||
grey,
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Game<'_> {
|
||||
Some(HeldName {
|
||||
item: stack.item.to_string(),
|
||||
hover_name: Text::literal(item_tooltip::name_line(stack, &|key| {
|
||||
self.pack.names.get(key).map(str::to_owned)
|
||||
self.pack.names.display_get(key)
|
||||
})),
|
||||
rarity_color: match rarity {
|
||||
"uncommon" => text::YELLOW,
|
||||
|
||||
@@ -399,7 +399,7 @@ impl Game<'_> {
|
||||
}
|
||||
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
|
||||
let clicks = {
|
||||
let names = |id: &str| self.pack.names.item(id);
|
||||
let names = |id: &str| self.pack.names.display_item(id);
|
||||
let rank = |s: &ItemStack| {
|
||||
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
|
||||
(order, hover_name(&names, s))
|
||||
|
||||
@@ -92,6 +92,8 @@ impl Game<'_> {
|
||||
match who {
|
||||
Some(Who::Player) => {
|
||||
if !self.creative {
|
||||
// WitherSkull.onHitEntity's source: the wither launched it.
|
||||
self.combat.projectile_owner = owner;
|
||||
self.mob_hits_player_kind(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
|
||||
@@ -35,7 +35,7 @@ fn entity_shader() -> String {
|
||||
mod entity_batch;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{SHADOW_OVERLAY, WATER_MASK_OVERLAY};
|
||||
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -516,6 +516,18 @@ fn sprite_split() -> u32 {
|
||||
}
|
||||
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
|
||||
const SPRITE_ARRAYS: u32 = 8;
|
||||
/// The layers to give a square texture of `count` layers viewed as a 2D
|
||||
/// array. wgpu's OpenGL backend picks a texture's target from its layer
|
||||
/// count when it is created: one layer makes a 2D texture and a multiple of
|
||||
/// six a cube map, neither of which can be viewed as an array.
|
||||
fn array_layers(count: u32, gl: bool) -> u32 {
|
||||
match count {
|
||||
_ if !gl => count,
|
||||
0 | 1 => 2,
|
||||
n if n % 6 == 0 => n + 1,
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The views of terrain.wgsl's sprite arrays, the unused ones a 1-layer
|
||||
/// stand-in.
|
||||
fn sprite_views(
|
||||
@@ -683,25 +695,31 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
}
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends
|
||||
/// itself. Only Windows has two: Auto tries Vulkan, then DirectX 12 when
|
||||
/// Vulkan has no adapter (no Vulkan driver) or no device. A native crash in
|
||||
/// the driver can't be caught in the game; the launcher switches the setting
|
||||
/// to DirectX 12 after one at the renderer's start (#746).
|
||||
fn backend_order(api: GraphicsApi, windows: bool, wgpu_backend: bool) -> Vec<wgpu::Backends> {
|
||||
if !windows || wgpu_backend {
|
||||
return vec![wgpu::Backends::default()];
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows,
|
||||
/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one
|
||||
/// before has no surface, adapter or device. A native crash in the driver can't be
|
||||
/// caught in the game; the launcher switches the setting to DirectX 12 after one at
|
||||
/// the renderer's start (#746). macOS has Metal only.
|
||||
fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec<wgpu::Backends> {
|
||||
use wgpu::Backends;
|
||||
if os == "macos" || wgpu_backend {
|
||||
return vec![Backends::default()];
|
||||
}
|
||||
let windows = os == "windows";
|
||||
match api {
|
||||
GraphicsApi::Auto => vec![wgpu::Backends::VULKAN, wgpu::Backends::DX12],
|
||||
GraphicsApi::Vulkan => vec![wgpu::Backends::VULKAN],
|
||||
GraphicsApi::Dx12 => vec![wgpu::Backends::DX12],
|
||||
GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL],
|
||||
GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL],
|
||||
GraphicsApi::Dx12 if windows => vec![Backends::DX12],
|
||||
GraphicsApi::Vulkan | GraphicsApi::Dx12 => vec![Backends::VULKAN],
|
||||
GraphicsApi::OpenGl => vec![Backends::GL],
|
||||
}
|
||||
}
|
||||
fn backends_name(backends: wgpu::Backends) -> &'static str {
|
||||
match backends {
|
||||
wgpu::Backends::VULKAN => "Vulkan",
|
||||
wgpu::Backends::DX12 => "DirectX 12",
|
||||
wgpu::Backends::GL => "OpenGL",
|
||||
_ => "graphics",
|
||||
}
|
||||
}
|
||||
@@ -780,7 +798,7 @@ async fn open_device(
|
||||
) -> Result<Opened> {
|
||||
let order = backend_order(
|
||||
api.with_env(),
|
||||
cfg!(windows),
|
||||
std::env::consts::OS,
|
||||
std::env::var_os("WGPU_BACKEND").is_some(),
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
@@ -793,7 +811,13 @@ async fn open_device(
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
Some(instance.create_surface(window.clone())?)
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name} surface: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
@@ -863,14 +887,14 @@ async fn open_device(
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
#[cfg(target_os = "windows")]
|
||||
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan or DirectX 12 found: Horizon needs a GPU \
|
||||
driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
|
||||
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan, DirectX 12 or OpenGL found: Horizon needs \
|
||||
a GPU driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
|
||||
start the game again";
|
||||
#[cfg(target_os = "macos")]
|
||||
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
|
||||
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan or OpenGL graphics driver found: Horizon needs the Vulkan loader \
|
||||
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's), or OpenGL through EGL";
|
||||
/// Logs a step of the renderer's start, for a native crash in the graphics
|
||||
/// driver: it leaves no panic or crash report, so the log's last line is the
|
||||
/// only sign of where the game stopped. The log file is written unbuffered;
|
||||
@@ -1300,13 +1324,17 @@ impl Renderer {
|
||||
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
|
||||
});
|
||||
let mut mip_levels = 1;
|
||||
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
|
||||
let sprite_textures: Vec<wgpu::Texture> = prepared
|
||||
.sprites
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(array, (mips, levels))| {
|
||||
.map(|(array, (mut mips, levels))| {
|
||||
let first = array as u32 * split;
|
||||
let count = layers.saturating_sub(first).clamp(1, split);
|
||||
let used = layers.saturating_sub(first).clamp(1, split);
|
||||
let count = array_layers(used, gl);
|
||||
// The extra layers are empty.
|
||||
mips.resize(mips.len() / used as usize * count as usize, 0);
|
||||
mip_levels = levels;
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
@@ -1340,6 +1368,7 @@ impl Renderer {
|
||||
sprite_mips,
|
||||
&sprite_textures,
|
||||
split,
|
||||
animate::SpriteAnimator::compute(&adapter, &device),
|
||||
);
|
||||
let sprite_unused = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -1347,7 +1376,7 @@ impl Renderer {
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
depth_or_array_layers: array_layers(1, gl),
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
@@ -2057,16 +2086,15 @@ impl Renderer {
|
||||
// One per blend and `.png.mcmeta` sampling (screen_effects::SAMPLE_*):
|
||||
// the shader is specialised, so a pixel pays for none of the others.
|
||||
let screen_pipelines: [wgpu::RenderPipeline; 8] = std::array::from_fn(|i| {
|
||||
let (entry, blend) = if i & 4 == 0 {
|
||||
("fs_screen", alpha_blend)
|
||||
} else {
|
||||
("fs_vignette", vignette_blend)
|
||||
let blur = i as u32 & crate::screen_effects::SAMPLE_BLUR != 0;
|
||||
let (entry, blend) = match (i & 4 == 0, blur) {
|
||||
(true, false) => ("fs_screen", alpha_blend),
|
||||
(true, true) => ("fs_screen_blur", alpha_blend),
|
||||
(false, false) => ("fs_vignette", vignette_blend),
|
||||
(false, true) => ("fs_vignette_blur", vignette_blend),
|
||||
};
|
||||
let on = |bit: u32| f64::from(u8::from(i as u32 & bit != 0));
|
||||
let constants = [
|
||||
("BLUR", on(crate::screen_effects::SAMPLE_BLUR)),
|
||||
("CLAMP", on(crate::screen_effects::SAMPLE_CLAMP)),
|
||||
];
|
||||
let clamp = i as u32 & crate::screen_effects::SAMPLE_CLAMP != 0;
|
||||
let constants = [("CLAMP", f64::from(u8::from(clamp)))];
|
||||
let compilation_options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
@@ -2154,15 +2182,26 @@ impl Renderer {
|
||||
)
|
||||
});
|
||||
let sorted_indices = arena::Arena::indices(&device, 1 << 16, flat.is_some());
|
||||
let multi_draw = device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
|
||||
&& adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
// Without indirect draws (OpenGL before 4.3 without compute shaders)
|
||||
// even creating an indirect buffer fails; one draw per section reads
|
||||
// `draws` on the CPU instead.
|
||||
let indirect = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section draws"),
|
||||
size: 1024 * 20,
|
||||
usage: wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST,
|
||||
usage: if multi_draw {
|
||||
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST
|
||||
} else {
|
||||
wgpu::BufferUsages::COPY_DST
|
||||
},
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let multi_draw = device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE);
|
||||
// HORIZON_GPU_CULL=0 turns occlusion culling off, `all` culls every
|
||||
// frame however few its draws and little it hides, `shader` tests in
|
||||
// the shader as on Metal (A/B checks).
|
||||
@@ -3439,6 +3478,7 @@ impl Renderer {
|
||||
translucent_crumbling_quads,
|
||||
water_mask_quads,
|
||||
shadow_quads,
|
||||
translucent_particle_quads,
|
||||
] = self.entity_batch.update(
|
||||
frame.entities,
|
||||
frame.additive,
|
||||
@@ -3450,7 +3490,8 @@ impl Renderer {
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads
|
||||
+ shadow_quads;
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
@@ -3849,7 +3890,7 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((world_quads - shadow_quads) * 4) as i32,
|
||||
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -3917,6 +3958,22 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
|
||||
// translucent particle layer draws after translucent terrain —
|
||||
// the quads write depth, so before it they would hide the glass
|
||||
// or water behind them.
|
||||
if translucent_particle_quads > 0 {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_particle_quads * 6,
|
||||
((world_quads - translucent_particle_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer.executeClassicTransparency: translucent targets'
|
||||
// outlines follow terrain, so its blending cannot wash them out.
|
||||
if outline_edges > 0 && frame.outline_after_terrain {
|
||||
@@ -4423,14 +4480,23 @@ mod tests {
|
||||
normal: [0; 4],
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [(0, 0), (14, 9), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, id)| quad(layer, id))
|
||||
.collect();
|
||||
let entities: Vec<_> = [
|
||||
(0, 0),
|
||||
(14, 9),
|
||||
(4, 1),
|
||||
(11, 2),
|
||||
(15, 10),
|
||||
(7, 3),
|
||||
(4, 4),
|
||||
(12, 7),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, id)| quad(layer, id))
|
||||
.collect();
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 0, 1]);
|
||||
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -4438,29 +4504,31 @@ mod tests {
|
||||
.iter()
|
||||
.map(|q| q[0].light)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3, 4, 2, 7, 9, 5, 6, 8]
|
||||
[0, 1, 3, 4, 2, 7, 9, 10, 5, 6, 8]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn windows_tries_vulkan_then_directx_12() {
|
||||
fn auto_tries_vulkan_then_directx_12_then_opengl() {
|
||||
use super::{GraphicsApi, backend_order};
|
||||
use wgpu::Backends;
|
||||
let order = |api, os| backend_order(api, os, false);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Auto, true, false),
|
||||
[Backends::VULKAN, Backends::DX12]
|
||||
order(GraphicsApi::Auto, "windows"),
|
||||
[Backends::VULKAN, Backends::DX12, Backends::GL]
|
||||
);
|
||||
assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Vulkan, true, false),
|
||||
[Backends::VULKAN]
|
||||
order(GraphicsApi::Auto, "linux"),
|
||||
[Backends::VULKAN, Backends::GL]
|
||||
);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Dx12, true, false),
|
||||
[Backends::DX12]
|
||||
);
|
||||
// WGPU_BACKEND picks the backends itself; other systems have one.
|
||||
assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]);
|
||||
// WGPU_BACKEND picks the backends itself; macOS has Metal only.
|
||||
for api in GraphicsApi::ALL {
|
||||
assert_eq!(backend_order(api, true, true), [Backends::default()]);
|
||||
assert_eq!(backend_order(api, false, false), [Backends::default()]);
|
||||
assert_eq!(backend_order(api, "windows", true), [Backends::default()]);
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
|
||||
@@ -4520,6 +4588,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn gl_sprite_arrays_are_neither_2d_textures_nor_cube_maps() {
|
||||
for (count, gl) in [(1, 2), (2, 2), (5, 5), (6, 7), (12, 13), (2048, 2048)] {
|
||||
assert_eq!(super::array_layers(count, true), gl);
|
||||
assert_eq!(super::array_layers(count, false), count);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn entity_shader_validates_with_its_overlay_constants() {
|
||||
let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! frames. Blending on the CPU at the array's size and copying every changed
|
||||
//! sprite level by level (a copy command each, a blit each in Mesa) was about
|
||||
//! a fifth of the render thread on an integrated GPU.
|
||||
//!
|
||||
//! Without compute shaders (OpenGL before 4.3, the last fallback) the same
|
||||
//! jobs run on the CPU, the same integer mix as `animate.wgsl`, and each
|
||||
//! changed level is copied into its layer.
|
||||
use super::upload::Uploads;
|
||||
use crate::mipmap::Settings;
|
||||
use crate::sprites::{FrameBlend, SpriteAnimation};
|
||||
@@ -38,17 +42,32 @@ struct Sprite {
|
||||
struct Group {
|
||||
/// Where its jobs start in `SpriteAnimator::jobs`, in bytes.
|
||||
offset: u64,
|
||||
/// One bind group per mip level of the array.
|
||||
/// One bind group per mip level of the array (none on the CPU).
|
||||
levels: Vec<wgpu::BindGroup>,
|
||||
/// This tick's jobs.
|
||||
jobs: Vec<u8>,
|
||||
/// Its sprite array and its frames, for the CPU.
|
||||
array: usize,
|
||||
buffer: usize,
|
||||
}
|
||||
|
||||
/// Where the jobs run.
|
||||
enum Writer {
|
||||
Gpu {
|
||||
jobs: wgpu::Buffer,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
},
|
||||
/// Every frames buffer's words, and the sprite arrays to copy into.
|
||||
Cpu {
|
||||
frames: Vec<Vec<u8>>,
|
||||
textures: Vec<wgpu::Texture>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct SpriteAnimator {
|
||||
sprites: Vec<Sprite>,
|
||||
groups: Vec<Group>,
|
||||
jobs: wgpu::Buffer,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
writer: Writer,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
/// The layers the last `animate` rewrote, counted over all arrays.
|
||||
@@ -57,7 +76,8 @@ pub(super) struct SpriteAnimator {
|
||||
|
||||
impl SpriteAnimator {
|
||||
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
|
||||
/// wide; `animations` hold their frames at that size.
|
||||
/// wide; `animations` hold their frames at that size. `compute`: whether
|
||||
/// the device says it runs compute shaders writing storage textures.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
animations: &[SpriteAnimation],
|
||||
@@ -65,11 +85,18 @@ impl SpriteAnimator {
|
||||
settings: &[Settings],
|
||||
textures: &[wgpu::Texture],
|
||||
split: u32,
|
||||
compute: bool,
|
||||
) -> Self {
|
||||
let pipeline = if compute { pipeline(device) } else { None };
|
||||
let limits = device.limits();
|
||||
let limit = limits
|
||||
.max_storage_buffer_binding_size
|
||||
.min(limits.max_buffer_size);
|
||||
let limit = if pipeline.is_some() {
|
||||
limits
|
||||
.max_storage_buffer_binding_size
|
||||
.min(limits.max_buffer_size)
|
||||
} else {
|
||||
// Word offsets.
|
||||
u64::from(u32::MAX) * 4
|
||||
};
|
||||
let mut frames: Vec<Vec<u8>> = Vec::new();
|
||||
// Each group's sprite array and frames buffer.
|
||||
let mut keys: Vec<(u32, usize)> = Vec::new();
|
||||
@@ -119,6 +146,29 @@ impl SpriteAnimator {
|
||||
group,
|
||||
});
|
||||
}
|
||||
let Some(pipeline) = pipeline else {
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
.map(|(&(array, buffer), &count)| Group {
|
||||
offset: 0,
|
||||
levels: Vec::new(),
|
||||
jobs: Vec::with_capacity(count * JOB_BYTES),
|
||||
array: array as usize,
|
||||
buffer,
|
||||
})
|
||||
.collect();
|
||||
return Self {
|
||||
sprites,
|
||||
groups,
|
||||
writer: Writer::Cpu {
|
||||
frames,
|
||||
textures: textures.to_vec(),
|
||||
},
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
};
|
||||
};
|
||||
let frames: Vec<_> = frames
|
||||
.iter()
|
||||
.map(|data| {
|
||||
@@ -142,18 +192,6 @@ impl SpriteAnimator {
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("animate"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
@@ -198,19 +236,33 @@ impl SpriteAnimator {
|
||||
offset,
|
||||
levels,
|
||||
jobs: Vec::with_capacity(count * JOB_BYTES),
|
||||
array: array as usize,
|
||||
buffer,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
sprites,
|
||||
groups,
|
||||
jobs,
|
||||
pipeline,
|
||||
writer: Writer::Gpu { jobs, pipeline },
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the jobs can run on the GPU: compute shaders writing a
|
||||
/// storage texture and reading two storage buffers (not OpenGL before
|
||||
/// 4.3).
|
||||
pub fn compute(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
let limits = device.limits();
|
||||
adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS)
|
||||
&& limits.max_storage_textures_per_shader_stage >= 1
|
||||
&& limits.max_storage_buffers_per_shader_stage >= 2
|
||||
}
|
||||
|
||||
/// The layers the last `animate` rewrote (for the shader pack atlas).
|
||||
pub fn changed(&self) -> &[u32] {
|
||||
&self.changed
|
||||
@@ -227,14 +279,47 @@ impl SpriteAnimator {
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
let (jobs, pipeline) = match &self.writer {
|
||||
Writer::Gpu { jobs, pipeline } => (jobs, pipeline),
|
||||
Writer::Cpu { frames, textures } => {
|
||||
for group in &mut self.groups {
|
||||
let (frames, texture) = (&frames[group.buffer], &textures[group.array]);
|
||||
for job in group.jobs.as_chunks::<JOB_BYTES>().0 {
|
||||
let (words, _) = job.as_chunks::<4>();
|
||||
let job: [u32; 5] = std::array::from_fn(|i| u32::from_le_bytes(words[i]));
|
||||
for level in 0..texture.mip_level_count() {
|
||||
let width = self.size >> level;
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: job[0],
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
width,
|
||||
width,
|
||||
&mix_level(frames, job, width),
|
||||
);
|
||||
}
|
||||
}
|
||||
group.jobs.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
for group in &self.groups {
|
||||
uploads.buffer(encoder, &self.jobs, group.offset, &group.jobs);
|
||||
uploads.buffer(encoder, jobs, group.offset, &group.jobs);
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_pipeline(pipeline);
|
||||
for group in &mut self.groups {
|
||||
let count = (group.jobs.len() / JOB_BYTES) as u32;
|
||||
if count == 0 {
|
||||
@@ -278,6 +363,62 @@ impl SpriteAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
|
||||
/// driver can list compute shaders among its extensions while wgpu writes
|
||||
/// the GLSL version of its core version, which has none (Mesa's
|
||||
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("animate"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => Some(pipeline),
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `animate.wgsl` on the CPU: one `width` wide level of a job
|
||||
/// (`[layer, current, next, millis, edge]`) from `frames`' chains.
|
||||
fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u32) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity((width * width * 4) as usize);
|
||||
for y in 0..width {
|
||||
for x in 0..width {
|
||||
let texel = if width >= edge {
|
||||
let scale = width / edge;
|
||||
y / scale * edge + x / scale
|
||||
} else {
|
||||
4 * (edge * edge - width * width) / 3 + y * width + x
|
||||
};
|
||||
let at = |chain: u32| ((chain + texel) * 4) as usize;
|
||||
let a = &frames[at(current)..at(current) + 4];
|
||||
if millis == 0 {
|
||||
out.extend_from_slice(a);
|
||||
continue;
|
||||
}
|
||||
let b = &frames[at(next)..at(next) + 4];
|
||||
out.extend(a.iter().zip(b).map(|(&a, &b)| {
|
||||
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000) as u8
|
||||
}));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The mip chain a frame's layer is made of, and its first level's edge.
|
||||
/// `mipmap::generate` makes a sprite scaled up by whole pixels to the
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
@@ -512,6 +653,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gpu_layers_match_the_cpu_chains_every_tick() {
|
||||
layers_match_the_cpu_chains_every_tick(true);
|
||||
}
|
||||
|
||||
/// Without compute shaders (OpenGL before 4.3): the same layers.
|
||||
#[test]
|
||||
fn copied_layers_match_the_cpu_chains_every_tick() {
|
||||
layers_match_the_cpu_chains_every_tick(false);
|
||||
}
|
||||
|
||||
fn layers_match_the_cpu_chains_every_tick(compute: bool) {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
@@ -552,12 +703,15 @@ mod tests {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &textures, split);
|
||||
let mut animator =
|
||||
SpriteAnimator::new(&device, &animations, size, &mips, &textures, split, compute);
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut writes = Vec::new();
|
||||
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
|
||||
|
||||
@@ -3,13 +3,16 @@ use super::Vertex;
|
||||
|
||||
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`) and entity shadows
|
||||
/// (14, `RenderPipelines.ENTITY_SHADOW`).
|
||||
const MATERIALS: usize = 5;
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
|
||||
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
|
||||
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
|
||||
const MATERIALS: usize = 6;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
|
||||
pub const SHADOW_OVERLAY: u32 = 14;
|
||||
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
|
||||
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EntityBatch {
|
||||
@@ -41,6 +44,7 @@ impl EntityBatch {
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
SHADOW_OVERLAY => 4,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 5,
|
||||
_ => 0,
|
||||
};
|
||||
self.buckets[material][counts[material]] = index;
|
||||
@@ -92,13 +96,14 @@ mod tests {
|
||||
) -> (Vec<Vertex>, [u32; MATERIALS]) {
|
||||
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
|
||||
let mut counts = [0; MATERIALS];
|
||||
for (i, layer) in [0, 11, 12, 13, 14].into_iter().enumerate() {
|
||||
for (i, layer) in [0, 11, 12, 13, 14, 15].into_iter().enumerate() {
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
let material = match quad[0].layer {
|
||||
11 => 11,
|
||||
12 => 12,
|
||||
13 => 13,
|
||||
14 => 14,
|
||||
15 => 15,
|
||||
_ => 0,
|
||||
};
|
||||
if material == layer {
|
||||
@@ -118,7 +123,7 @@ mod tests {
|
||||
pos: [index as f32, 2.0, 3.0],
|
||||
uv: [index as f32, 5.0],
|
||||
layer: if mixed {
|
||||
[7, 11, 14, 12, 4, 13, 0][index / 4 % 7]
|
||||
[7, 11, 14, 12, 4, 13, 15, 0][index / 4 % 8]
|
||||
} else {
|
||||
0
|
||||
},
|
||||
|
||||
@@ -98,19 +98,27 @@ impl PresentMode {
|
||||
/// macOS). Applies at the next start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GraphicsApi {
|
||||
/// Vulkan, and DirectX 12 when Vulkan has no adapter or device.
|
||||
/// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device.
|
||||
#[default]
|
||||
Auto,
|
||||
Vulkan,
|
||||
Dx12,
|
||||
/// OpenGL, for GPUs with no Vulkan or DirectX 12 driver (Windows and Linux).
|
||||
OpenGl,
|
||||
}
|
||||
impl GraphicsApi {
|
||||
pub const ALL: [GraphicsApi; 3] = [GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::Dx12];
|
||||
pub const ALL: [GraphicsApi; 4] = [
|
||||
GraphicsApi::Auto,
|
||||
GraphicsApi::Vulkan,
|
||||
GraphicsApi::Dx12,
|
||||
GraphicsApi::OpenGl,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GraphicsApi::Auto => "auto",
|
||||
GraphicsApi::Vulkan => "vulkan",
|
||||
GraphicsApi::Dx12 => "dx12",
|
||||
GraphicsApi::OpenGl => "opengl",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -118,6 +126,7 @@ impl GraphicsApi {
|
||||
GraphicsApi::Auto => "Auto",
|
||||
GraphicsApi::Vulkan => "Vulkan",
|
||||
GraphicsApi::Dx12 => "DirectX 12",
|
||||
GraphicsApi::OpenGl => "OpenGL",
|
||||
}
|
||||
}
|
||||
/// The API a name stands for, ignoring case; `None` for anything else.
|
||||
@@ -126,7 +135,7 @@ impl GraphicsApi {
|
||||
.into_iter()
|
||||
.find(|api| api.name().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
/// `HORIZON_GRAPHICS_API` (auto, vulkan or dx12) if set, else the setting.
|
||||
/// `HORIZON_GRAPHICS_API` (auto, vulkan, dx12 or opengl) if set, else the setting.
|
||||
pub fn with_env(self) -> Self {
|
||||
std::env::var("HORIZON_GRAPHICS_API")
|
||||
.ok()
|
||||
@@ -376,14 +385,18 @@ impl Graphics {
|
||||
if self.workers > 0 {
|
||||
return self.workers as usize;
|
||||
}
|
||||
// The pool never takes every core. The render thread and the
|
||||
// integrated server thread (single player runs on its own server
|
||||
// thread by default) each need one, and a pool that fills every core
|
||||
// starves them: the frame rate collapses to single digits while the
|
||||
// workers saturate the CPU and its memory bandwidth.
|
||||
// Vanilla's count (Util.maxAllowedExecutorThreads: every core but
|
||||
// one). The pool never takes every core: a pool that fills them all
|
||||
// starves the render and server threads, and the frame rate collapses
|
||||
// while the workers saturate the CPU and its memory bandwidth. One
|
||||
// core left is enough with the workers behind those two threads
|
||||
// (horizon_threads: niced on Linux; on macOS and Windows the server
|
||||
// and render threads are raised above four cores, where vanilla
|
||||
// raises its server thread). Leaving two idled a core of a 4-thread
|
||||
// laptop while a world loaded: a teleport there took ~15% longer.
|
||||
let auto = std::thread::available_parallelism()
|
||||
.map_or(2, |n| n.get())
|
||||
.saturating_sub(2)
|
||||
.saturating_sub(1)
|
||||
.max(1);
|
||||
if self.battery { auto.div_ceil(2) } else { auto }
|
||||
}
|
||||
@@ -546,11 +559,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_workers_leave_cores_for_the_render_and_server_threads() {
|
||||
fn automatic_workers_leave_a_core_as_vanilla_does() {
|
||||
let g = Graphics::default();
|
||||
assert_eq!(g.workers, 0);
|
||||
let cores = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(2).max(1));
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
// A manual count is the player's own choice, kept as asked.
|
||||
let manual = Graphics { workers: 3, ..g };
|
||||
assert_eq!(manual.worker_threads(), 3);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! GUI scale is applied when drawing).
|
||||
use crate::Result;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
@@ -58,6 +58,9 @@ pub struct HudAtlas {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
|
||||
/// which puts their particles on vanilla's translucent layer.
|
||||
pub translucent: HashSet<String>,
|
||||
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
|
||||
/// sample as vanilla's defaults (nearest, repeat).
|
||||
pub flags: HashMap<String, TextureFlags>,
|
||||
@@ -462,7 +465,16 @@ impl HudAtlas {
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
let mut sprites = HashMap::new();
|
||||
let mut translucent = HashSet::new();
|
||||
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
|
||||
if data
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.any(|p| p[3] != 0 && p[3] != 255)
|
||||
{
|
||||
translucent.insert(name.clone());
|
||||
}
|
||||
for row in 0..*h {
|
||||
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
@@ -527,6 +539,7 @@ impl HudAtlas {
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
translucent,
|
||||
flags,
|
||||
fire_animations,
|
||||
font,
|
||||
@@ -890,6 +903,9 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
let text = text.resolved();
|
||||
let font = &atlas.font;
|
||||
let alpha = (color >> 24) as u8;
|
||||
let mut glyphs = Vec::new();
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Item stacks on the wire (`ItemStack.OPTIONAL_STREAM_CODEC`): the component
|
||||
//! patch read back as vanilla's `components` compound, so the decoder the saved
|
||||
//! and command item NBT use fills every field. Both sides use it: a client for
|
||||
//! what its server sends, a server for a creative client's
|
||||
//! `SetCreativeModeSlot`. The other direction is `lan::guest::stack_to_network`.
|
||||
use crate::items::ItemStack;
|
||||
use horizon_protocol::item::{Component, Profile, Stack, component};
|
||||
use horizon_protocol::registry::{Builtin, Dynamic};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stack from the wire: its item by the built-in registry's id, its
|
||||
/// components by the synced registries' (enchantments, trims).
|
||||
pub fn from_network(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> ItemStack {
|
||||
let mut out = ItemStack {
|
||||
item: item_name(builtin, s.item),
|
||||
count: s.count.max(0) as u32,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
let components = components_tag(s, builtin, synced);
|
||||
let (damage, data) = crate::item_data::decode_components(components.as_ref());
|
||||
out.damage = damage;
|
||||
if let Some(data) = data {
|
||||
*out.data_mut() = *data;
|
||||
}
|
||||
out.tidy();
|
||||
out
|
||||
}
|
||||
|
||||
fn item_name(builtin: &Builtin, id: i32) -> Arc<str> {
|
||||
Arc::from(
|
||||
builtin
|
||||
.get("minecraft:item")
|
||||
.and_then(|i| i.name(id))
|
||||
.unwrap_or("minecraft:air"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The item's patched components as vanilla's `components` compound.
|
||||
fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag> {
|
||||
let name = |id: i32| builtin.get("minecraft:data_component_type")?.name(id);
|
||||
let mut entries: Vec<(&str, Tag)> = Vec::new();
|
||||
for (kind, c) in &s.added {
|
||||
let Some(key) = name(*kind) else {
|
||||
continue;
|
||||
};
|
||||
let value = match c {
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
// Trim materials and patterns are synced registries, as enchantments are.
|
||||
Component::Trim { material, pattern } => {
|
||||
let (Some(material), Some(pattern)) = (
|
||||
synced.name("minecraft:trim_material", *material),
|
||||
synced.name("minecraft:trim_pattern", *pattern),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
Tag::compound([
|
||||
("material", Tag::String(material.into())),
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
}
|
||||
Component::Int(v) => Tag::Int(*v),
|
||||
Component::Unit => Tag::Compound(Default::default()),
|
||||
Component::Bool(v) => Tag::Byte(i8::from(*v)),
|
||||
Component::Text(t) | Component::Nbt(t) => t.clone(),
|
||||
Component::Ident(v) => Tag::String(v.as_str().into()),
|
||||
Component::Lore(lines) => Tag::List(lines.clone()),
|
||||
Component::WritableBook(pages) => Tag::compound([(
|
||||
"pages",
|
||||
Tag::List(
|
||||
pages
|
||||
.iter()
|
||||
.map(|p| Tag::String(p.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
)]),
|
||||
Component::WrittenBook(b) => Tag::compound([
|
||||
("title", Tag::String(b.title.as_str().into())),
|
||||
("author", Tag::String(b.author.as_str().into())),
|
||||
("generation", Tag::Int(b.generation)),
|
||||
("pages", Tag::List(b.pages.clone())),
|
||||
("resolved", Tag::Byte(i8::from(b.resolved))),
|
||||
]),
|
||||
Component::Fireworks { flight, explosions } => Tag::compound([
|
||||
("flight_duration", Tag::Byte(*flight as i8)),
|
||||
(
|
||||
"explosions",
|
||||
Tag::List(explosions.iter().map(fireworks_tag).collect()),
|
||||
),
|
||||
]),
|
||||
Component::FireworkExplosion(e) => fireworks_tag(e),
|
||||
Component::Enchantments(list) => {
|
||||
// Enchantments are a synced (data-driven) registry: the
|
||||
// ids index the server's RegistryData, not the builtin
|
||||
// report, so a datapack enchantment names correctly.
|
||||
let levels = list.iter().filter_map(|(id, level)| {
|
||||
let name = synced.name("minecraft:enchantment", *id)?;
|
||||
Some((name, Tag::Int(*level)))
|
||||
});
|
||||
Tag::compound(levels)
|
||||
}
|
||||
Component::AttackRange(v) => Tag::compound([
|
||||
("min_reach", Tag::Float(v[0].to_bits())),
|
||||
("max_reach", Tag::Float(v[1].to_bits())),
|
||||
("min_attack_momentum", Tag::Float(v[2].to_bits())),
|
||||
("max_attack_momentum", Tag::Float(v[3].to_bits())),
|
||||
("hitbox_margin", Tag::Float(v[4].to_bits())),
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// PotionContents' NBT: the potion by name and the custom colour.
|
||||
Component::Potion { potion, color, .. } => {
|
||||
let potion = potion.and_then(|id| builtin.get("minecraft:potion")?.name(id));
|
||||
Tag::compound(
|
||||
potion
|
||||
.map(|name| ("potion", Tag::String(name.into())))
|
||||
.into_iter()
|
||||
.chain(color.map(|c| ("custom_color", Tag::Int(c)))),
|
||||
)
|
||||
}
|
||||
// ItemStackTemplate.CODEC: id, count and components.
|
||||
Component::Stacks(stacks) => Tag::List(
|
||||
stacks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut entries = vec![
|
||||
(
|
||||
"id",
|
||||
Tag::String(item_name(builtin, t.item).as_ref().into()),
|
||||
),
|
||||
("count", Tag::Int(t.count)),
|
||||
];
|
||||
if let Some(c) = components_tag(t, builtin, synced) {
|
||||
entries.push(("components", c));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Component::CustomModelData {
|
||||
floats,
|
||||
flags,
|
||||
strings,
|
||||
colors,
|
||||
} => Tag::compound([
|
||||
(
|
||||
"floats",
|
||||
Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()),
|
||||
),
|
||||
(
|
||||
"flags",
|
||||
Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()),
|
||||
),
|
||||
(
|
||||
"strings",
|
||||
Tag::List(
|
||||
strings
|
||||
.iter()
|
||||
.map(|s| Tag::String(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"colors",
|
||||
Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()),
|
||||
),
|
||||
]),
|
||||
Component::TooltipDisplay { hide, hidden } => {
|
||||
let hidden = hidden
|
||||
.iter()
|
||||
.filter_map(|id| Some(Tag::String(name(*id)?.into())));
|
||||
Tag::compound([
|
||||
("hide_tooltip", Tag::Byte(i8::from(*hide))),
|
||||
("hidden_components", Tag::List(hidden.collect())),
|
||||
])
|
||||
}
|
||||
};
|
||||
entries.push((key, value));
|
||||
}
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
|
||||
/// A wire `FireworkExplosion` as vanilla's NBT codec writes it
|
||||
/// (`FireworkExplosion.Shape`'s BY_ID order).
|
||||
fn fireworks_tag(e: &horizon_protocol::item::FireworkExplosion) -> Tag {
|
||||
const SHAPES: [&str; 5] = ["small_ball", "large_ball", "star", "creeper", "burst"];
|
||||
let ints = |c: &[i32]| Tag::List(c.iter().map(|c| Tag::Int(*c)).collect());
|
||||
Tag::compound([
|
||||
(
|
||||
"shape",
|
||||
Tag::String(
|
||||
SHAPES
|
||||
.get(e.shape.max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or("small_ball")
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
("colors", ints(&e.colors)),
|
||||
("fade_colors", ints(&e.fade)),
|
||||
("has_trail", Tag::Byte(i8::from(e.trail))),
|
||||
("has_twinkle", Tag::Byte(i8::from(e.twinkle))),
|
||||
])
|
||||
}
|
||||
|
||||
/// A `minecraft:profile` component as the item data JSON the model reads.
|
||||
fn profile_tag(p: &Profile) -> Tag {
|
||||
let properties = p
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(name, value, signature)| {
|
||||
let mut entries: Vec<(&'static str, Tag)> = vec![
|
||||
("name", Tag::String(name.as_str().into())),
|
||||
("value", Tag::String(value.as_str().into())),
|
||||
];
|
||||
if let Some(signature) = signature {
|
||||
entries.push(("signature", Tag::String(signature.as_str().into())));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
|
||||
if let Some(name) = &p.name {
|
||||
entries.push(("name", Tag::String(name.as_str().into())));
|
||||
}
|
||||
entries.push(("properties", Tag::List(properties)));
|
||||
Tag::compound(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::registry::Ids;
|
||||
|
||||
/// What a creative client sends (`stack_to_network`) comes back whole on
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim.
|
||||
#[test]
|
||||
fn a_creative_stack_keeps_its_components_across_the_wire() {
|
||||
let mut components = vec![String::new(); 60];
|
||||
for (id, name) in [
|
||||
(component::DAMAGE, "minecraft:damage"),
|
||||
(component::CUSTOM_NAME, "minecraft:custom_name"),
|
||||
(component::ENCHANTMENTS, "minecraft:enchantments"),
|
||||
(component::POTION_CONTENTS, "minecraft:potion_contents"),
|
||||
(component::REPAIR_COST, "minecraft:repair_cost"),
|
||||
(
|
||||
component::STORED_ENCHANTMENTS,
|
||||
"minecraft:stored_enchantments",
|
||||
),
|
||||
(component::TRIM, "minecraft:trim"),
|
||||
] {
|
||||
components[id as usize] = name.to_owned();
|
||||
}
|
||||
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".to_owned(),
|
||||
Ids::from_names(names(&[
|
||||
"minecraft:air",
|
||||
"minecraft:diamond_chestplate",
|
||||
"minecraft:splash_potion",
|
||||
])),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".to_owned(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
(
|
||||
"minecraft:potion".to_owned(),
|
||||
Ids::from_names(names(&["minecraft:water", "minecraft:swiftness"])),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let synced = |n: &[&str]| n.iter().map(|s| (s.to_string(), None)).collect::<Vec<_>>();
|
||||
let dynamic = Dynamic {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:enchantment".to_owned(),
|
||||
synced(&["minecraft:protection", "minecraft:sharpness"]),
|
||||
),
|
||||
(
|
||||
"minecraft:trim_material".to_owned(),
|
||||
synced(&["minecraft:gold"]),
|
||||
),
|
||||
(
|
||||
"minecraft:trim_pattern".to_owned(),
|
||||
synced(&["minecraft:sentry"]),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
stack.damage = 5;
|
||||
stack.enchant("minecraft:protection", 4);
|
||||
let d = stack.data_mut();
|
||||
d.stored_enchantments
|
||||
.push((Arc::from("minecraft:sharpness"), 2));
|
||||
d.repair_cost = 9;
|
||||
d.custom_name = Some("Keeper".to_owned());
|
||||
d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")));
|
||||
let id = |registry: &str, name: &str| {
|
||||
builtin
|
||||
.get(registry)
|
||||
.and_then(|ids| ids.id(name))
|
||||
.or_else(|| dynamic.id(registry, name))
|
||||
};
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| {
|
||||
builtin
|
||||
.get("minecraft:item")
|
||||
.and_then(|i| i.id(name))
|
||||
.unwrap_or(0)
|
||||
},
|
||||
id,
|
||||
&Some(stack.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let back = from_network(&wire, &builtin, &dynamic);
|
||||
assert_eq!(back.item, stack.item);
|
||||
assert_eq!(back.damage, 5);
|
||||
let d = back.data().expect("components");
|
||||
assert_eq!(d.enchantments, vec![(Arc::from("minecraft:protection"), 4)]);
|
||||
assert_eq!(
|
||||
d.stored_enchantments,
|
||||
vec![(Arc::from("minecraft:sharpness"), 2)]
|
||||
);
|
||||
assert_eq!(d.repair_cost, 9);
|
||||
assert_eq!(d.custom_name.as_deref(), Some("Keeper"));
|
||||
assert_eq!(
|
||||
d.trim,
|
||||
Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")))
|
||||
);
|
||||
// PotionContents: the potion is a built-in registry, as a hub's
|
||||
// `SyncData::id` treats it, so the id has to come from there.
|
||||
let potion = ItemStack::new("minecraft:splash_potion", 1).with_potion("swiftness");
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| id("minecraft:item", name).unwrap_or(0),
|
||||
id,
|
||||
&Some(potion),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
from_network(&wire, &builtin, &dynamic).potion(),
|
||||
Some("minecraft:swiftness")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -487,23 +487,31 @@ pub fn lines(
|
||||
) -> Vec<String> {
|
||||
let mut lines = vec![name_line(stack, translate)];
|
||||
if TooltipDisplay::for_stack(stack).shows("minecraft:lore") {
|
||||
lines.extend(lore_lines(stack));
|
||||
lines.extend(lore_texts(stack).into_iter().map(|t| t.plain()));
|
||||
}
|
||||
lines.extend(detail_lines(stack, info, translate));
|
||||
lines
|
||||
}
|
||||
|
||||
/// `LoreComponent.addToTooltip`: the saved component lines, as plain text.
|
||||
fn lore_lines(stack: &ItemStack) -> Vec<String> {
|
||||
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
|
||||
/// the component's own styles (including explicit `false`) winning on children.
|
||||
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let base = crate::text::Style {
|
||||
color: Some(crate::text::DARK_PURPLE),
|
||||
italic: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let json = crate::scoreboard::tag_to_json(entry);
|
||||
let text = crate::component::to_text(&json, &mut |s| crate::text::Text::literal(s));
|
||||
text.plain()
|
||||
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
|
||||
for span in &mut text.spans {
|
||||
span.style.inherit(&base);
|
||||
}
|
||||
text
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -512,6 +520,42 @@ fn lore_lines(stack: &ItemStack) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lore_keeps_component_colours_under_dark_purple_italic() {
|
||||
let mut stack = ItemStack::new("minecraft:stick", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:lore",
|
||||
Some(Tag::List(vec![
|
||||
Tag::compound([
|
||||
("text", Tag::String("Gold line".into())),
|
||||
("color", Tag::String("gold".into())),
|
||||
("italic", Tag::Byte(0)),
|
||||
]),
|
||||
Tag::String("Plain lore".into()),
|
||||
])),
|
||||
);
|
||||
let lines = lore_texts(&stack);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0].plain(), "Gold line");
|
||||
assert_eq!(lines[0].spans[0].style.color, Some(crate::text::GOLD));
|
||||
assert!(!lines[0].spans[0].style.is_italic());
|
||||
assert_eq!(lines[1].plain(), "Plain lore");
|
||||
assert_eq!(
|
||||
lines[1].spans[0].style.color,
|
||||
Some(crate::text::DARK_PURPLE)
|
||||
);
|
||||
assert!(lines[1].spans[0].style.is_italic());
|
||||
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:tooltip_display",
|
||||
Some(Tag::compound([(
|
||||
"hidden_components",
|
||||
Tag::List(vec![Tag::String("minecraft:lore".into())]),
|
||||
)])),
|
||||
);
|
||||
assert!(!TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_display_controls_name_and_component_visibility() {
|
||||
let mut stack = ItemStack::new("minecraft:diamond_sword", 1);
|
||||
|
||||
@@ -343,7 +343,21 @@ impl Hub {
|
||||
if !window_ok || self.guests[i].spectator() {
|
||||
return Vec::new();
|
||||
}
|
||||
// A menu that is no longer valid takes no clicks.
|
||||
if let Some(m) = self.guests[i].menu.as_ref().filter(|_| in_menu)
|
||||
&& !m.slots.still_valid(world, &self.guests[i], m.pos)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let source = self.guests[i].menu.as_ref().map(|m| m.slots);
|
||||
let shulker_box = self
|
||||
.sync
|
||||
.builtin
|
||||
.get("minecraft:menu")
|
||||
.and_then(|m| m.id("minecraft:shulker_box"));
|
||||
let shulker = in_menu
|
||||
&& shulker_box.is_some()
|
||||
&& self.guests[i].menu.as_ref().map(|m| m.kind) == shulker_box;
|
||||
let mut drag = if in_menu {
|
||||
self.guests[i].menu.as_mut().and_then(|m| m.drag.take())
|
||||
} else {
|
||||
@@ -357,6 +371,7 @@ impl Hub {
|
||||
let mut view = View {
|
||||
guest,
|
||||
container: items.as_deref_mut(),
|
||||
shulker,
|
||||
};
|
||||
let (drops, changed) = menu::click(
|
||||
&mut view,
|
||||
|
||||
@@ -223,6 +223,8 @@ pub struct Guest {
|
||||
/// Whether the guest was on the ground before the first of `moves`.
|
||||
pub moves_from_ground: bool,
|
||||
pub dead: bool,
|
||||
/// `LivingEntity.deathTime`: ticks since the guest died.
|
||||
pub death_time: u32,
|
||||
/// The join packets were sent (PlayerList.placeNewPlayer ran).
|
||||
pub placed: bool,
|
||||
/// The level the player is in (`ServerPlayer.level().dimension()`): the
|
||||
@@ -349,6 +351,7 @@ impl Guest {
|
||||
moves: Vec::new(),
|
||||
moves_from_ground: false,
|
||||
dead: false,
|
||||
death_time: 0,
|
||||
placed: false,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
menu: None,
|
||||
@@ -404,6 +407,17 @@ impl Guest {
|
||||
pub fn eye_height(&self) -> f64 {
|
||||
if self.sneaking { 1.27 } else { 1.62 }
|
||||
}
|
||||
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
|
||||
/// its block interaction range is 4.5, 5 in creative.
|
||||
pub fn within_block_range(&self, pos: [i32; 3], buffer: f64) -> bool {
|
||||
let eye = [
|
||||
self.position[0],
|
||||
self.position[1] + self.eye_height(),
|
||||
self.position[2],
|
||||
];
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
within_block_range(eye, range + buffer, pos)
|
||||
}
|
||||
/// The inventory menu's 46 slots (ServerPlayer.inventoryMenu): 0 crafting
|
||||
/// result, 1..=4 crafting grid, 5..=8 armor, 9..=35 main, 36..=44 hotbar,
|
||||
/// 45 off hand.
|
||||
@@ -431,6 +445,43 @@ impl Guest {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FireworkExplosion.STREAM_CODEC`'s fields (`Shape` by its BY_ID order).
|
||||
fn network_explosion(
|
||||
e: &crate::particles::FireworkExplosion,
|
||||
) -> horizon_protocol::item::FireworkExplosion {
|
||||
horizon_protocol::item::FireworkExplosion {
|
||||
shape: e.shape as i32,
|
||||
colors: e.colors.iter().map(|c| *c as i32).collect(),
|
||||
fade: e.fade.iter().map(|c| *c as i32).collect(),
|
||||
trail: e.trail,
|
||||
twinkle: e.twinkle,
|
||||
}
|
||||
}
|
||||
|
||||
/// A rocket's `minecraft:fireworks` and a star's `minecraft:firework_explosion`.
|
||||
pub fn firework_components(
|
||||
d: &crate::item_data::StackData,
|
||||
) -> Vec<(i32, horizon_protocol::item::Component)> {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
let mut out = Vec::new();
|
||||
if let Some((flight, explosions)) = &d.fireworks {
|
||||
out.push((
|
||||
component::FIREWORKS,
|
||||
Component::Fireworks {
|
||||
flight: i32::from(*flight),
|
||||
explosions: explosions.iter().map(network_explosion).collect(),
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(e) = &d.firework_explosion {
|
||||
out.push((
|
||||
component::FIREWORK_EXPLOSION,
|
||||
Component::FireworkExplosion(network_explosion(e)),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
|
||||
/// `synced_id` names an entry of a synced registry (enchantments, trim
|
||||
/// materials and patterns) by its id on this connection.
|
||||
@@ -521,6 +572,23 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
stack.added.extend(firework_components(d));
|
||||
// PotionContents: the potion by its `minecraft:potion` id.
|
||||
if let Some(id) = (d.potion.as_deref()).and_then(|potion| {
|
||||
synced_id(
|
||||
"minecraft:potion",
|
||||
&horizon_world::datafix::dynamic::namespaced(potion),
|
||||
)
|
||||
}) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::POTION_CONTENTS,
|
||||
horizon_protocol::item::Component::Potion {
|
||||
potion: Some(id),
|
||||
color: None,
|
||||
rest: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
// A loaded crossbow's ammunition.
|
||||
if !d.charged.is_empty() {
|
||||
let charged = d
|
||||
@@ -568,11 +636,41 @@ fn network_stack(
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `Player.isWithinBlockInteractionRange`: `eye` closer than `range` to the
|
||||
/// box of the block at `pos` (`AABB.distanceToSqr`).
|
||||
pub fn within_block_range(eye: [f64; 3], range: f64, pos: [i32; 3]) -> bool {
|
||||
(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>()
|
||||
< range * range
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::item::{Component, component};
|
||||
|
||||
#[test]
|
||||
fn potions_go_as_potion_registry_ids() {
|
||||
let synced_id = |registry: &str, name: &str| match (registry, name) {
|
||||
("minecraft:potion", "minecraft:swiftness") => Some(14),
|
||||
_ => None,
|
||||
};
|
||||
let potion = ItemStack::new("minecraft:splash_potion", 1).with_potion("swiftness");
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(potion)).unwrap();
|
||||
assert_eq!(
|
||||
stack.component(component::POTION_CONTENTS),
|
||||
Some(&Component::Potion {
|
||||
potion: Some(14),
|
||||
color: None,
|
||||
rest: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_go_as_synced_registry_references() {
|
||||
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
|
||||
@@ -149,6 +149,9 @@ pub struct Hub {
|
||||
pub singleplayer_owner: Option<u128>,
|
||||
/// Who rides what, set by the game before each tick.
|
||||
pub riding: super::entity::Riding,
|
||||
/// The scoreboard's teams, set by the game each tick, for players'
|
||||
/// display names.
|
||||
pub teams: Vec<crate::scoreboard::Team>,
|
||||
last_clock: i64,
|
||||
last_rate: f32,
|
||||
ticking: (f32, bool),
|
||||
@@ -206,6 +209,24 @@ pub fn display_name(uuid: u128, name: &str) -> Tag {
|
||||
])
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// Player.getDisplayName: the name formatted for the player's team
|
||||
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
|
||||
/// player on hover and inserting the name.
|
||||
pub fn player_display_name(&self, uuid: u128, name: &str) -> Tag {
|
||||
let Some(team) = self.teams.iter().find(|t| t.players.contains(name)) else {
|
||||
return display_name(uuid, name);
|
||||
};
|
||||
let mut c = team.formatted_name_comp(&crate::scoreboard::literal(name));
|
||||
c["click_event"] =
|
||||
serde_json::json!({"action": "suggest_command", "command": format!("/tell {name} ")});
|
||||
c["hover_event"] = serde_json::json!({"action": "show_entity", "id": "minecraft:player",
|
||||
"uuid": horizon_protocol::crypto::uuid_string(uuid), "name": name});
|
||||
c["insertion"] = name.into();
|
||||
crate::scoreboard::component_tag(&c)
|
||||
}
|
||||
}
|
||||
|
||||
/// A yellow translation of player names (`multiplayer.player.joined`).
|
||||
fn yellow_names(key: &str, names: Vec<Tag>) -> Tag {
|
||||
Tag::compound([
|
||||
@@ -315,6 +336,7 @@ impl Hub {
|
||||
entity_types: Default::default(),
|
||||
entities: Default::default(),
|
||||
entity_out: Vec::new(),
|
||||
teams: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2293,6 +2315,11 @@ impl Hub {
|
||||
.name("minecraft:enchantment", id)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
/// A stack a client sent (`SetCreativeModeSlot`), read with this
|
||||
/// server's registries.
|
||||
pub fn stack_from_network(&self, s: &horizon_protocol::item::Stack) -> crate::items::ItemStack {
|
||||
crate::item_sync::from_network(s, &self.sync.builtin, &self.sync.dynamic)
|
||||
}
|
||||
|
||||
/// ServerLevel.removePlayerImmediately, then addRespawnedPlayer: the other players
|
||||
/// lose the dead player's entity (ServerEntity.removePairing) and are sent the
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! handle it: chat sessions validated against the services' keys, signed
|
||||
//! messages checked link by link, the acknowledged window, and messages
|
||||
//! relayed with each connection's numbering and signature cache.
|
||||
use super::{HostEvent, Hub, display_name};
|
||||
use super::{HostEvent, Hub};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
secure_chat::{self, Body, Chain, ChatSession, DecodeError, ProfileKey, Signature, Unpacked},
|
||||
@@ -214,7 +214,7 @@ impl Hub {
|
||||
}
|
||||
let bound = play::ChatTypeBound {
|
||||
chat_type: self.chat_type,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
let line = format!("<{name}> {}", message.content);
|
||||
@@ -427,7 +427,7 @@ impl Hub {
|
||||
let (uuid, name) = (self.guests[i].uuid, self.guests[i].name.clone());
|
||||
let bound = play::ChatTypeBound {
|
||||
chat_type: self.chat_type_id(chat_type),
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
let line = match chat_type {
|
||||
@@ -467,15 +467,15 @@ impl Hub {
|
||||
self.chat_type_id("msg_command_incoming"),
|
||||
);
|
||||
for &t in targets {
|
||||
let target = display_name(self.guests[t].uuid, &self.guests[t].name);
|
||||
let target = self.player_display_name(self.guests[t].uuid, &self.guests[t].name);
|
||||
let out = play::ChatTypeBound {
|
||||
chat_type: outgoing,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: Some(target),
|
||||
};
|
||||
let inc = play::ChatTypeBound {
|
||||
chat_type: incoming,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
match &message {
|
||||
|
||||
@@ -64,6 +64,19 @@ impl Slots {
|
||||
.iter()
|
||||
.all(|k| world.block_entities.contains_key(k))
|
||||
}
|
||||
/// `Container.stillValid` for the guest: the block entities are there
|
||||
/// and the guest is within reach of each (`stillValidBlockEntity`). The
|
||||
/// ender chest's block is `at` (`PlayerEnderChestContainer.activeChest`).
|
||||
pub fn still_valid(self, world: &World, guest: &Guest, at: Option<(i32, i32, i32)>) -> bool {
|
||||
let blocks = match self {
|
||||
Slots::Ender => at.into_iter().collect(),
|
||||
_ => self.halves(),
|
||||
};
|
||||
self.valid(world)
|
||||
&& blocks
|
||||
.iter()
|
||||
.all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0))
|
||||
}
|
||||
/// Container slot `n`.
|
||||
pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option<ItemStack> {
|
||||
if self == Slots::Ender {
|
||||
@@ -111,6 +124,8 @@ pub struct View<'a> {
|
||||
pub guest: &'a mut Guest,
|
||||
/// The container's slots, or None for the inventory menu.
|
||||
pub container: Option<&'a mut [Option<ItemStack>]>,
|
||||
/// The container is a shulker box (`ShulkerBoxSlot`).
|
||||
pub shulker: bool,
|
||||
}
|
||||
|
||||
impl View<'_> {
|
||||
@@ -153,6 +168,12 @@ impl View<'_> {
|
||||
if self.container.is_none() && (5..=8).contains(&i) {
|
||||
return rules.armor_index(&stack.item) == Some(i - 5);
|
||||
}
|
||||
if self.shulker
|
||||
&& self.container.as_ref().is_some_and(|c| i < c.len())
|
||||
&& !crate::menu::fits_inside_container_items(stack)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.enabled(i)
|
||||
}
|
||||
fn slot_max(&self, i: usize, stack: &ItemStack, rules: &dyn Rules) -> u32 {
|
||||
@@ -665,6 +686,24 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// The prepared resources' items, or None (the test is skipped) without them.
|
||||
fn rules() -> Option<ItemRules> {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping resource-backed menu check: {}", root.display());
|
||||
return None;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
Some(ItemRules(Items::load(&root, ®istry, &facts).unwrap()))
|
||||
}
|
||||
|
||||
fn contents(stack: &Option<ItemStack>) -> Vec<(String, u32)> {
|
||||
let inside = stack.as_ref().and_then(|s| s.data());
|
||||
inside.map_or(Vec::new(), |d| {
|
||||
@@ -680,20 +719,9 @@ mod tests {
|
||||
/// slot; a stack clicked onto a bundle goes in, as far as it fits.
|
||||
#[test]
|
||||
fn guest_chest_clicks_fill_and_empty_bundles() {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping resource-backed bundle check: {}", root.display());
|
||||
let Some(rules) = rules() else {
|
||||
return;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
let rules = ItemRules(Items::load(&root, ®istry, &facts).unwrap());
|
||||
};
|
||||
let mut g = guest();
|
||||
let mut chest = vec![
|
||||
Some(ItemStack::new("minecraft:stone", 48)),
|
||||
@@ -704,6 +732,7 @@ mod tests {
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut chest),
|
||||
shulker: false,
|
||||
};
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1));
|
||||
// Left click with the bundle: the stone goes in.
|
||||
@@ -736,4 +765,37 @@ mod tests {
|
||||
);
|
||||
assert_eq!(contents(&view.get(2)), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guest_shulker_box_menu_refuses_shulker_boxes() {
|
||||
let Some(rules) = rules() else {
|
||||
return;
|
||||
};
|
||||
for shulker in [true, false] {
|
||||
let mut g = guest();
|
||||
let mut container = vec![None; 27];
|
||||
let mut drag = None;
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut container),
|
||||
shulker,
|
||||
};
|
||||
// Carried onto an empty slot, shift-clicked from the hotbar
|
||||
// (menu slot 27 + 27) and swapped in with the number key 1.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:red_shulker_box", 1));
|
||||
click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.get(0).is_none(), shulker);
|
||||
view.guest.carried = None;
|
||||
view.guest.inventory[0] = Some(ItemStack::new("minecraft:shulker_box", 1));
|
||||
click(&mut view, &mut drag, 54, 0, input::QUICK_MOVE, &rules);
|
||||
assert_eq!(view.guest.inventory[0].is_some(), shulker);
|
||||
view.guest.inventory[0] = Some(ItemStack::new("minecraft:blue_shulker_box", 1));
|
||||
click(&mut view, &mut drag, 5, 0, input::SWAP, &rules);
|
||||
assert_eq!(view.get(5).is_none(), shulker);
|
||||
// Anything else goes in.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:diamond", 1));
|
||||
click(&mut view, &mut drag, 9, 0, input::PICKUP, &rules);
|
||||
assert!(view.get(9).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,11 @@ impl Language {
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.map.get(key).map(String::as_str)
|
||||
}
|
||||
/// The resolved map, for `horizon_protocol::text::plain`: a server's
|
||||
/// components carry keys, and the network layer resolves them with this.
|
||||
pub fn map(&self) -> &HashMap<String, String> {
|
||||
&self.map
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ pub mod inv_sort;
|
||||
pub mod inventory_effects;
|
||||
pub mod item_data;
|
||||
pub mod item_render;
|
||||
pub mod item_sync;
|
||||
pub mod item_tooltip;
|
||||
pub mod items;
|
||||
pub mod java_text;
|
||||
|
||||
@@ -247,6 +247,12 @@ pub fn beacon_required_levels(effect: Option<&str>) -> i32 {
|
||||
.map_or(i32::MAX, |i| i as i32 + 1)
|
||||
}
|
||||
|
||||
/// `Item.canFitInsideContainerItems` (`ShulkerBoxSlot.mayPlace`): shulker
|
||||
/// boxes cannot nest.
|
||||
pub fn fits_inside_container_items(stack: &ItemStack) -> bool {
|
||||
!stack.item.ends_with("shulker_box")
|
||||
}
|
||||
|
||||
/// The containers a menu spans.
|
||||
const PLAYER: usize = 0;
|
||||
const MAIN: usize = 1;
|
||||
@@ -995,7 +1001,7 @@ impl Menu {
|
||||
fn anvil_result(&mut self, env: &Env) {
|
||||
let hover = self.containers[MAIN][0]
|
||||
.as_ref()
|
||||
.map(|i| crate::stations::hover_name(&|id| env.names.item(id), i))
|
||||
.map(|i| crate::stations::hover_name(&|id| env.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
let out = crate::stations::anvil_result(
|
||||
env.enchantments,
|
||||
@@ -1210,11 +1216,6 @@ impl Menu {
|
||||
env.items.get(&stack.item).is_some_and(|i| i.fuel.is_some())
|
||||
}
|
||||
|
||||
/// `Item.canFitInsideContainerItems`: shulker boxes cannot nest.
|
||||
fn fits_inside(stack: &ItemStack) -> bool {
|
||||
!(stack.item.ends_with("shulker_box"))
|
||||
}
|
||||
|
||||
// ---- Slot ----
|
||||
|
||||
fn may_place(&self, env: &Env, s: usize, stack: &ItemStack) -> bool {
|
||||
@@ -1247,7 +1248,7 @@ impl Menu {
|
||||
.as_deref()
|
||||
.is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m)))
|
||||
}),
|
||||
Role::Shulker => Self::fits_inside(stack),
|
||||
Role::Shulker => fits_inside_container_items(stack),
|
||||
Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack),
|
||||
Role::Output | Role::CraftResult | Role::Inert => false,
|
||||
Role::CrafterIn => self.data.get(self.slots[s].index) != Some(&1),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! behaviour, models, animations and sounds.
|
||||
pub mod ai;
|
||||
pub mod allay;
|
||||
pub mod ambient;
|
||||
pub mod anim;
|
||||
pub mod animal_brain;
|
||||
pub mod aquatic;
|
||||
@@ -1646,6 +1647,11 @@ impl Mob {
|
||||
pub fn species(&self) -> Species {
|
||||
self.kind.species
|
||||
}
|
||||
/// `Mob.getMainArm`: the left arm when the mob is left-handed, a player's
|
||||
/// `mainHand` option for a player mob.
|
||||
pub fn main_arm_left(&self) -> bool {
|
||||
self.extra.main_hand_left || self.extra.left_handed
|
||||
}
|
||||
/// The player hit this mob (melee): LivingEntity.hurt with knockback.
|
||||
pub fn hurt(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! What a client's copy of a mob makes on its own, in the
|
||||
//! `level().isClientSide()` branches of `aiStep`: nothing of it reaches the
|
||||
//! server or goes over the network, so the window's game runs it, whether it
|
||||
//! simulates the world (in-frame) or follows a server.
|
||||
use super::{Mob, MobEvent, Species};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// `Enderman.aiStep`, `Endermite.aiStep` and `Blaze.aiStep` on a client:
|
||||
/// two portal particles a tick around endermen and endermites; two large
|
||||
/// smoke particles a tick around blazes and, one tick in 24, their burn.
|
||||
pub fn client_ai_step(m: &mut Mob, out: &mut Vec<MobEvent>) {
|
||||
let species = m.species();
|
||||
if !matches!(
|
||||
species,
|
||||
Species::Enderman | Species::Endermite | Species::Blaze
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
|
||||
let p = m.position;
|
||||
if species == Species::Blaze && m.random.next_int_bound(24).unwrap_or(1) == 0 && !m.silent {
|
||||
let volume = 1.0 + m.random.next_float();
|
||||
let pitch = m.random.next_float() * 0.7 + 0.3;
|
||||
out.push(MobEvent::LocalSound(
|
||||
"entity.blaze.burn",
|
||||
[p.x + 0.5, p.y + 0.5, p.z + 0.5],
|
||||
volume,
|
||||
pitch,
|
||||
));
|
||||
}
|
||||
for _ in 0..2 {
|
||||
// Entity.getRandomX(0.5), getRandomY(), getRandomZ(0.5).
|
||||
let r = &mut m.random;
|
||||
let x = p.x + w * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let y = p.y + h * r.next_double();
|
||||
let z = p.z + w * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let (kind, at, velocity) = if species == Species::Blaze {
|
||||
("large_smoke", [x, y, z], [0.0; 3])
|
||||
} else {
|
||||
// The enderman's are a quarter block lower.
|
||||
let y = if species == Species::Enderman {
|
||||
y - 0.25
|
||||
} else {
|
||||
y
|
||||
};
|
||||
let velocity = [
|
||||
(r.next_double() - 0.5) * 2.0,
|
||||
-r.next_double(),
|
||||
(r.next_double() - 0.5) * 2.0,
|
||||
];
|
||||
("portal", [x, y, z], velocity)
|
||||
};
|
||||
out.push(MobEvent::ParticleAt { kind, at, velocity });
|
||||
}
|
||||
}
|
||||
@@ -642,7 +642,7 @@ pub fn setup(
|
||||
} else {
|
||||
ArmPose::Empty
|
||||
};
|
||||
let mut h = if m.extra.main_hand_left {
|
||||
let mut h = if m.main_arm_left() {
|
||||
Hum::new(ArmPose::Empty, main)
|
||||
} else {
|
||||
Hum::new(main, ArmPose::Empty)
|
||||
@@ -693,7 +693,7 @@ pub fn setup(
|
||||
} else {
|
||||
ArmPose::Empty
|
||||
};
|
||||
let mut h = if m.extra.main_hand_left {
|
||||
let mut h = if m.main_arm_left() {
|
||||
Hum::new(off, pose)
|
||||
} else {
|
||||
Hum::new(pose, off)
|
||||
|
||||
@@ -621,8 +621,10 @@ impl BowAttack {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.isHolding(Items.BOW)`: the bow can sit in either hand.
|
||||
fn holding_bow(m: &Mob) -> bool {
|
||||
m.extra.main_hand.as_deref() == Some("minecraft:bow")
|
||||
|| m.extra.off_hand.as_deref() == Some("minecraft:bow")
|
||||
}
|
||||
impl Goal for BowAttack {
|
||||
goal_clone!();
|
||||
@@ -715,7 +717,10 @@ impl Goal for BowAttack {
|
||||
} else {
|
||||
self.attack_time -= 1;
|
||||
if self.attack_time <= 0 && self.see_time >= -60 {
|
||||
// mob.startUsingItem(ProjectileUtil.getWeaponHoldingHand):
|
||||
// the off hand when the main one doesn't hold the bow.
|
||||
m.extra.using_item = 1;
|
||||
m.extra.using_off_hand = m.extra.main_hand.as_deref() != Some("minecraft:bow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Installed {
|
||||
pub problem: Option<String>,
|
||||
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
|
||||
pub dev: bool,
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
}
|
||||
|
||||
impl ModList {
|
||||
@@ -96,6 +99,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
};
|
||||
let mut list = ModList {
|
||||
installed: vec![installed("a", false), installed("b", true)],
|
||||
|
||||
@@ -167,6 +167,9 @@ pub enum MotionEvent {
|
||||
InsidePortal {
|
||||
pos: BlockPos,
|
||||
},
|
||||
/// `WitherRoseBlock.entityInside`: the player's box swept through a
|
||||
/// wither rose; the server adds its Wither unless peaceful.
|
||||
InsideWitherRose,
|
||||
/// `ServerPlayer.checkFallDamage` after a mace smash: block particles
|
||||
/// from the landing block, more the further the fall.
|
||||
SmashLanding {
|
||||
@@ -1083,11 +1086,26 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
/// What `applyEffectsFromBlocks` does to a server's player along the
|
||||
/// moves its client sent since `from` (in its history): the damage of
|
||||
/// magma, cactus, lit campfires and grown sweet berry bushes. Its client
|
||||
/// moves it, so the slowing and the rest are the client's.
|
||||
/// magma, cactus, lit campfires and grown sweet berry bushes, and powder
|
||||
/// snow's FREEZE, then `LivingEntity.aiStep`'s freezing and its damage.
|
||||
/// Its client moves it, so the slowing and the rest are the client's.
|
||||
pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> {
|
||||
self.player.old_position = from;
|
||||
self.effects_from_blocks(true)
|
||||
// Entity.baseTick
|
||||
self.player.was_in_powder_snow = self.player.in_powder_snow;
|
||||
self.player.in_powder_snow = false;
|
||||
self.effects_from_blocks(true)?;
|
||||
// LivingEntity.aiStep "freezing" (server).
|
||||
if !self.player.in_powder_snow || !self.player.can_freeze() {
|
||||
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
|
||||
}
|
||||
if self.player.health.tick_count % 40 == 0
|
||||
&& self.player.is_fully_frozen()
|
||||
&& self.player.can_freeze()
|
||||
{
|
||||
self.freeze_damage()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> {
|
||||
use horizon_world::entity_move::MovementRecord;
|
||||
@@ -1191,7 +1209,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
groups += 1;
|
||||
last_step = step;
|
||||
}
|
||||
if groups == 1 {
|
||||
if groups == 1 && !damage_only {
|
||||
// A later group finds the fire already out.
|
||||
self.player.powder_snow_entered.push(p);
|
||||
}
|
||||
@@ -1259,21 +1277,22 @@ impl MotionWorld<'_, '_, '_> {
|
||||
continue;
|
||||
}
|
||||
let powder_snow = name == "minecraft:powder_snow";
|
||||
let lava_cauldron = name == "minecraft:lava_cauldron";
|
||||
// getEntityInsideCollisionShape is the whole block but for powder
|
||||
// snow (its collision shape, the whole block when it is empty),
|
||||
// filled cauldrons, end portals and tripwires; Horizon doesn't
|
||||
// follow the player into those last three.
|
||||
// follow the player into the water and powder snow cauldrons, end
|
||||
// portals or tripwires yet.
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:water_cauldron"
|
||||
| "minecraft:lava_cauldron"
|
||||
| "minecraft:powder_snow_cauldron"
|
||||
| "minecraft:end_portal"
|
||||
| "minecraft:tripwire"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let inside = !powder_snow || {
|
||||
let inside = !(powder_snow || lava_cauldron) || {
|
||||
let context = self.context();
|
||||
let binding = self.collision_binding;
|
||||
let shape = binding.collision_shape_with_context(
|
||||
@@ -1282,21 +1301,33 @@ impl MotionWorld<'_, '_, '_> {
|
||||
p,
|
||||
&context,
|
||||
)?;
|
||||
shape.is_empty() || shape.boxes() == VoxelShape::block().boxes() || {
|
||||
let boxes: Vec<_> = shape
|
||||
.moved(Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z)))
|
||||
.boxes()
|
||||
.to_vec();
|
||||
self.player
|
||||
.dimensions
|
||||
.bounds(from)
|
||||
.collided_along_vector(to.subtract(from), &boxes)
|
||||
}
|
||||
powder_snow && (shape.is_empty() || shape.boxes() == VoxelShape::block().boxes())
|
||||
|| {
|
||||
let offset = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
|
||||
let mut boxes: Vec<_> = shape.moved(offset).boxes().to_vec();
|
||||
if lava_cauldron {
|
||||
// LavaCauldronBlock.FILLED_SHAPE: the cauldron's
|
||||
// shape or its lava, Block.column(12, 4, 15).
|
||||
boxes.push(Aabb::new(
|
||||
Vec3::new(2.0 / 16.0, 4.0 / 16.0, 2.0 / 16.0).add_vector(offset),
|
||||
Vec3::new(14.0 / 16.0, 15.0 / 16.0, 14.0 / 16.0).add_vector(offset),
|
||||
));
|
||||
}
|
||||
self.player
|
||||
.dimensions
|
||||
.bounds(from)
|
||||
.collided_along_vector(to.subtract(from), &boxes)
|
||||
}
|
||||
};
|
||||
if inside && visited.insert(p.pack()) {
|
||||
// CactusBlock and CampfireBlock.entityInside (`entity.hurt`).
|
||||
// CactusBlock, CampfireBlock and LavaCauldronBlock.entityInside
|
||||
// (`entity.hurt`), and WitherRoseBlock's server-side effect.
|
||||
match name {
|
||||
"minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?,
|
||||
"minecraft:lava_cauldron" => self.lava_cauldron_inside()?,
|
||||
"minecraft:wither_rose" => {
|
||||
self.player.events.push(MotionEvent::InsideWitherRose);
|
||||
}
|
||||
"minecraft:campfire" | "minecraft:soul_campfire" => {
|
||||
let lit = self
|
||||
.assets
|
||||
@@ -1316,6 +1347,8 @@ impl MotionWorld<'_, '_, '_> {
|
||||
"minecraft:sweet_berry_bush" if damage_only => {
|
||||
self.sweet_berry_bush_damage(state)?;
|
||||
}
|
||||
// PowderSnowBlock.entityInside's FREEZE.
|
||||
"minecraft:powder_snow" if damage_only => entered.push((step, p)),
|
||||
_ => {}
|
||||
}
|
||||
if damage_only {
|
||||
@@ -1354,6 +1387,14 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
self.player.impulse.try_reset();
|
||||
}
|
||||
/// `LavaCauldronBlock.entityInside`: CLEAR_FREEZE, LAVA_IGNITE
|
||||
/// (`Entity.lavaIgnite`, 15 seconds) and then `Entity.lavaHurt` (lava,
|
||||
/// 4), as ordinary lava does to the player.
|
||||
fn lava_cauldron_inside(&mut self) -> Result<()> {
|
||||
self.player.ticks_frozen = 0;
|
||||
self.player.ignite_for_ticks(300)?;
|
||||
self.environment_hurt("minecraft:lava", 4.0)
|
||||
}
|
||||
/// `WebBlock.entityInside`: stuck at a quarter of the speed, half with
|
||||
/// the Weaving effect.
|
||||
fn web_inside(&mut self) {
|
||||
@@ -2368,6 +2409,124 @@ mod tests {
|
||||
assert_eq!(player.position.y, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lava_cauldron_burns_the_player_inside_it() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
let cauldron = assets
|
||||
.registry
|
||||
.default_state("minecraft:lava_cauldron")
|
||||
.unwrap();
|
||||
scene.set(20, 4, 20, cauldron).unwrap();
|
||||
let mut tick = |player: &mut PlayerMotion| {
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.controlled_simulation_tick(Default::default())
|
||||
.unwrap();
|
||||
};
|
||||
// Standing on the cauldron's floor, in its lava.
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.25, 20.5)).unwrap();
|
||||
player.ticks_frozen = 40;
|
||||
tick(&mut player);
|
||||
assert!(player.health.health() < 20.0, "the lava must hurt");
|
||||
assert!(player.fire_ticks > 0, "the lava must ignite");
|
||||
assert_eq!(player.ticks_frozen, 0);
|
||||
// Beside it, nothing.
|
||||
let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap();
|
||||
tick(&mut away);
|
||||
assert_eq!(away.health.health(), 20.0);
|
||||
assert!(away.fire_ticks <= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standing_in_a_wither_rose_asks_for_its_effect() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
let rose = assets
|
||||
.registry
|
||||
.default_state("minecraft:wither_rose")
|
||||
.unwrap();
|
||||
scene.set(20, 4, 20, rose).unwrap();
|
||||
let roses = |player: &PlayerMotion| {
|
||||
player
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MotionEvent::InsideWitherRose))
|
||||
.count()
|
||||
};
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.controlled_simulation_tick(Default::default())
|
||||
.unwrap();
|
||||
assert_eq!(roses(&player), 1);
|
||||
// A server's player: its client moved it, the server applies the effect.
|
||||
player.events.clear();
|
||||
let from = player.position;
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.server_block_damage(from)
|
||||
.unwrap();
|
||||
assert_eq!(roses(&player), 1);
|
||||
// Two blocks away, nothing.
|
||||
let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap();
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut away,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.controlled_simulation_tick(Default::default())
|
||||
.unwrap();
|
||||
assert_eq!(roses(&away), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_travel_moves_only_the_velocity() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -686,8 +686,14 @@ pub fn json_text(v: &serde_json::Value, lang: &Language) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// en_us.json from the prepared resources.
|
||||
/// The language a session resolves server-sent components with: the selected
|
||||
/// one as `lang` loaded it (`en_us` under it, packs included), so a menu title
|
||||
/// the server sends as a key follows the language screen. A process with no
|
||||
/// language set (a dedicated server's log) reads en_us.json.
|
||||
pub fn load_language(resources: &std::path::Path) -> Language {
|
||||
if let Some(lang) = crate::lang::current() {
|
||||
return lang.map().clone();
|
||||
}
|
||||
std::fs::read(resources.join("assets/minecraft/lang/en_us.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
|
||||
@@ -893,6 +893,9 @@ enum Act {
|
||||
ResetPadKey(&'static str),
|
||||
ResetPadKeys,
|
||||
ToggleMod(usize),
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
ModSettings(usize),
|
||||
OpenModFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
@@ -944,6 +947,9 @@ pub enum Event {
|
||||
Packs(Vec<String>),
|
||||
OpenPackFolder,
|
||||
OpenModFolder,
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
@@ -1503,15 +1509,26 @@ impl OptionsUi {
|
||||
} else if installed.dev {
|
||||
label = label.append(" (Dev)");
|
||||
}
|
||||
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
|
||||
Row::Buttons(
|
||||
if installed.dev || installed.id.is_empty() {
|
||||
inactive(widget)
|
||||
} else {
|
||||
widget
|
||||
},
|
||||
None,
|
||||
)
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
// `ui`) gets its own button beside the toggle.
|
||||
if installed.has_settings {
|
||||
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
|
||||
let settings = button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Settings"),
|
||||
Act::ModSettings(i),
|
||||
);
|
||||
Row::Buttons(
|
||||
if dead { inactive(toggle) } else { toggle },
|
||||
Some(settings),
|
||||
)
|
||||
} else {
|
||||
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
|
||||
Row::Buttons(if dead { inactive(widget) } else { widget }, None)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -2498,6 +2515,10 @@ impl OptionsUi {
|
||||
s.mods.toggle(i);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
events.push(Event::ButtonClick);
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
@@ -2988,6 +3009,7 @@ mod tests {
|
||||
description: "May show HUD panels.".into(),
|
||||
problem: id.is_empty().then(|| "Invalid package".into()),
|
||||
dev,
|
||||
has_settings: false,
|
||||
});
|
||||
}
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
|
||||
@@ -18,7 +18,10 @@ use horizon_core::{
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
macro_rules! particle_types {
|
||||
($($variant:ident = $name:literal),* $(,)?) => {
|
||||
@@ -196,6 +199,35 @@ impl ParticleType {
|
||||
| Self::OminousSpawning
|
||||
)
|
||||
}
|
||||
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
|
||||
/// hardcodes the translucent layer, drawn after translucent terrain.
|
||||
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
|
||||
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
|
||||
/// sprite set on the engine.
|
||||
pub fn translucent_layer(self) -> bool {
|
||||
match self {
|
||||
// CampfireSmokeParticle.
|
||||
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
|
||||
// PlayerCloudParticle.
|
||||
| Self::Cloud | Self::Sneeze
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
|
||||
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
|
||||
| Self::Vibration | Self::Firefly | Self::Flash
|
||||
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
|
||||
// SparkParticle (firework), SquidInkParticle, TotemParticle.
|
||||
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
|
||||
| Self::TotemOfUndying
|
||||
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
|
||||
| Self::VaultConnection => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -1147,7 +1179,9 @@ pub struct ParticleEngine {
|
||||
pub requested: Option<Vec<(ParticleOptions, [f64; 3], [f64; 3])>>,
|
||||
names: Vec<String>,
|
||||
rects: Vec<Option<Rect>>,
|
||||
atlas: HashMap<String, Rect>,
|
||||
/// Each named sprite, and whether `SingleQuadParticle.Layer.bySprite` puts
|
||||
/// it on the translucent layer (a texel with 0 < alpha < 255).
|
||||
atlas: HashMap<String, (Rect, bool)>,
|
||||
sets: HashMap<ParticleType, u32>,
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
@@ -1167,7 +1201,7 @@ pub struct ParticleEngine {
|
||||
/// `ParticleGroup.MAX_PARTICLES` (per render group; one group here).
|
||||
const MAX_PARTICLES: usize = 16384;
|
||||
impl ParticleEngine {
|
||||
pub fn new(root: &Path, atlas: &HashMap<String, Rect>) -> Self {
|
||||
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
|
||||
let mut engine = Self {
|
||||
particles: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
@@ -1178,7 +1212,10 @@ impl ParticleEngine {
|
||||
requested: None,
|
||||
names: Vec::new(),
|
||||
rects: Vec::new(),
|
||||
atlas: atlas.clone(),
|
||||
atlas: atlas
|
||||
.iter()
|
||||
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
|
||||
.collect(),
|
||||
sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
emitters: Vec::new(),
|
||||
@@ -1220,7 +1257,7 @@ impl ParticleEngine {
|
||||
return i as u32;
|
||||
}
|
||||
self.names.push(name.to_owned());
|
||||
self.rects.push(self.atlas.get(name).copied());
|
||||
self.rects.push(self.atlas.get(name).map(|&(rect, _)| rect));
|
||||
(self.names.len() - 1) as u32
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -3760,9 +3797,18 @@ impl ParticleEngine {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let rect = match &p.sprite {
|
||||
Sprite::Index(i) => self.rects.get(*i as usize).copied().flatten(),
|
||||
Sprite::Named(name) => self.atlas.get(name).copied(),
|
||||
// `SubmitNodeCollection.submitQuadParticleGroup`: the translucent
|
||||
// layer draws after translucent terrain, so a crumb of stained
|
||||
// glass cannot hide the glass behind it (`getLayer`).
|
||||
let (rect, layer) = match &p.sprite {
|
||||
Sprite::Index(i) => (
|
||||
self.rects.get(*i as usize).copied().flatten(),
|
||||
p.ty.translucent_layer(),
|
||||
),
|
||||
Sprite::Named(name) => match self.atlas.get(name) {
|
||||
Some(&(rect, translucent)) => (Some(rect), translucent),
|
||||
None => (None, false),
|
||||
},
|
||||
};
|
||||
let Some(r) = rect else { continue };
|
||||
let a = f64::from(partial);
|
||||
@@ -3867,7 +3913,11 @@ impl ParticleEngine {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
layer: if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
},
|
||||
color,
|
||||
light,
|
||||
});
|
||||
@@ -3891,7 +3941,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(0, 0, 0);
|
||||
// Destination offset (-15, 7, 15); the unused top byte is ignored.
|
||||
engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, [16, 8, 16], 0);
|
||||
@@ -3916,7 +3966,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn consume_teleport_unpacks_offsets_within_127() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(0, 0, 0);
|
||||
// TeleportRandomlyConsumeEffect: BlockUtil.clampedPackDifferenceInPosition
|
||||
// of the move (-7, -3, 9) with radii 127, from (4, 12, -5) to (-3, 9, 4).
|
||||
@@ -3941,7 +3991,8 @@ mod tests {
|
||||
(2, 0, 99_413_408_083_437),
|
||||
] {
|
||||
for distant in [false, true] {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
|
||||
let mut engine =
|
||||
ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(0, 0, 0);
|
||||
if distant {
|
||||
engine.camera = [1000.0; 3];
|
||||
|
||||
@@ -488,7 +488,12 @@ pub fn encode_block_entity(
|
||||
entries.push(("cooking_time_spent", Tag::Int(f.cooking_timer)));
|
||||
entries.push(("cooking_total_time", Tag::Int(f.cooking_total)));
|
||||
entries.push(("speed_multiplier", Tag::Float(f.speed.to_bits())));
|
||||
entries.push(("RecipesUsed", Tag::Compound(Compound::new())));
|
||||
let used: Compound = f
|
||||
.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (id.as_str().into(), Tag::Int(*count as i32)))
|
||||
.collect();
|
||||
entries.push(("RecipesUsed", Tag::Compound(used)));
|
||||
}
|
||||
BlockEntity::Brewing(b) => {
|
||||
entries.push(("Items", encode_items(&b.items)));
|
||||
@@ -640,6 +645,17 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
if let Some(speed) = float(tag.get("speed_multiplier")) {
|
||||
f.speed = speed as f32;
|
||||
}
|
||||
// A save without it owes nothing: the field is newer than some worlds.
|
||||
if let Some(Tag::Compound(used)) = tag.get("RecipesUsed") {
|
||||
for (id, count) in used {
|
||||
let count = match count {
|
||||
Tag::Int(n) => *n,
|
||||
_ => continue,
|
||||
};
|
||||
f.recipes_used
|
||||
.insert(id.to_string_lossy(), count.max(0) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockEntity::Brewing(b) => {
|
||||
let items = decode_items(tag.get("Items"), 5);
|
||||
@@ -4276,4 +4292,21 @@ mod tests {
|
||||
.unwrap_or(0);
|
||||
assert_eq!(flying_survival, 0);
|
||||
}
|
||||
|
||||
/// `RecipesUsed` is what a furnace owes in experience, so a save that drops it pays the
|
||||
/// player nothing after a reload.
|
||||
#[test]
|
||||
fn a_furnaces_recipes_used_survive_a_save() {
|
||||
use crate::containers::BlockEntity;
|
||||
let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else {
|
||||
panic!("a furnace");
|
||||
};
|
||||
f.recipes_used
|
||||
.insert("minecraft:cooked_porkchop".to_owned(), 3);
|
||||
let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace");
|
||||
let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else {
|
||||
panic!("a furnace back");
|
||||
};
|
||||
assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3));
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@
|
||||
// atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear
|
||||
// sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half
|
||||
// a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel
|
||||
// costs what it did with nearest and repeat only: one sample.
|
||||
// costs what it did with nearest and repeat only: one sample. Each entry
|
||||
// point reads `atlas` through one sampler only: GLSL (wgpu's OpenGL backend)
|
||||
// combines a texture with its sampler, and an override would not remove the
|
||||
// other sampler's branch.
|
||||
@group(0) @binding(2) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var linear_sampler: sampler;
|
||||
override BLUR: bool = false;
|
||||
override CLAMP: bool = false;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@@ -32,28 +34,36 @@ struct Out {
|
||||
out.edges = vec4(rect.xy + half, rect.zw - half);
|
||||
return out;
|
||||
}
|
||||
fn sample(in: Out) -> vec4<f32> {
|
||||
// The sprite's atlas coordinates of this pixel.
|
||||
fn atlas_uv(in: Out) -> vec2<f32> {
|
||||
var f = in.uv;
|
||||
if !CLAMP {
|
||||
// Repeat: one period of the sprite.
|
||||
f = fract(f);
|
||||
}
|
||||
var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
if CLAMP {
|
||||
uv = clamp(uv, in.edges.xy, in.edges.zw);
|
||||
return clamp(uv, in.edges.xy, in.edges.zw);
|
||||
}
|
||||
if BLUR {
|
||||
return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color;
|
||||
}
|
||||
return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color;
|
||||
return uv;
|
||||
}
|
||||
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = sample(in);
|
||||
fn screen(c: vec4<f32>) -> vec4<f32> {
|
||||
if c.a <= 0.0 { discard; }
|
||||
return c;
|
||||
}
|
||||
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
|
||||
return screen(textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color);
|
||||
}
|
||||
@fragment fn fs_screen_blur(in: Out) -> @location(0) vec4<f32> {
|
||||
return screen(textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color);
|
||||
}
|
||||
// RenderPipelines.VIGNETTE blends ZERO, ONE_MINUS_SRC_COLOR: output the
|
||||
// texture colour; the blend state darkens the frame by it.
|
||||
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
|
||||
return vec4(sample(in).rgb, 1.0);
|
||||
let c = textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color;
|
||||
return vec4(c.rgb, 1.0);
|
||||
}
|
||||
@fragment fn fs_vignette_blur(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color;
|
||||
return vec4(c.rgb, 1.0);
|
||||
}
|
||||
@@ -619,26 +619,7 @@ impl TextField {
|
||||
/// `getWordPosition(dir, from)`: past the next space run, or back to the
|
||||
/// start of the word.
|
||||
pub fn word_position(&self, dir: isize, from: usize) -> usize {
|
||||
let v: Vec<char> = self.value.chars().collect();
|
||||
let mut at = from;
|
||||
if dir > 0 {
|
||||
at = v
|
||||
.iter()
|
||||
.skip(at)
|
||||
.position(|c| *c == ' ')
|
||||
.map_or(v.len(), |p| p + at);
|
||||
while at < v.len() && v[at] == ' ' {
|
||||
at += 1;
|
||||
}
|
||||
} else {
|
||||
while at > 0 && v[at - 1] == ' ' {
|
||||
at -= 1;
|
||||
}
|
||||
while at > 0 && v[at - 1] != ' ' {
|
||||
at -= 1;
|
||||
}
|
||||
}
|
||||
at
|
||||
crate::ui::word_position(&self.value, dir, from)
|
||||
}
|
||||
/// `keyPressed` of a focused, editable box: `Some(changed)` when handled.
|
||||
pub fn key(
|
||||
|
||||
@@ -2148,8 +2148,13 @@ mod cover_tests {
|
||||
}
|
||||
|
||||
/// Every question streaming asks gets the same answer with the grid.
|
||||
///
|
||||
/// A debug build checks every fourth cell of each row, shifted by row and case so the cases still cover every
|
||||
/// cell's position relative to the areas: the full check took 20 of the ~30 s of the crate's tests.
|
||||
/// `cargo test --release -p horizon-client --lib the_grid_answers_as_the_scan` checks every cell.
|
||||
#[test]
|
||||
fn the_grid_answers_as_the_scan() {
|
||||
let stride = if cfg!(debug_assertions) { 4 } else { 1 };
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
|
||||
for case in 0..60 {
|
||||
@@ -2165,7 +2170,8 @@ mod cover_tests {
|
||||
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
|
||||
let span = [4, 30, 120][case % 3] + 40;
|
||||
for z in -span..=span {
|
||||
for x in -span..=span {
|
||||
let first = -span + (z + case as i32).rem_euclid(stride);
|
||||
for x in (first..=span).step_by(stride as usize) {
|
||||
for m in 0..=ExtraCover::REACH + 2 {
|
||||
assert_eq!(
|
||||
scan.in_extra(x, z, m),
|
||||
|
||||
@@ -428,6 +428,15 @@ impl Text {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `resolve_legacy` borrowing `self` when no span holds a `§` code (the
|
||||
/// common case).
|
||||
pub fn resolved(&self) -> std::borrow::Cow<'_, Text> {
|
||||
if self.spans.iter().any(|s| s.text.contains('§')) {
|
||||
std::borrow::Cow::Owned(self.resolve_legacy())
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(self)
|
||||
}
|
||||
}
|
||||
/// The text without its legacy `§` codes (ChatFormatting.stripFormatting:
|
||||
/// a `§` and a code character, `0`-`9`, `a`-`f`, `k`-`o` or `r`).
|
||||
pub fn strip_legacy(&self) -> Text {
|
||||
@@ -695,4 +704,20 @@ mod tests {
|
||||
assert_eq!(t.spans[1].style.color, Some(GOLD));
|
||||
assert_eq!(t.slice(1, 2).plain(), "y");
|
||||
}
|
||||
#[test]
|
||||
fn resolved_borrows_clean_text_and_restyles_codes() {
|
||||
let clean = Text::colored("Steve", GOLD);
|
||||
assert!(matches!(clean.resolved(), std::borrow::Cow::Borrowed(_)));
|
||||
// A colour code inside a span clears its decorations (a bold
|
||||
// "§cSteve" draws "Steve" red, not bold).
|
||||
let styled = Style {
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
let styled = Text::styled("§cSteve", styled);
|
||||
let resolved = styled.resolved();
|
||||
assert_eq!(resolved.plain(), "Steve");
|
||||
assert_eq!(resolved.spans[0].style.color, Some(RED));
|
||||
assert_eq!(resolved.spans[0].style.bold, Some(false));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user