mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Merge remote-tracking branch 'origin/main' into pr-5321
This commit is contained in:
commit
aa68b6653f
379 files changed
+39339
-6208
No files matched your search
@@ -9,7 +9,9 @@ permissions:
|
||||
actions: write
|
||||
jobs:
|
||||
cancel:
|
||||
runs-on: ubuntu-latest
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
|
||||
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Cancel the pull request's unfinished runs
|
||||
env:
|
||||
|
||||
+26
-10
@@ -26,14 +26,21 @@ env:
|
||||
jobs:
|
||||
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
|
||||
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
|
||||
#
|
||||
# Where: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
|
||||
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
|
||||
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
|
||||
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
|
||||
# Python and the Rust cache steps are GitHub's only.
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Does this pull request need Rust checks?
|
||||
@@ -60,12 +67,16 @@ jobs:
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
env:
|
||||
HOSTED: ${{ runner.environment == 'github-hosted' }}
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
if [ "$HOSTED" = true ]; then
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -92,8 +103,8 @@ jobs:
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
launcher:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -107,12 +118,16 @@ jobs:
|
||||
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust and the headers GPUI links against
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
env:
|
||||
HOSTED: ${{ runner.environment == 'github-hosted' }}
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
if [ "$HOSTED" = true ]; then
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher
|
||||
@@ -129,7 +144,8 @@ jobs:
|
||||
# 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
|
||||
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
run: ${{ steps.scope.outputs.run }}
|
||||
steps:
|
||||
|
||||
@@ -29,7 +29,8 @@ jobs:
|
||||
# the skipped runs of ordinary comments must not cancel a waiting one.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number }}
|
||||
runs-on: ubuntu-latest
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -55,7 +56,8 @@ jobs:
|
||||
run: python3 tools/ci/claim_command.py command
|
||||
release:
|
||||
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
|
||||
runs-on: ubuntu-latest
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -19,7 +19,9 @@ jobs:
|
||||
concurrency:
|
||||
group: overlap-note-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
|
||||
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -16,7 +16,9 @@ jobs:
|
||||
label:
|
||||
# Title-only edits change nothing: skip them before a runner starts.
|
||||
if: github.event.action != 'edited' || github.event.changes.body != null
|
||||
runs-on: ubuntu-latest
|
||||
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
|
||||
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -68,6 +68,16 @@ unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
|
||||
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
|
||||
|
||||
## Write tests with signal
|
||||
|
||||
- Test observable behavior, not words in docs, comments or source. Keep checks for machine-consumed contracts.
|
||||
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
|
||||
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
|
||||
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
|
||||
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
|
||||
- Make slow tests faster without removing cases or weakening their coverage.
|
||||
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
|
||||
@@ -113,6 +113,10 @@ it (edited on every push, never repeated): talk to its author in the thread befo
|
||||
can see.
|
||||
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
|
||||
ourselves instead of sending it back to you; only design questions come back.
|
||||
- **Once we've pushed to your branch, merge main into it; don't rebase or force-push.** A rebase or force-push
|
||||
replaces our commits with yours and erases the fixes we added (it happened on #5188 and #5203). Tell your agent
|
||||
too: `git merge origin/main`, never `git rebase` or `git push --force` on a branch a maintainer has touched. If
|
||||
our fixes get erased anyway, we finish the work in our own pull request that keeps your commits and credits you.
|
||||
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
|
||||
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
|
||||
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
|
||||
|
||||
Generated
+517
-12
@@ -34,6 +34,17 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -163,6 +174,45 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -181,6 +231,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -195,6 +251,9 @@ name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
@@ -217,6 +276,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -301,6 +369,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
@@ -339,7 +416,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -349,7 +437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
@@ -386,6 +474,18 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cms"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der",
|
||||
"spki",
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
@@ -425,6 +525,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -520,6 +626,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -548,7 +663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -571,10 +686,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"fiat-crypto",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
]
|
||||
@@ -596,6 +711,65 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der_derive",
|
||||
"flagset",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der_derive"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "des"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -745,6 +919,12 @@ version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flagset"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -897,6 +1077,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1035,6 +1216,12 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hidapi"
|
||||
version = "2.6.7"
|
||||
@@ -1101,6 +1288,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client"
|
||||
version = "0.1.0"
|
||||
@@ -1115,6 +1311,7 @@ dependencies = [
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
@@ -1125,6 +1322,7 @@ dependencies = [
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1192,6 +1390,7 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
@@ -1209,6 +1408,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
@@ -1294,6 +1494,18 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-narrator"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1354,14 +1566,20 @@ dependencies = [
|
||||
name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"chrono",
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-core",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
"libc",
|
||||
"p12-keystore",
|
||||
"png",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
@@ -1625,6 +1843,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -1916,6 +2135,12 @@ dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
@@ -2027,6 +2252,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
@@ -2036,6 +2271,22 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -2047,6 +2298,15 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2423,6 +2683,15 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -2484,6 +2753,29 @@ dependencies = [
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p12-keystore"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cbc",
|
||||
"cms",
|
||||
"der",
|
||||
"des",
|
||||
"hex",
|
||||
"hmac",
|
||||
"pkcs12",
|
||||
"pkcs5",
|
||||
"rand",
|
||||
"rc2",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2507,6 +2799,25 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2550,6 +2861,36 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs12"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2"
|
||||
dependencies = [
|
||||
"cms",
|
||||
"const-oid",
|
||||
"der",
|
||||
"digest",
|
||||
"spki",
|
||||
"x509-cert",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs5"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"der",
|
||||
"pbkdf2",
|
||||
"scrypt",
|
||||
"sha2",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -2601,7 +2942,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -2642,6 +2983,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "pp-rs"
|
||||
version = "0.2.1"
|
||||
@@ -2711,6 +3058,17 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20 0.10.2",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -2720,6 +3078,12 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -2744,6 +3108,28 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rc2"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
@@ -2835,6 +3221,15 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
@@ -2911,6 +3306,15 @@ dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "salsa20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2932,6 +3336,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scrypt"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
|
||||
dependencies = [
|
||||
"pbkdf2",
|
||||
"salsa20",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
@@ -3010,7 +3425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3021,7 +3436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3136,7 +3551,7 @@ checksum = "c5482afe85a0b6ce956c945401598dbc527593c77ba51d0a87a586938b1b893a"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"hkdf",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
@@ -3149,6 +3564,16 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3266,6 +3691,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
@@ -3326,6 +3762,36 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
@@ -3445,7 +3911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"nom 8.0.0",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
@@ -4515,6 +4981,35 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x509-cert"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcursor"
|
||||
version = "0.3.11"
|
||||
@@ -4559,6 +5054,16 @@ version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -4579,7 +5084,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4620,7 +5125,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,6 +3,7 @@ exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-re
|
||||
members = [
|
||||
"crates/horizon-core",
|
||||
"crates/horizon-dualsense",
|
||||
"crates/horizon-narrator",
|
||||
"crates/horizon-threads",
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
@@ -14,6 +15,7 @@ members = [
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-anticheat",
|
||||
"crates/horizon-chat-filter",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -33,9 +33,16 @@ pub enum Change {
|
||||
Velocity([f64; 3]),
|
||||
/// ClientboundExplodePacket's player knockback, added to the velocity.
|
||||
Knockback([f64; 3]),
|
||||
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
|
||||
/// attributes and effects, as they are when the replay reaches it.
|
||||
State,
|
||||
/// ClientboundUpdateAttributesPacket for the player itself: the server's attributes as they
|
||||
/// are when the replay reaches it, but for the movement speed's sprint modifier, which the
|
||||
/// client's sprinting decides: `sprint` is whether the packet had it when it carried the
|
||||
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
|
||||
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
|
||||
Attributes { sprint: Option<bool> },
|
||||
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
|
||||
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
|
||||
/// are the server's: they reach the client as attributes.)
|
||||
Effects,
|
||||
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
|
||||
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
|
||||
/// tick played before the client had them may be off.
|
||||
@@ -386,16 +393,16 @@ mod tests {
|
||||
fn changes_keep_the_server_tick_that_sent_them() {
|
||||
let mut r = Record::default();
|
||||
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
|
||||
r.changed(5, Change::State);
|
||||
r.changed(5, Change::Effects);
|
||||
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
|
||||
assert_eq!(
|
||||
all,
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::Effects)]
|
||||
);
|
||||
assert_eq!(r.changes_since(1).unwrap().count(), 1);
|
||||
assert!(r.changes_since(3).is_none());
|
||||
for i in 0..CHANGES_KEPT as u64 {
|
||||
r.changed(6 + i, Change::State);
|
||||
r.changed(6 + i, Change::Effects);
|
||||
}
|
||||
// The first two are gone.
|
||||
assert!(r.changes_since(1).is_none());
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.88"
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,63 @@
|
||||
# Horizon's chat filter: the hate slurs a server refuses in chat, private messages, /say, /me, team
|
||||
# messages, signs, anvil names and book titles (docs/chat-filter.md). This is a moderation list: the
|
||||
# words are written here only so the filter can recognise them, because players spammed them on the
|
||||
# official server (2026-10-31). Add a word when players find a way around the list; keep insults that
|
||||
# aren't slurs out of it (moderators handle those).
|
||||
#
|
||||
# One entry a line, matched after normalising the text: lower case, accents and look-alike letters
|
||||
# (Cyrillic, Greek, full-width, small capitals...), leetspeak (1 i, 3 e, 4 a, 0 o, @ a, $ s, 9 g),
|
||||
# separators between letters (n.i.g, n_i_g) and repeated letters. A doubled letter in an entry must be
|
||||
# doubled in the text: "niger", the river and the country, isn't "nigger".
|
||||
# word matches inside a longer word too
|
||||
# =word only as a whole word ("spic", not "suspicious")
|
||||
# ~word also when it's spread over several words ("nig ger", "n i g g e r")
|
||||
# !word never a match: a word that contains an entry ("snigger"); !word* any word starting so
|
||||
|
||||
~nigger
|
||||
~nigga
|
||||
nigguh
|
||||
niggah
|
||||
niqqa
|
||||
niqqer
|
||||
nlgger
|
||||
nlgga
|
||||
kneegrow
|
||||
=nig
|
||||
=nigs
|
||||
=niglet
|
||||
=niglets
|
||||
=nibba
|
||||
=nibbas
|
||||
=nibber
|
||||
=nibbers
|
||||
jigaboo
|
||||
porchmonkey
|
||||
=coon
|
||||
=coons
|
||||
~faggot
|
||||
=fag
|
||||
=fags
|
||||
=faggy
|
||||
=fagot
|
||||
=fagots
|
||||
=tranny
|
||||
=trannies
|
||||
=kike
|
||||
=kikes
|
||||
=chink
|
||||
=chinks
|
||||
=gook
|
||||
=gooks
|
||||
=spic
|
||||
=spics
|
||||
wetback
|
||||
=beaner
|
||||
=beaners
|
||||
raghead
|
||||
towelhead
|
||||
zipperhead
|
||||
=paki
|
||||
=pakis
|
||||
|
||||
!snigger*
|
||||
!niggard*
|
||||
@@ -0,0 +1,472 @@
|
||||
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
|
||||
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
|
||||
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
|
||||
//! player's text and carries out what it decides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
pub const DEFAULT_WORDS: &str = include_str!("../data/slurs.txt");
|
||||
|
||||
/// The mutes, beside server.properties.
|
||||
pub const MUTES_FILE: &str = "chat-filter-mutes.json";
|
||||
|
||||
/// `server.toml`'s `[chat_filter]`: on by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// The hit, counted within `window_minutes`, that mutes the player; 0: never.
|
||||
pub mute_after: u32,
|
||||
/// The hit that bans the player for `tempban_hours`; 0: never.
|
||||
pub tempban_after: u32,
|
||||
/// How long a hit counts towards the next ones (the time spent muted doesn't count).
|
||||
pub window_minutes: u32,
|
||||
pub mute_minutes: u32,
|
||||
pub tempban_hours: u32,
|
||||
/// The slur list, relative to the server's folder; empty: the list built into the game.
|
||||
pub words_file: String,
|
||||
/// Every refused text with the player and what followed, relative to the server's folder.
|
||||
pub log: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
mute_after: 2,
|
||||
tempban_after: 3,
|
||||
window_minutes: 10,
|
||||
mute_minutes: 10,
|
||||
tempban_hours: 24,
|
||||
words_file: String::new(),
|
||||
log: "logs/chat-filter.log".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// The `[chat_filter]` section of a whole `server.toml`; the default without it.
|
||||
pub fn from_server_toml(text: &str) -> Result<Self, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
chat_filter: Config,
|
||||
}
|
||||
// Other sections are the supervisor's to check: only this one is read here.
|
||||
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut only = toml::Table::new();
|
||||
if let Some(section) = table.remove("chat_filter") {
|
||||
only.insert("chat_filter".into(), section);
|
||||
}
|
||||
let file: File = only
|
||||
.try_into()
|
||||
.map_err(|e: toml::de::Error| format!("[chat_filter]: {e}"))?;
|
||||
file.chat_filter.check()?;
|
||||
Ok(file.chat_filter)
|
||||
}
|
||||
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.window_minutes == 0 {
|
||||
return Err("chat_filter.window_minutes must be 1 or more".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// How an entry of the list matches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
/// Inside a longer word too.
|
||||
Embedded,
|
||||
/// Only as a whole word.
|
||||
Whole,
|
||||
/// Inside a word, or spread over several words from the start of one.
|
||||
Spread,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entry {
|
||||
runs: Vec<Run>,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
/// The slur list, compiled: each entry's letters as runs, indexed by first letter.
|
||||
#[derive(Debug)]
|
||||
pub struct Filter {
|
||||
entries: Vec<Entry>,
|
||||
by_first: [Vec<u16>; 27],
|
||||
/// Words never matched: exact (`false`) or as a prefix (`true`).
|
||||
allowed: Vec<(Vec<Run>, bool)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Compiles a list in `data/slurs.txt`'s format.
|
||||
pub fn new(list: &str) -> Self {
|
||||
let mut f = Filter {
|
||||
entries: Vec::new(),
|
||||
by_first: Default::default(),
|
||||
allowed: Vec::new(),
|
||||
};
|
||||
for line in list.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(word) = line.strip_prefix('!') {
|
||||
let (word, prefix) = match word.strip_suffix('*') {
|
||||
Some(w) => (w, true),
|
||||
None => (word, false),
|
||||
};
|
||||
f.allowed.push((normalise::word(word), prefix));
|
||||
continue;
|
||||
}
|
||||
let (kind, word) = match line.as_bytes()[0] {
|
||||
b'=' => (Kind::Whole, &line[1..]),
|
||||
b'~' => (Kind::Spread, &line[1..]),
|
||||
_ => (Kind::Embedded, line),
|
||||
};
|
||||
let runs = normalise::word(word);
|
||||
let Some(first) = runs.first() else {
|
||||
continue;
|
||||
};
|
||||
f.by_first[usize::from(first.letter)].push(f.entries.len() as u16);
|
||||
f.entries.push(Entry { runs, kind });
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The built-in list.
|
||||
pub fn default_list() -> Self {
|
||||
Self::new(DEFAULT_WORDS)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `text` holds a slur of the list.
|
||||
pub fn matches(&self, text: &str) -> bool {
|
||||
let t = Text::new(text);
|
||||
let runs = &t.runs;
|
||||
for &(start, end) in t.words.iter().chain(&t.spaced) {
|
||||
let word = &runs[start..end];
|
||||
if self.is_allowed(word) {
|
||||
continue;
|
||||
}
|
||||
for at in 0..word.len() {
|
||||
for &e in &self.by_first[usize::from(word[at].letter)] {
|
||||
let e = &self.entries[usize::from(e)];
|
||||
let whole = e.kind == Kind::Whole;
|
||||
if (!whole || at == 0) && fits(&e.runs, &word[at..], whole) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spread entries over the whole text, from the start of a word, across at least two.
|
||||
for (w, &at) in t.joined_at.iter().enumerate() {
|
||||
for &e in &self.by_first[usize::from(t.joined[at].letter)] {
|
||||
let e = &self.entries[usize::from(e)];
|
||||
if e.kind == Kind::Spread
|
||||
&& fits(&e.runs, &t.joined[at..], false)
|
||||
&& t.spans_words(w, e.runs.len())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_allowed(&self, word: &[Run]) -> bool {
|
||||
self.allowed.iter().any(|(a, prefix)| {
|
||||
if *prefix {
|
||||
// A prefix: its last run may be part of a longer one.
|
||||
word.len() >= a.len() && fits(a, word, false)
|
||||
} else {
|
||||
fits(a, word, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `entry`'s runs start `runs` (are all of it with `whole`): the same letters, each at least
|
||||
/// as many times as the entry has it.
|
||||
fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
|
||||
if runs.len() < entry.len() || (whole && runs.len() != entry.len()) {
|
||||
return false;
|
||||
}
|
||||
entry
|
||||
.iter()
|
||||
.zip(runs)
|
||||
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
|
||||
}
|
||||
|
||||
/// What a hit leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The text is refused, nothing more.
|
||||
Refused,
|
||||
/// Refused, and the player can't chat until then (epoch milliseconds).
|
||||
Muted { until: i64 },
|
||||
/// Refused, and the player is banned until then.
|
||||
Banned { until: i64 },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Refused => "refused",
|
||||
Action::Muted { .. } => "muted",
|
||||
Action::Banned { .. } => "banned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds.
|
||||
pub until: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct MutesFile {
|
||||
mutes: Vec<Mute>,
|
||||
}
|
||||
|
||||
/// One player's hits: how many, and when the last one stops counting.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Strikes {
|
||||
count: u32,
|
||||
counts_until: i64,
|
||||
}
|
||||
|
||||
/// The filter, the players' hits and the mutes.
|
||||
pub struct Guard {
|
||||
pub config: Config,
|
||||
pub filter: Filter,
|
||||
strikes: HashMap<u128, Strikes>,
|
||||
mutes: HashMap<u128, Mute>,
|
||||
/// Where the mutes are kept; `None` keeps them in memory only (tests).
|
||||
file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
const MINUTE: i64 = 60_000;
|
||||
|
||||
impl Guard {
|
||||
pub fn new(config: Config, filter: Filter, file: Option<PathBuf>) -> Self {
|
||||
let mut mutes = HashMap::new();
|
||||
if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok())
|
||||
&& let Ok(saved) = serde_json::from_str::<MutesFile>(&text)
|
||||
{
|
||||
for m in saved.mutes {
|
||||
if let Some(uuid) = parse_uuid(&m.uuid) {
|
||||
mutes.insert(uuid, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
config,
|
||||
filter,
|
||||
strikes: HashMap::new(),
|
||||
mutes,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's guard: `words_file` or the built-in list, the mutes in `dir`. An unreadable
|
||||
/// `words_file` falls back to the built-in list, with why.
|
||||
pub fn load(config: Config, dir: &Path) -> (Self, Option<String>) {
|
||||
let mut problem = None;
|
||||
let filter = if config.words_file.is_empty() {
|
||||
Filter::default_list()
|
||||
} else {
|
||||
match std::fs::read_to_string(dir.join(&config.words_file)) {
|
||||
Ok(text) => Filter::new(&text),
|
||||
Err(e) => {
|
||||
problem = Some(format!(
|
||||
"chat_filter.words_file {}: {e}; using the built-in list",
|
||||
config.words_file
|
||||
));
|
||||
Filter::default_list()
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
Self::new(config, filter, Some(dir.join(MUTES_FILE))),
|
||||
problem,
|
||||
)
|
||||
}
|
||||
|
||||
/// When the player's mute ends, if they are muted at `now`.
|
||||
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
|
||||
self.mutes
|
||||
.get(&uuid)
|
||||
.map(|m| m.until)
|
||||
.filter(|&until| until > now)
|
||||
}
|
||||
|
||||
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
|
||||
pub fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
|
||||
let c = &self.config;
|
||||
let window = i64::from(c.window_minutes) * MINUTE;
|
||||
let s = self.strikes.entry(uuid).or_insert(Strikes {
|
||||
count: 0,
|
||||
counts_until: 0,
|
||||
});
|
||||
if now > s.counts_until {
|
||||
s.count = 0;
|
||||
}
|
||||
s.count += 1;
|
||||
s.counts_until = now + window;
|
||||
let count = s.count;
|
||||
let action = if c.tempban_after > 0 && count >= c.tempban_after {
|
||||
self.strikes.remove(&uuid);
|
||||
Action::Banned {
|
||||
until: now + i64::from(c.tempban_hours) * 60 * MINUTE,
|
||||
}
|
||||
} else if c.mute_after > 0 && count >= c.mute_after {
|
||||
let until = now + i64::from(c.mute_minutes) * MINUTE;
|
||||
// The window starts again when the mute ends.
|
||||
s.counts_until = until + window;
|
||||
self.mutes.insert(
|
||||
uuid,
|
||||
Mute {
|
||||
uuid: uuid_string(uuid),
|
||||
name: name.to_owned(),
|
||||
until,
|
||||
},
|
||||
);
|
||||
self.save();
|
||||
Action::Muted { until }
|
||||
} else {
|
||||
Action::Refused
|
||||
};
|
||||
(count, action)
|
||||
}
|
||||
|
||||
/// Lifts a player's mute (and forgets their hits), by name; the name as muted, if they were.
|
||||
pub fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
let uuid = *self
|
||||
.mutes
|
||||
.iter()
|
||||
.find(|(_, m)| m.name.eq_ignore_ascii_case(name))?
|
||||
.0;
|
||||
let mute = self.mutes.remove(&uuid)?;
|
||||
self.strikes.remove(&uuid);
|
||||
self.save();
|
||||
Some(mute.name)
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
let Some(file) = &self.file else {
|
||||
return;
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.until > now);
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, text).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of `logs/chat-filter.log`, without its newline.
|
||||
pub fn log_line(
|
||||
now: i64,
|
||||
name: &str,
|
||||
uuid: u128,
|
||||
place: &str,
|
||||
count: u32,
|
||||
action: Action,
|
||||
text: &str,
|
||||
) -> String {
|
||||
let text: String = text
|
||||
.chars()
|
||||
.map(|c| if c == '\n' { ' ' } else { c })
|
||||
.collect();
|
||||
format!(
|
||||
"{} {name} {} {place} hit {count} {}: {text}",
|
||||
utc(now),
|
||||
uuid_string(uuid),
|
||||
action.name()
|
||||
)
|
||||
}
|
||||
|
||||
/// Appends a line to the log at `path` (its folder made if needed).
|
||||
pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
|
||||
rest / 3600,
|
||||
rest / 60 % 60,
|
||||
rest % 60
|
||||
)
|
||||
}
|
||||
|
||||
fn uuid_string(u: u128) -> String {
|
||||
let h = format!("{u:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..]
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_uuid(s: &str) -> Option<u128> {
|
||||
u128::from_str_radix(&s.replace('-', ""), 16).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,255 @@
|
||||
//! A text as the filter sees it: words of letters `a`..`z`, each letter with how many times it repeats.
|
||||
//! Case, accents, look-alike letters (Cyrillic, Greek, full-width, small capitals, styled mathematical
|
||||
//! letters, circled and squared letters), leetspeak and invisible characters are folded away;
|
||||
//! separators inside a word are dropped (`n.i.g` is `nig`), and whitespace ends a word.
|
||||
|
||||
/// The letter code of anything that is a letter but none of `a`..`z`: it never matches.
|
||||
const OTHER: u8 = 26;
|
||||
|
||||
/// One letter and how many times it repeats (`gg` is `g` twice).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Run {
|
||||
/// 0..=25 for `a`..`z`, 26 for any other letter.
|
||||
pub letter: u8,
|
||||
pub count: u8,
|
||||
/// Every one of its characters was punctuation read as a letter (`!` as `i`, `@` as `a`).
|
||||
pub weak: bool,
|
||||
}
|
||||
|
||||
/// A whole message, normalised.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Text {
|
||||
pub runs: Vec<Run>,
|
||||
/// Each word's runs, `runs[start..end]`.
|
||||
pub words: Vec<(usize, usize)>,
|
||||
/// More ranges of `runs` to match as words: letters spaced out (`n i g g a`), joined, and words
|
||||
/// without the punctuation they end with (`fag!`).
|
||||
pub spaced: Vec<(usize, usize)>,
|
||||
/// Every word one after the other, a letter that ends one and starts the next merged.
|
||||
pub joined: Vec<Run>,
|
||||
/// Where each word starts in `joined`.
|
||||
pub joined_at: Vec<usize>,
|
||||
/// The last word each run of `joined` belongs to.
|
||||
joined_last: Vec<usize>,
|
||||
}
|
||||
|
||||
enum Fold {
|
||||
Letter(u8, bool),
|
||||
Separator,
|
||||
Space,
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn new(text: &str) -> Self {
|
||||
let mut t = Text::default();
|
||||
let mut start = 0;
|
||||
for c in text.chars() {
|
||||
match fold(c) {
|
||||
Fold::Letter(letter, weak) => {
|
||||
let in_word = t.runs.len() > start;
|
||||
match t.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == letter => {
|
||||
last.count = last.count.saturating_add(1);
|
||||
last.weak &= weak;
|
||||
}
|
||||
_ => t.runs.push(Run {
|
||||
letter,
|
||||
count: 1,
|
||||
weak,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Fold::Space => {
|
||||
t.end_word(start);
|
||||
start = t.runs.len();
|
||||
}
|
||||
Fold::Separator | Fold::Skip => {}
|
||||
}
|
||||
}
|
||||
t.end_word(start);
|
||||
t.add_spaced();
|
||||
t.join();
|
||||
t
|
||||
}
|
||||
|
||||
fn end_word(&mut self, start: usize) {
|
||||
if self.runs.len() > start {
|
||||
self.words.push((start, self.runs.len()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Spaced-out letters and words without their trailing punctuation, as extra words (their runs
|
||||
/// copied after the real words').
|
||||
fn add_spaced(&mut self) {
|
||||
let words = self.words.len();
|
||||
let mut w = 0;
|
||||
while w < words {
|
||||
let mut end = w;
|
||||
while end < words && self.words[end].1 - self.words[end].0 == 1 {
|
||||
end += 1;
|
||||
}
|
||||
if end - w >= 2 {
|
||||
let first = self.runs.len();
|
||||
for k in w..end {
|
||||
let run = self.runs[self.words[k].0];
|
||||
let in_word = self.runs.len() > first;
|
||||
match self.runs.last_mut() {
|
||||
Some(last) if in_word && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
}
|
||||
_ => self.runs.push(run),
|
||||
}
|
||||
}
|
||||
self.spaced.push((first, self.runs.len()));
|
||||
w = end;
|
||||
} else {
|
||||
w += 1;
|
||||
}
|
||||
}
|
||||
for k in 0..words {
|
||||
let (start, mut end) = self.words[k];
|
||||
while end > start + 1 && self.runs[end - 1].weak {
|
||||
end -= 1;
|
||||
}
|
||||
if end < self.words[k].1 {
|
||||
self.spaced.push((start, end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&mut self) {
|
||||
for (w, &(start, end)) in self.words.iter().enumerate() {
|
||||
let mut first = true;
|
||||
for run in &self.runs[start..end] {
|
||||
match self.joined.last_mut() {
|
||||
Some(last) if first && last.letter == run.letter => {
|
||||
last.count = last.count.saturating_add(run.count);
|
||||
self.joined_at.push(self.joined.len() - 1);
|
||||
*self.joined_last.last_mut().expect("beside joined") = w;
|
||||
}
|
||||
_ => {
|
||||
if first {
|
||||
self.joined_at.push(self.joined.len());
|
||||
}
|
||||
self.joined.push(*run);
|
||||
self.joined_last.push(w);
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `len` runs of `joined` from word `w`'s start reach into a later word.
|
||||
pub fn spans_words(&self, w: usize, len: usize) -> bool {
|
||||
self.joined_last
|
||||
.get(self.joined_at[w] + len - 1)
|
||||
.is_some_and(|&last| last > w)
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry of the list, normalised the same way (one word, whitespace ignored).
|
||||
pub fn word(text: &str) -> Vec<Run> {
|
||||
let t = Text::new(&text.replace(char::is_whitespace, ""));
|
||||
t.words
|
||||
.first()
|
||||
.map_or_else(Vec::new, |&(s, e)| t.runs[s..e].to_vec())
|
||||
}
|
||||
|
||||
fn letter(c: char) -> Fold {
|
||||
Fold::Letter(c as u8 - b'a', false)
|
||||
}
|
||||
|
||||
fn fold(c: char) -> Fold {
|
||||
if c.is_ascii_alphabetic() {
|
||||
return letter(c.to_ascii_lowercase());
|
||||
}
|
||||
if c.is_ascii() {
|
||||
return match c {
|
||||
'0' => letter('o'),
|
||||
'1' => letter('i'),
|
||||
'3' => letter('e'),
|
||||
'4' => letter('a'),
|
||||
'5' => letter('s'),
|
||||
'6' | '9' => letter('g'),
|
||||
'7' => letter('t'),
|
||||
'8' => letter('b'),
|
||||
'!' | '|' => Fold::Letter(b'i' - b'a', true),
|
||||
'@' => Fold::Letter(0, true),
|
||||
'$' => Fold::Letter(b's' - b'a', true),
|
||||
c if c.is_ascii_whitespace() => Fold::Space,
|
||||
_ => Fold::Separator,
|
||||
};
|
||||
}
|
||||
let u = c as u32;
|
||||
let from = |base: u32, span: u32| char::from(b'a' + ((u - base) % span) as u8);
|
||||
let mapped = match u {
|
||||
// Zero-width characters, soft hyphen, combining marks, variation selectors.
|
||||
0xAD | 0x34F | 0x200B..=0x200F | 0x2060..=0x2064 | 0xFEFF | 0x180E => return Fold::Skip,
|
||||
0x300..=0x36F
|
||||
| 0x1AB0..=0x1AFF
|
||||
| 0x1DC0..=0x1DFF
|
||||
| 0x20D0..=0x20FF
|
||||
| 0xFE00..=0xFE0F
|
||||
| 0xFE20..=0xFE2F
|
||||
| 0xE0100..=0xE01EF => return Fold::Skip,
|
||||
// Full-width Latin and digits.
|
||||
0xFF21..=0xFF3A => from(0xFF21, 26),
|
||||
0xFF41..=0xFF5A => from(0xFF41, 26),
|
||||
0xFF10..=0xFF19 => return fold(char::from(b'0' + (u - 0xFF10) as u8)),
|
||||
// Mathematical alphanumerics: 52 letters a style, then digits.
|
||||
0x1D400..=0x1D6A3 => from(0x1D400, 26),
|
||||
0x1D7CE..=0x1D7FF => return fold(char::from(b'0' + ((u - 0x1D7CE) % 10) as u8)),
|
||||
// Circled, parenthesised, squared and negative letters, regional indicators.
|
||||
0x24B6..=0x24CF => from(0x24B6, 26),
|
||||
0x24D0..=0x24E9 => from(0x24D0, 26),
|
||||
0x249C..=0x24B5 => from(0x249C, 26),
|
||||
0x1F130..=0x1F149 => from(0x1F130, 26),
|
||||
0x1F150..=0x1F169 => from(0x1F150, 26),
|
||||
0x1F170..=0x1F189 => from(0x1F170, 26),
|
||||
0x1F1E6..=0x1F1FF => from(0x1F1E6, 26),
|
||||
_ => {
|
||||
if c.is_whitespace() {
|
||||
return Fold::Space;
|
||||
}
|
||||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||||
match LOOK_ALIKES.iter().find(|(_, chars)| chars.contains(lower)) {
|
||||
Some((base, _)) => *base,
|
||||
None if c.is_alphabetic() => return Fold::Letter(OTHER, false),
|
||||
None => return Fold::Separator,
|
||||
}
|
||||
}
|
||||
};
|
||||
letter(mapped)
|
||||
}
|
||||
|
||||
/// Letters with accents and letters that look like a Latin one, by the letter they read as (lower case).
|
||||
const LOOK_ALIKES: &[(char, &str)] = &[
|
||||
('a', "àáâãäåāăąǎǟǡǻȁȃȧạảấầẩẫậắằẳẵặⱥɐаαᴀᵃª"),
|
||||
('b', "ƀɓḃḅḇвьъβʙᵇ"),
|
||||
('c', "çćĉċčƈḉȼсϲᴄᶜ"),
|
||||
('d', "ďđɗḋḍḏḑḓԁδᴅᵈ"),
|
||||
('e', "èéêëēĕėęěȅȇȩẹẻẽếềểễệḕḗḙḛḝɇеёєεᴇᵉ€"),
|
||||
('f', "ƒḟꜰᶠ"),
|
||||
('g', "ĝğġģǥǧǵɠḡɡɢᵍ"),
|
||||
('h', "ĥħȟḣḥḧḩḫẖнһʜʰ"),
|
||||
('i', "ìíîïĩīĭįıǐȉȋḭḯỉịіїιɪⁱ¡ӏ"),
|
||||
('j', "ĵǰјᴊʲ"),
|
||||
('k', "ķĸǩḱḳḵкκᴋᵏ"),
|
||||
('l', "ĺļľŀłḷḹḻḽʟˡ"),
|
||||
('m', "мᴍᵐ"),
|
||||
('n', "ñńņňʼnŋǹṅṇṉṋиипηπɴⁿ"),
|
||||
('o', "òóôõöøōŏőơǒǫǭǿȍȏȫȭȯȱọỏốồổỗộớờởỡợоοσᴏᵒº"),
|
||||
('p', "ṕṗрρᴘᵖ"),
|
||||
('q', "ԛ"),
|
||||
('r', "ŕŗřȑȓṙṛṝṟгʀʳ"),
|
||||
('s', "śŝşšșṡṣṥṧṩѕꜱˢß"),
|
||||
('t', "ţťŧțṫṭṯṱẗтτᴛᵗ"),
|
||||
('u', "ùúûüũūŭůűųưǔǖǘǚǜȕȗụủứừửữựυᴜᵘ"),
|
||||
('v', "νᴠᵛ"),
|
||||
('w', "ŵẁẃẅẇẉẘԝωᴡʷ"),
|
||||
('x', "хχˣ"),
|
||||
('y', "ýÿŷȳẏẙỳỵỷỹуүγʏʸ"),
|
||||
('z', "źżžƶẑẓẕᴢᶻ"),
|
||||
];
|
||||
@@ -0,0 +1,253 @@
|
||||
// The test texts spell the slurs the filter must refuse: that is what they test.
|
||||
use super::*;
|
||||
|
||||
fn filter() -> Filter {
|
||||
Filter::default_list()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_compiles() {
|
||||
let f = filter();
|
||||
assert!(f.len() > 30, "{} entries", f.len());
|
||||
assert_eq!(f.allowed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalising_catches_the_usual_disguises() {
|
||||
let f = filter();
|
||||
for text in [
|
||||
"nigger",
|
||||
"NIGGER",
|
||||
"shut up nigga",
|
||||
"Niggers",
|
||||
"n1gg3r",
|
||||
"ni99a",
|
||||
"N!GGA",
|
||||
"nigg@",
|
||||
"n.i.g.g.e.r",
|
||||
"n_i_g_g_a",
|
||||
"n i g g e r",
|
||||
"n i gg a",
|
||||
"nig ger",
|
||||
"nig gaaa",
|
||||
"niiiiggggggerrrrr",
|
||||
"ñíggér",
|
||||
"nigger",
|
||||
"Niggeг", // Cyrillic ghe for r
|
||||
"nіgger", // Cyrillic i
|
||||
"nigg\u{0435}r", // Cyrillic e
|
||||
"n\u{200B}igger", // a zero-width space
|
||||
"n\u{0301}igger", // a combining accent
|
||||
"𝐧𝐢𝐠𝐠𝐞𝐫",
|
||||
"ⓝⓘⓖⓖⓐ",
|
||||
"ɴɪɢɢᴀ",
|
||||
"hi nig",
|
||||
"f a g",
|
||||
"fag!",
|
||||
"you $pic",
|
||||
"FAGGOTINO-CHAN",
|
||||
"sandnigger",
|
||||
"lol kike",
|
||||
] {
|
||||
assert!(f.matches(text), "{text:?} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_words_pass() {
|
||||
let f = filter();
|
||||
for text in [
|
||||
"hello",
|
||||
"Niger",
|
||||
"I live in Niger",
|
||||
"Nigeria and the Niger river",
|
||||
"nigerian food",
|
||||
"proud2benigerian",
|
||||
"don't snigger",
|
||||
"sniggering at that",
|
||||
"a niggardly offer",
|
||||
"knight",
|
||||
"qKnights",
|
||||
"its still night",
|
||||
"anyone able to boot the latest nightly version?",
|
||||
"i hate nighbours",
|
||||
"can someune give mi night vision",
|
||||
"suspicious stew",
|
||||
"spicy food",
|
||||
"a raccoon in a cocoon",
|
||||
"Scunthorpe",
|
||||
"bigger trigger digger",
|
||||
"gg",
|
||||
"gg ez",
|
||||
"mini gg all",
|
||||
"kniga",
|
||||
"shenanigans",
|
||||
"dignity and signing",
|
||||
"Pakistan",
|
||||
"chinking glasses",
|
||||
"the trannys? no: transmission",
|
||||
"1nicerOli",
|
||||
"ask me evernig",
|
||||
"冰淇淋",
|
||||
"привет",
|
||||
"",
|
||||
"!!!",
|
||||
] {
|
||||
assert!(!f.matches(text), "{text:?} should pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_list_has_the_same_syntax() {
|
||||
let f = Filter::new("# comment\n=bad\n~worse\nmeh\n!mehish*\n");
|
||||
assert!(f.matches("so bad"));
|
||||
assert!(!f.matches("badly"));
|
||||
assert!(f.matches("wor se"));
|
||||
assert!(f.matches("xxmehxx"));
|
||||
assert!(!f.matches("mehishness"));
|
||||
assert!(!f.matches("me h"));
|
||||
}
|
||||
|
||||
fn guard(config: Config) -> Guard {
|
||||
Guard::new(config, filter(), None)
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_793_469_939_000;
|
||||
const MIN: i64 = 60_000;
|
||||
|
||||
#[test]
|
||||
fn the_second_hit_mutes_and_the_third_bans() {
|
||||
let mut g = guard(Config::default());
|
||||
let id = 7;
|
||||
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.muted_until(id, NOW), None);
|
||||
let until = NOW + 3 * MIN + 10 * MIN;
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", NOW + 3 * MIN),
|
||||
(2, Action::Muted { until })
|
||||
);
|
||||
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
|
||||
assert_eq!(g.muted_until(id, until), None);
|
||||
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
|
||||
assert_eq!(
|
||||
g.strike(id, "Ann", until + 8 * MIN),
|
||||
(
|
||||
3,
|
||||
Action::Banned {
|
||||
until: until + 8 * MIN + 24 * 60 * MIN
|
||||
}
|
||||
)
|
||||
);
|
||||
// Then it starts again.
|
||||
assert_eq!(g.strike(id, "Ann", until + 9 * MIN), (1, Action::Refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hits_far_apart_dont_add_up() {
|
||||
let mut g = guard(Config::default());
|
||||
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
|
||||
assert_eq!(g.strike(1, "Bo", NOW + 11 * MIN), (1, Action::Refused));
|
||||
// Players are counted apart.
|
||||
assert_eq!(g.strike(2, "Cy", NOW + 12 * MIN), (1, Action::Refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thresholds_are_configurable() {
|
||||
let mut g = guard(Config {
|
||||
mute_after: 0,
|
||||
tempban_after: 2,
|
||||
tempban_hours: 1,
|
||||
..Config::default()
|
||||
});
|
||||
assert_eq!(g.strike(1, "Bo", NOW), (1, Action::Refused));
|
||||
assert_eq!(
|
||||
g.strike(1, "Bo", NOW + MIN),
|
||||
(
|
||||
2,
|
||||
Action::Banned {
|
||||
until: NOW + MIN + 60 * MIN
|
||||
}
|
||||
)
|
||||
);
|
||||
let mut off = guard(Config {
|
||||
mute_after: 0,
|
||||
tempban_after: 0,
|
||||
..Config::default()
|
||||
});
|
||||
for k in 1..5 {
|
||||
assert_eq!(off.strike(1, "Bo", NOW + k), (k as u32, Action::Refused));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutes_survive_a_restart_and_unmute_lifts_them() {
|
||||
let dir = std::env::temp_dir().join(format!("chat-filter-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let now = now_millis();
|
||||
let (mut g, problem) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(problem, None);
|
||||
g.strike(5, "Dee", now);
|
||||
let (_, action) = g.strike(5, "Dee", now);
|
||||
assert!(matches!(action, Action::Muted { .. }));
|
||||
let (mut again, _) = Guard::load(Config::default(), &dir);
|
||||
assert!(again.muted_until(5, now).is_some());
|
||||
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
|
||||
assert_eq!(again.unmute("dee"), None);
|
||||
assert_eq!(again.muted_until(5, now), None);
|
||||
let (reloaded, _) = Guard::load(Config::default(), &dir);
|
||||
assert_eq!(reloaded.muted_until(5, now), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_words_file_falls_back_to_the_built_in_list() {
|
||||
let config = Config {
|
||||
words_file: "nope.txt".into(),
|
||||
..Config::default()
|
||||
};
|
||||
let (g, problem) = Guard::load(config, Path::new("/nonexistent-horizon"));
|
||||
assert!(problem.unwrap().contains("nope.txt"));
|
||||
assert!(g.filter.matches("nigger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_section_reads_from_server_toml() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(c.enabled);
|
||||
let c = Config::from_server_toml(
|
||||
"[chat_filter]\nenabled = true\nmute_after = 3\ntempban_after = 5\nwords_file = \"slurs.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((c.mute_after, c.tempban_after), (3, 5));
|
||||
assert_eq!(c.words_file, "slurs.txt");
|
||||
assert!(
|
||||
!Config::from_server_toml("[chat_filter]\nenabled = false\n")
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
assert!(Config::from_server_toml("[chat_filter]\nmute = 2\n").is_err());
|
||||
assert!(Config::from_server_toml("[chat_filter]\nwindow_minutes = 0\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_lines_have_the_time_the_player_and_the_text() {
|
||||
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(utc(951_782_400_000), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(
|
||||
log_line(NOW, "Ann", 1, "chat", 2, Action::Muted { until: 0 }, "a\nb"),
|
||||
"2026-10-31T18:05:39Z Ann 00000000-0000-0000-0000-000000000001 chat hit 2 muted: a b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_costs_little() {
|
||||
// A long message through the whole list stays far below a tick (debug builds included).
|
||||
let f = filter();
|
||||
let text = "the quick brown fox jumps over the lazy dog ".repeat(6);
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
assert!(!f.matches(&text));
|
||||
}
|
||||
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
|
||||
}
|
||||
@@ -118,6 +118,12 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
None => mod_effect(id),
|
||||
}
|
||||
}
|
||||
/// A content mod's effect id (`<ns>:<path>`), not vanilla's with or
|
||||
/// without `minecraft:`.
|
||||
#[inline]
|
||||
pub fn is_mod_effect(id: &str) -> bool {
|
||||
!id.starts_with("minecraft:") && id.bytes().any(|b| b == b':')
|
||||
}
|
||||
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
@@ -365,6 +371,8 @@ mod tests {
|
||||
(0xFF0000, Category::Neutral, false)
|
||||
);
|
||||
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
|
||||
assert!(is_mod_effect(glow.id) && !is_mod_effect("speed"));
|
||||
assert!(!is_mod_effect(namespaced("speed")));
|
||||
assert_eq!(namespaced(glow.id), "ruby:glow");
|
||||
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
|
||||
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
|
||||
|
||||
@@ -28,6 +28,12 @@ pub struct Registries {
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// A dedicated server's lists, for the completions of `/op`, `/deop`, `/pardon`, `/pardon-ip` and
|
||||
/// `/whitelist`: the operators' names, the banned names and addresses, the whitelisted names.
|
||||
pub ops: Vec<String>,
|
||||
pub banned_players: Vec<String>,
|
||||
pub banned_ips: Vec<String>,
|
||||
pub whitelisted: Vec<String>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
|
||||
/// each once.
|
||||
|
||||
@@ -15,6 +15,8 @@ horizon-command = { path = "../horizon-command" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
# The narrator option's status and whether the narrator is there (Options.narrator).
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
@@ -40,6 +42,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10.9"
|
||||
smallvec = { version = "1", features = ["const_new"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
|
||||
@@ -235,6 +235,9 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
sprites.finish();
|
||||
|
||||
@@ -13,7 +13,8 @@ use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
|
||||
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
|
||||
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
|
||||
register_mod_effects,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
|
||||
@@ -434,6 +434,10 @@ pub struct Camera {
|
||||
pub up: [f32; 3],
|
||||
pub left: [f32; 3],
|
||||
pub detached: bool,
|
||||
/// The entity's eye and view rotation (yaw, pitch), where a detached camera backs off
|
||||
/// from.
|
||||
pub eye: [f64; 3],
|
||||
pub eye_rotation: [f32; 2],
|
||||
}
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
@@ -450,6 +454,8 @@ impl Default for Camera {
|
||||
up: [0.0, 1.0, 0.0],
|
||||
left: [-1.0, 0.0, 0.0],
|
||||
detached: false,
|
||||
eye: [0.0; 3],
|
||||
eye_rotation: [0.0; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,6 +511,8 @@ impl Camera {
|
||||
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
|
||||
lerp_d(p, entity.previous[2], entity.position[2]),
|
||||
];
|
||||
self.eye = self.position;
|
||||
self.eye_rotation = [self.y_rot, self.x_rot];
|
||||
self.detached = !camera_type.is_first_person();
|
||||
if self.detached {
|
||||
if camera_type.is_mirrored() {
|
||||
|
||||
@@ -878,6 +878,22 @@ impl Chat {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SuggestionsList.getNarrationMessage` for entry `index` of `count`.
|
||||
fn suggestion_narration(index: usize, count: usize, text: &str, tooltip: Option<&str>) -> Text {
|
||||
let mut args = vec![
|
||||
Text::literal((index + 1).to_string()),
|
||||
Text::literal(count.to_string()),
|
||||
Text::literal(text),
|
||||
];
|
||||
match tooltip {
|
||||
Some(tooltip) => {
|
||||
args.push(Text::literal(tooltip));
|
||||
Text::translate("narration.suggestion.tooltip", &args)
|
||||
}
|
||||
None => Text::translate("narration.suggestion", &args),
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommandSuggestions.SuggestionsList`.
|
||||
#[derive(Clone, Debug)]
|
||||
struct SuggestionList {
|
||||
@@ -889,8 +905,17 @@ struct SuggestionList {
|
||||
current: usize,
|
||||
last_mouse: (i32, i32),
|
||||
tab_cycles: bool,
|
||||
/// `lastNarratedEntry`: the entry the narrator last said (-1: none yet).
|
||||
last_narrated: isize,
|
||||
}
|
||||
impl SuggestionList {
|
||||
/// `getNarrationMessage`: "Selected suggestion 2 out of 5: …", with its
|
||||
/// tooltip when it has one. It marks the entry said.
|
||||
fn narration_message(&mut self) -> Text {
|
||||
self.last_narrated = self.current as isize;
|
||||
let s = &self.list[self.current];
|
||||
suggestion_narration(self.current, self.list.len(), &s.text, s.tooltip.as_deref())
|
||||
}
|
||||
fn contains(&self, x: i32, y: i32) -> bool {
|
||||
let [rx, ry, w, h] = self.rect;
|
||||
x >= rx && x <= rx + w && y >= ry && y <= ry + h
|
||||
@@ -1149,12 +1174,14 @@ impl ChatScreen {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
let abilities = self.abilities;
|
||||
let message = std::mem::take(&mut self.input.message);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
self.auto_suggestions = auto;
|
||||
self.input.message = message;
|
||||
}
|
||||
}
|
||||
/// `ChatScreen.onEdited`.
|
||||
@@ -1378,11 +1405,12 @@ impl ChatScreen {
|
||||
};
|
||||
self.suggestions = None;
|
||||
if self.allow_suggestions && self.auto_suggestions {
|
||||
self.show_suggestions(m);
|
||||
self.show_suggestions(m, false);
|
||||
}
|
||||
}
|
||||
/// `CommandSuggestions.showSuggestions`.
|
||||
fn show_suggestions(&mut self, m: &Measure) {
|
||||
/// `CommandSuggestions.showSuggestions`: `immediate` (Tab asked for
|
||||
/// them) has the narrator say the first one at once.
|
||||
fn show_suggestions(&mut self, m: &Measure, immediate: bool) {
|
||||
let Some((start, list)) = self.pending.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -1425,6 +1453,7 @@ impl ChatScreen {
|
||||
current: 0,
|
||||
last_mouse: (0, 0),
|
||||
tab_cycles: false,
|
||||
last_narrated: if immediate { -1 } else { 0 },
|
||||
};
|
||||
self.select(&mut l, 0);
|
||||
self.suggestions = Some(l);
|
||||
@@ -1444,6 +1473,9 @@ impl ChatScreen {
|
||||
self.input.suggestion = applied
|
||||
.strip_prefix(self.input.value.as_str())
|
||||
.map(str::to_owned);
|
||||
if l.last_narrated != l.current as isize {
|
||||
horizon_narrator::say_system_now(&l.narration_message().plain());
|
||||
}
|
||||
}
|
||||
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
|
||||
self.select(l, l.current as isize + dir);
|
||||
@@ -1554,7 +1586,7 @@ impl ChatScreen {
|
||||
self.suggestions = Some(l);
|
||||
handled
|
||||
} else if key == K::Tab {
|
||||
self.show_suggestions(m);
|
||||
self.show_suggestions(m, true);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -1787,4 +1819,18 @@ impl ChatScreen {
|
||||
pub fn suggestions_visible(&self) -> bool {
|
||||
self.suggestions.is_some()
|
||||
}
|
||||
/// `CommandSuggestions.getUsageNarration` while the list shows
|
||||
/// (`isVisible`): whether Tab cycles it or fills the input in, and
|
||||
/// whether Escape hides it (`allowHiding`, which only the chat's turns
|
||||
/// off).
|
||||
pub fn suggestions_usage(&self) -> Option<&'static str> {
|
||||
let list = self.suggestions.as_ref()?;
|
||||
let hidable = self.layout != SuggestLayout::CHAT;
|
||||
Some(match (list.tab_cycles, hidable) {
|
||||
(true, true) => "narration.suggestion.usage.cycle.hidable",
|
||||
(true, false) => "narration.suggestion.usage.cycle.fixed",
|
||||
(false, true) => "narration.suggestion.usage.fill.hidable",
|
||||
(false, false) => "narration.suggestion.usage.fill.fixed",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -579,6 +579,39 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
|
||||
assert!(chat.deletions.is_empty());
|
||||
}
|
||||
|
||||
/// `SuggestionsList.select` says an entry once (`lastNarratedEntry`), as
|
||||
/// "suggestion N out of M", with its tooltip when it has one.
|
||||
#[test]
|
||||
fn a_suggestion_is_narrated_by_its_place_once() {
|
||||
let suggestion = |text: &str, tooltip: Option<&str>| Suggestion {
|
||||
range: (1, 1),
|
||||
text: text.into(),
|
||||
tooltip: tooltip.map(str::to_owned),
|
||||
int_value: None,
|
||||
};
|
||||
let mut l = SuggestionList {
|
||||
rect: [0; 4],
|
||||
original: "/".into(),
|
||||
list: vec![
|
||||
suggestion("give", None),
|
||||
suggestion("gamemode", Some("Mode")),
|
||||
],
|
||||
offset: 0,
|
||||
current: 1,
|
||||
last_mouse: (0, 0),
|
||||
tab_cycles: false,
|
||||
last_narrated: 0,
|
||||
};
|
||||
let said = l.narration_message().component();
|
||||
assert_eq!(l.last_narrated, 1);
|
||||
assert_eq!(said["translate"], "narration.suggestion.tooltip");
|
||||
assert_eq!(said["with"].as_array().map(Vec::len), Some(4));
|
||||
l.current = 0;
|
||||
let said = l.narration_message().component();
|
||||
assert_eq!(said["translate"], "narration.suggestion");
|
||||
assert_eq!(said["with"].as_array().map(Vec::len), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chat_message_is_trimmed_and_its_whitespace_runs_collapsed() {
|
||||
// ChatScreen.normalizeChatMessage: String.trim, then StringUtils
|
||||
|
||||
@@ -391,6 +391,9 @@ pub struct Chunk {
|
||||
pub light_loaded: bool,
|
||||
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
|
||||
pub fresh: bool,
|
||||
/// Finished from a proto chunk just now, in any kind of world, not read
|
||||
/// from a save (`ChunkEvent.Load`'s `isNewChunk`).
|
||||
pub generated: bool,
|
||||
/// Block entities read from a save, moved into the world on arrival.
|
||||
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
|
||||
/// `CustomName` of the container block entities (shown as the screen title).
|
||||
@@ -428,6 +431,7 @@ impl Chunk {
|
||||
lit: false,
|
||||
light_loaded: false,
|
||||
fresh: false,
|
||||
generated: false,
|
||||
block_entities: Vec::new(),
|
||||
block_names: Vec::new(),
|
||||
structure_refs: Vec::new(),
|
||||
|
||||
@@ -23,7 +23,7 @@ enum Source {
|
||||
}
|
||||
impl DataPack {
|
||||
/// Every file path under `prefix` (relative to the pack root).
|
||||
fn list(&self, prefix: &str) -> Vec<String> {
|
||||
pub fn list(&self, prefix: &str) -> Vec<String> {
|
||||
match &self.source {
|
||||
Source::Zip(z) => z
|
||||
.names()
|
||||
@@ -58,12 +58,13 @@ impl DataPack {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
match &self.source {
|
||||
Source::Zip(z) => z.read(path).ok(),
|
||||
Source::Dir(root) => std::fs::read(root.join(path)).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `description` of `pack.mcmeta`, as text.
|
||||
pub fn description(&self) -> String {
|
||||
let Some(bytes) = self.read("pack.mcmeta") else {
|
||||
|
||||
@@ -13,6 +13,37 @@ use std::collections::HashMap;
|
||||
|
||||
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
|
||||
pub const MAX: usize = 16;
|
||||
/// Camera-relative position and radius; RGB intensity in a second vec4.
|
||||
pub type Colored = [[f32; 4]; 2];
|
||||
|
||||
/// Nearest validated native visual lights; never enters gameplay lighting.
|
||||
pub fn select_colored(
|
||||
sources: impl Iterator<Item = ([f64; 3], u32, f32, f32)>,
|
||||
camera: DVec3,
|
||||
) -> Vec<Colored> {
|
||||
let mut nearest: Vec<_> = sources.collect();
|
||||
nearest.sort_by(|a, b| {
|
||||
(DVec3::from(a.0) - camera)
|
||||
.length_squared()
|
||||
.total_cmp(&(DVec3::from(b.0) - camera).length_squared())
|
||||
});
|
||||
nearest
|
||||
.into_iter()
|
||||
.take(MAX)
|
||||
.map(|(at, rgb, radius, intensity)| {
|
||||
let at = (DVec3::from(at) - camera).as_vec3();
|
||||
[
|
||||
[at.x, at.y, at.z, radius],
|
||||
[
|
||||
((rgb >> 16) & 255) as f32 / 255.0,
|
||||
((rgb >> 8) & 255) as f32 / 255.0,
|
||||
(rgb & 255) as f32 / 255.0,
|
||||
intensity,
|
||||
],
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// What an entity on fire gives off, as a lit campfire or fire block does.
|
||||
pub const BURNING: u8 = 15;
|
||||
/// Sources farther than this from the camera light nothing it can see well.
|
||||
@@ -87,6 +118,17 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn colored_sources_are_camera_relative_and_bounded() {
|
||||
let lights = select_colored(
|
||||
(0..20).map(|x| ([x as f64, 64.0, 0.0], 0xff8040, 5.0, 0.5)),
|
||||
DVec3::new(2.0, 64.0, 0.0),
|
||||
);
|
||||
assert_eq!(lights.len(), MAX);
|
||||
assert_eq!(lights[0][0][0], 0.0);
|
||||
assert_eq!(lights[0][1], [1.0, 128.0 / 255.0, 64.0 / 255.0, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_sources_win() {
|
||||
let camera = DVec3::new(100.0, 64.0, 100.0);
|
||||
|
||||
@@ -235,10 +235,12 @@ impl PlayerEffects {
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
|
||||
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
|
||||
/// answer: the effect ends once the absorption is used up. Returns the
|
||||
/// actions to apply, in order, and whether Raid Omen was newly added.
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
|
||||
/// (on its last tick) and a content mod's effect (every tick, with the
|
||||
/// tick count `shouldApplyEffectTickThisTick` takes); `absorption_empty`
|
||||
/// is `AbsorptionMobEffect`'s answer: the effect ends once the
|
||||
/// absorption is used up. Returns the actions to apply, in order, and
|
||||
/// whether Raid Omen was newly added.
|
||||
///
|
||||
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
|
||||
/// iterated: vanilla's iterator then throws a
|
||||
@@ -247,7 +249,7 @@ impl PlayerEffects {
|
||||
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
|
||||
pub fn tick_with(
|
||||
&mut self,
|
||||
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
|
||||
omen: &mut dyn FnMut(&EffectInstance, i32) -> OmenTick,
|
||||
absorption_empty: bool,
|
||||
) -> (Vec<EffectAction>, bool) {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
@@ -269,8 +271,9 @@ impl PlayerEffects {
|
||||
actions.push(a);
|
||||
}
|
||||
let answer = match e.id {
|
||||
"bad_omen" => omen(e),
|
||||
"raid_omen" if t == 1 => omen(e),
|
||||
"bad_omen" => omen(e, t),
|
||||
"raid_omen" if t == 1 => omen(e, t),
|
||||
id if potions::is_mod_effect(id) => omen(e, t),
|
||||
"absorption" if absorption_empty => OmenTick::Remove,
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
@@ -508,7 +511,7 @@ mod tests {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("bad_omen", 100, 1));
|
||||
fx.add(EffectInstance::new("speed", 50, 0));
|
||||
let mut convert = |e: &EffectInstance| match e.id {
|
||||
let mut convert = |e: &EffectInstance, _: i32| match e.id {
|
||||
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
@@ -550,12 +553,39 @@ mod tests {
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
/// A content mod's effect answers each tick before it counts down (an
|
||||
/// infinite one with the tick count), and goes at once on Remove.
|
||||
#[test]
|
||||
fn a_mod_effect_answers_before_it_counts_down() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("ruby:glow", 3, 1));
|
||||
fx.add(EffectInstance::new("ruby:bleed", -1, 0));
|
||||
let mut seen = Vec::new();
|
||||
let mut answer = |e: &EffectInstance, t: i32| {
|
||||
seen.push((e.id, t));
|
||||
match (e.id, t) {
|
||||
("ruby:glow", 2) => OmenTick::Remove,
|
||||
_ => OmenTick::Continue,
|
||||
}
|
||||
};
|
||||
fx.tick_with(&mut answer, false);
|
||||
fx.tick_with(&mut answer, false);
|
||||
let ticks = [
|
||||
("ruby:glow", 3),
|
||||
("ruby:bleed", 1),
|
||||
("ruby:glow", 2),
|
||||
("ruby:bleed", 2),
|
||||
];
|
||||
assert_eq!(seen, ticks);
|
||||
assert!(fx.get("ruby:glow").is_none());
|
||||
assert_eq!(fx.get("ruby:bleed").unwrap().duration, -1);
|
||||
}
|
||||
#[test]
|
||||
fn raid_omen_acts_on_its_last_tick() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("raid_omen", 2, 0));
|
||||
let mut seen = Vec::new();
|
||||
let mut watch = |e: &EffectInstance| {
|
||||
let mut watch = |e: &EffectInstance, _: i32| {
|
||||
seen.push(e.duration);
|
||||
OmenTick::Remove
|
||||
};
|
||||
|
||||
@@ -22,6 +22,10 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
colored_lights: array<array<vec4<f32>, 2>, 16>,
|
||||
colored_count: vec4<u32>,
|
||||
emissive_layers: array<vec4<u32>, 32>,
|
||||
emissive_count: vec4<u32>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
filtering: vec4<f32>,
|
||||
}
|
||||
@@ -90,6 +94,15 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
|
||||
}
|
||||
return level * 16.0;
|
||||
}
|
||||
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
|
||||
var rgb = vec3(0.0);
|
||||
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
|
||||
let at = globals.colored_lights[i][0];
|
||||
let color = globals.colored_lights[i][1];
|
||||
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
|
||||
}
|
||||
return min(rgb, vec3(1.0));
|
||||
}
|
||||
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
@@ -103,6 +116,9 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|
||||
|| overlay == 11u || overlay == 12u;
|
||||
out.light_color = select(lm, vec4(1.0), unlit);
|
||||
if globals.colored_count.x > 0u && !unlit {
|
||||
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
|
||||
}
|
||||
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
|
||||
// (cracks): the destroy stage sprite's origin.
|
||||
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
|
||||
|
||||
@@ -86,12 +86,14 @@ mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_material;
|
||||
mod native_pack;
|
||||
mod native_post;
|
||||
pub mod native_render;
|
||||
mod native_sky;
|
||||
mod occlusion;
|
||||
mod oit;
|
||||
@@ -236,6 +238,10 @@ struct Globals {
|
||||
outline_params: [f32; 4],
|
||||
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
colored_lights: [crate::dynamic_light::Colored; crate::dynamic_light::MAX],
|
||||
colored_count: [u32; 4],
|
||||
emissive_layers: [[u32; 4]; crate::native_emissive::MAX],
|
||||
emissive_count: [u32; 4],
|
||||
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
|
||||
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
|
||||
/// x: `Options.textureFiltering` (0 none, 1 RGSS, 2 anisotropic); y: the sprite arrays' width.
|
||||
@@ -268,7 +274,22 @@ struct SectionGpu {
|
||||
/// When it began to fade out, its column gone over far terrain that
|
||||
/// stays behind it (`Renderer::leave_section`); released once it has.
|
||||
leaving: Option<std::time::Instant>,
|
||||
/// Animated sprite layers present in this section.
|
||||
animated_layers: Box<[u16]>,
|
||||
}
|
||||
/// Extracts the animated sprite layers present in a section mesh. Meshing
|
||||
/// records the distinct layers, so uploading does not rescan every vertex.
|
||||
pub(crate) fn collect_animated_layers(
|
||||
mesh: &SectionMesh,
|
||||
is_animated: impl Fn(u16) -> bool,
|
||||
) -> Box<[u16]> {
|
||||
mesh.sprite_layers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&layer| is_animated(layer))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl SectionGpu {
|
||||
/// Its fade code this frame (`lod::fade_code`), 0 once whole. `retiring`:
|
||||
/// its column has left the render distance, far terrain is coming in
|
||||
@@ -297,6 +318,12 @@ pub struct Frame<'a> {
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub fov_degrees: f32,
|
||||
/// The camera entity's eye and view rotation (yaw, pitch): the camera's own except in a
|
||||
/// third-person view.
|
||||
pub eye: DVec3,
|
||||
pub eye_rotation: [f32; 2],
|
||||
/// The camera is the entity's eye: no third-person view.
|
||||
pub first_person: bool,
|
||||
/// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the
|
||||
/// projection (`crate::camera::bob_matrix`).
|
||||
pub bob: Mat4,
|
||||
@@ -369,6 +396,8 @@ pub struct Frame<'a> {
|
||||
pub weather_time: crate::weather::Time,
|
||||
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
|
||||
pub dynamic_lights: &'a [[f32; 4]],
|
||||
pub colored_lights: &'a [crate::dynamic_light::Colored],
|
||||
pub emissive_layers: &'a [[u32; 4]],
|
||||
/// Cloud mesh inputs; geometry and GPU storage are retained by the renderer.
|
||||
pub clouds: Option<crate::clouds::Input<'a>>,
|
||||
/// Retained lightning bolts, drawn before the other additive entities.
|
||||
@@ -583,6 +612,7 @@ pub struct Renderer {
|
||||
lightmap_view: wgpu::TextureView,
|
||||
lightmap_sampler: wgpu::Sampler,
|
||||
animations: animate::SpriteAnimator,
|
||||
active_animated_layers: Vec<u64>,
|
||||
uploads: upload::Uploads,
|
||||
/// Section meshes are staged through their own belt into
|
||||
/// `section_copies`, which the next frame records its commands after:
|
||||
@@ -661,6 +691,9 @@ pub struct Renderer {
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// Which pixels of each block sprite may shine in HDR output (terrain.wgsl's
|
||||
/// `emission_thresholds`), measured when HDR output turns on.
|
||||
emission_stats: hdr_stats::EmissionStats,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
@@ -795,6 +828,7 @@ fn terrain_group(
|
||||
sprite_sampler: &wgpu::Sampler,
|
||||
lightmap: &wgpu::TextureView,
|
||||
lightmap_sampler: &wgpu::Sampler,
|
||||
emission_thresholds: &wgpu::TextureView,
|
||||
) -> wgpu::BindGroup {
|
||||
let view = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
@@ -825,6 +859,7 @@ fn terrain_group(
|
||||
view(9, &sprites[5]),
|
||||
view(10, &sprites[6]),
|
||||
view(11, &sprites[7]),
|
||||
view(12, emission_thresholds),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -1543,6 +1578,15 @@ impl Renderer {
|
||||
entry(9, vf, sprite_array),
|
||||
entry(10, vf, sprite_array),
|
||||
entry(11, vf, sprite_array),
|
||||
entry(
|
||||
12,
|
||||
vf,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -1823,6 +1867,7 @@ impl Renderer {
|
||||
split,
|
||||
animate::SpriteAnimator::compute(&adapter, &device),
|
||||
);
|
||||
let active_layer_words = animations.active_layer_words();
|
||||
let sprite_unused = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Block sprites (unused)"),
|
||||
@@ -1865,6 +1910,8 @@ impl Renderer {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let emission_stats =
|
||||
hdr_stats::EmissionStats::new(&device, split, sprite_textures.len() as u32);
|
||||
let group = terrain_group(
|
||||
&device,
|
||||
"Horizon terrain resources",
|
||||
@@ -1874,6 +1921,7 @@ impl Renderer {
|
||||
&sprite_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
&emission_stats.view,
|
||||
);
|
||||
let crosshair_group = terrain_group(
|
||||
&device,
|
||||
@@ -1884,6 +1932,7 @@ impl Renderer {
|
||||
&sprite_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
&emission_stats.view,
|
||||
);
|
||||
let mirror_group = terrain_group(
|
||||
&device,
|
||||
@@ -1894,6 +1943,7 @@ impl Renderer {
|
||||
&sprite_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
&emission_stats.view,
|
||||
);
|
||||
let index_quads = 1 << 16;
|
||||
let indices = Self::quad_indices(&device, index_quads);
|
||||
@@ -3023,6 +3073,7 @@ impl Renderer {
|
||||
lightmap_view,
|
||||
lightmap_sampler,
|
||||
animations,
|
||||
active_animated_layers: vec![0; active_layer_words],
|
||||
uploads,
|
||||
section_uploads,
|
||||
section_copies: None,
|
||||
@@ -3069,6 +3120,7 @@ impl Renderer {
|
||||
layout: crate::chunk::ColumnLayout::VANILLA,
|
||||
world_post,
|
||||
hdr: None,
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
@@ -3431,6 +3483,8 @@ impl Renderer {
|
||||
{
|
||||
self.queue.submit([copies]);
|
||||
}
|
||||
let animated_layers =
|
||||
collect_animated_layers(mesh, |layer| self.animations.is_animated(layer));
|
||||
Some(SectionGpu {
|
||||
page,
|
||||
start,
|
||||
@@ -3453,6 +3507,7 @@ impl Renderer {
|
||||
flat_id,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers,
|
||||
})
|
||||
}
|
||||
/// The section copies recorded since the last submission, closed.
|
||||
@@ -3812,6 +3867,7 @@ impl Renderer {
|
||||
),
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
self.measure_emission();
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.ui = ui;
|
||||
@@ -3825,6 +3881,15 @@ impl Renderer {
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
/// Measures which pixels of each block sprite may shine (`hdr_stats`), at the current
|
||||
/// masks. Only with HDR output on: without it the frame's alpha is never read.
|
||||
pub fn measure_emission(&mut self) {
|
||||
if let Some(hdr) = &self.hdr {
|
||||
let views = sprite_views(&self.sprite_textures, &self.sprite_unused, None);
|
||||
self.emission_stats
|
||||
.measure(&self.device, &self.queue, &views, hdr.masks);
|
||||
}
|
||||
}
|
||||
/// Whether what the system reports for the display changed (another output, the
|
||||
/// player recalibrating the desktop): the settings' Auto values follow it then, so
|
||||
/// apply them again.
|
||||
@@ -3897,6 +3962,7 @@ impl Renderer {
|
||||
pack_post::PackPost::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&self.adapter,
|
||||
post,
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
world,
|
||||
@@ -3952,8 +4018,24 @@ impl Renderer {
|
||||
/// What a shader pack's gbuffers programs read from Horizon's geometry: the sky's, and
|
||||
/// terrain's in the pack's block atlas (made here when missing; none when it does not fit).
|
||||
pub fn pack_inputs(&mut self) -> horizon_shaders::GameInputs {
|
||||
let terrain = self.pack_terrain_inputs();
|
||||
// A geometry stage reads flat draws' vertices itself, through their index buffer.
|
||||
let terrain_geometry = terrain
|
||||
.clone()
|
||||
.filter(|_| self.pack_terrain_flat)
|
||||
.map(|mut inputs| {
|
||||
inputs.declarations.push_str(
|
||||
"layout(set = 5, binding = 0) readonly buffer HzIndices { uint hz_indices[]; };\n",
|
||||
);
|
||||
horizon_shaders::GeometryInputs {
|
||||
inputs,
|
||||
pull: pack_atlas::PULL.to_owned(),
|
||||
index: "hz_indices[@]".to_owned(),
|
||||
}
|
||||
});
|
||||
horizon_shaders::GameInputs {
|
||||
terrain: self.pack_terrain_inputs(),
|
||||
terrain,
|
||||
terrain_geometry,
|
||||
sky: Some(pack_sky::inputs()),
|
||||
celestial: Some(pack_sky::celestial_inputs()),
|
||||
entities: Some(pack_entities::inputs()),
|
||||
@@ -4074,6 +4156,7 @@ impl Renderer {
|
||||
&self.sprite_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
&self.emission_stats.view,
|
||||
);
|
||||
}
|
||||
/// The Mipmap Levels option: block textures sample at most `levels`
|
||||
@@ -4184,8 +4267,10 @@ impl Renderer {
|
||||
self.lightmap_pixels = pixels;
|
||||
self.lightmap_uploaded = true;
|
||||
}
|
||||
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) {
|
||||
self.animations.animate(encoder, &mut self.uploads, tick);
|
||||
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64, cull_inactive: bool) {
|
||||
let active = cull_inactive.then_some(&self.active_animated_layers[..]);
|
||||
self.animations
|
||||
.animate(encoder, &mut self.uploads, tick, active);
|
||||
if let Some(atlas) = &self.pack_atlas {
|
||||
atlas.copy(
|
||||
encoder,
|
||||
@@ -4310,7 +4395,6 @@ impl Renderer {
|
||||
})
|
||||
});
|
||||
if self.animated_textures {
|
||||
self.animate(&mut encoder, frame.ticks);
|
||||
self.hud
|
||||
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
|
||||
}
|
||||
@@ -4426,6 +4510,32 @@ impl Renderer {
|
||||
}
|
||||
lights
|
||||
},
|
||||
colored_lights: {
|
||||
let mut lights = [[[0.0; 4]; 2]; crate::dynamic_light::MAX];
|
||||
for (to, from) in lights.iter_mut().zip(frame.colored_lights) {
|
||||
*to = *from;
|
||||
}
|
||||
lights
|
||||
},
|
||||
colored_count: [
|
||||
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
emissive_layers: {
|
||||
let mut layers = [[0; 4]; crate::native_emissive::MAX];
|
||||
for (to, from) in layers.iter_mut().zip(frame.emissive_layers) {
|
||||
*to = *from;
|
||||
}
|
||||
layers
|
||||
},
|
||||
emissive_count: [
|
||||
frame.emissive_layers.len().min(crate::native_emissive::MAX) as u32,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
|
||||
filtering: [
|
||||
self.texture_filtering as f32,
|
||||
@@ -4554,14 +4664,27 @@ impl Renderer {
|
||||
planes: frustum(v.cull),
|
||||
};
|
||||
crate::spans::time("shadow visibility", || {
|
||||
self.sections.visible(
|
||||
camera,
|
||||
radius,
|
||||
&light,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut shadow_visible,
|
||||
)
|
||||
// Within the unculled distance every section, then those in the light's box.
|
||||
if let Some(d) = v.unculled {
|
||||
let all = Frustum {
|
||||
planes: [glam::Vec4::W; 6],
|
||||
};
|
||||
let near = radius.min((d / 16.0).ceil() as i32);
|
||||
self.sections.visible(
|
||||
camera,
|
||||
near,
|
||||
&all,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut shadow_visible,
|
||||
);
|
||||
}
|
||||
let mut boxed = Vec::new();
|
||||
self.sections
|
||||
.visible(camera, radius, &light, |_| true, |_, _| false, &mut boxed);
|
||||
let seen: std::collections::HashSet<_> =
|
||||
shadow_visible.iter().map(|s| s.key).collect();
|
||||
shadow_visible.extend(boxed.into_iter().filter(|s| !seen.contains(&s.key)));
|
||||
});
|
||||
// Nearest the light first, so the depth test rejects what lies under it.
|
||||
let light = v.light;
|
||||
@@ -4570,6 +4693,43 @@ impl Renderer {
|
||||
height(b).total_cmp(&height(a))
|
||||
});
|
||||
}
|
||||
if self.animated_textures {
|
||||
self.active_animated_layers.fill(0);
|
||||
for v in &visible {
|
||||
let s = self.sections.get(v.slot);
|
||||
for &layer in &*s.animated_layers {
|
||||
let layer = layer as usize;
|
||||
self.active_animated_layers[layer / u64::BITS as usize] |=
|
||||
1_u64 << (layer % u64::BITS as usize);
|
||||
}
|
||||
}
|
||||
for v in &visible_lods {
|
||||
if let Some(tile) = self.lods.get(&v.key) {
|
||||
for &layer in &*tile.drawn().animated_layers {
|
||||
let layer = layer as usize;
|
||||
self.active_animated_layers[layer / u64::BITS as usize] |=
|
||||
1_u64 << (layer % u64::BITS as usize);
|
||||
}
|
||||
for &layer in &*tile.mesh.animated_layers {
|
||||
let layer = layer as usize;
|
||||
self.active_animated_layers[layer / u64::BITS as usize] |=
|
||||
1_u64 << (layer % u64::BITS as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in &shadow_visible {
|
||||
let s = self.sections.get(v.slot);
|
||||
for &layer in &*s.animated_layers {
|
||||
let layer = layer as usize;
|
||||
self.active_animated_layers[layer / u64::BITS as usize] |=
|
||||
1_u64 << (layer % u64::BITS as usize);
|
||||
}
|
||||
}
|
||||
// Visibility is collected before terrain draw commands are recorded,
|
||||
// so upload the current mask here and keep animation updates ahead
|
||||
// of every draw in this command buffer.
|
||||
self.animate(&mut encoder, frame.ticks, !frame.title);
|
||||
}
|
||||
self.drawn_sections = visible.len() + visible_lods.len();
|
||||
let instances = self.drawn_sections + shadow_visible.len();
|
||||
if instances as u64 > self.instance_capacity {
|
||||
@@ -4874,7 +5034,13 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
let before = self.draws.len();
|
||||
facing_runs(&s.sides, Some(toward), v.origin, |first, quads| {
|
||||
// Every face of a section within the unculled distance.
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = glam::Vec3::from(v.origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled).then_some(toward);
|
||||
facing_runs(&s.sides, toward, v.origin, |first, quads| {
|
||||
let start = s.start as u32 + first * 4;
|
||||
self.draws.push([quads * 6, 1, 0, start, instance]);
|
||||
self.draw_pages.push((s.page, None));
|
||||
@@ -5290,7 +5456,10 @@ impl Renderer {
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa) || oit_on;
|
||||
let keep_depth = pack_reads_world
|
||||
|| (taa && self.world_post.taa)
|
||||
|| oit_on
|
||||
|| native_render::render_needs().world_depth;
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -6203,7 +6372,8 @@ impl Renderer {
|
||||
let mut terrain =
|
||||
|rp: &mut wgpu::RenderPass<'_>,
|
||||
list: pack_post::TerrainList,
|
||||
page: &dyn Fn(&mut wgpu::RenderPass<'_>, u32)| {
|
||||
page: &dyn Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
geometry: Option<(u32, &wgpu::BindGroupLayout)>| {
|
||||
use pack_post::TerrainList as L;
|
||||
let shadow = matches!(list, L::ShadowSolid | L::ShadowTranslucent);
|
||||
if !(if shadow {
|
||||
@@ -6222,8 +6392,26 @@ impl Renderer {
|
||||
L::ShadowSolid => &flat.calls[shadow..shadow_translucent],
|
||||
L::ShadowTranslucent => &flat.calls[shadow_translucent..],
|
||||
};
|
||||
flat.draw_with(rp, calls, |p| self.arena.buffer(p), page);
|
||||
match geometry {
|
||||
Some((instances, layout)) => {
|
||||
let indices =
|
||||
self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack geometry indices"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: flat.indices.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
rp.set_bind_group(5, &indices, &[]);
|
||||
let vertices = |p| self.arena.buffer(p);
|
||||
flat.draw_geometry(rp, calls, vertices, page, instances);
|
||||
}
|
||||
None => flat.draw_with(rp, calls, |p| self.arena.buffer(p), page),
|
||||
}
|
||||
}
|
||||
// A geometry stage reads flat draws' indices; without them it draws nothing.
|
||||
None if geometry.is_some() => {}
|
||||
None => {
|
||||
rp.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
rp.set_vertex_buffer(1, self.instances.slice(..));
|
||||
@@ -6280,6 +6468,30 @@ impl Renderer {
|
||||
self.world_post
|
||||
.finish(&self.queue, &mut encoder, &view_target);
|
||||
}
|
||||
if !frame.title {
|
||||
let (depth, depth_size) = match &scaled {
|
||||
Some((_, depth)) => (depth, self.world_post.size().unwrap_or(size)),
|
||||
None => (&self.depth, size),
|
||||
};
|
||||
native_render::native_world_drawn(&mut native_render::WorldDrawn {
|
||||
device: &self.device,
|
||||
queue: &self.queue,
|
||||
encoder: &mut encoder,
|
||||
color: &target_texture,
|
||||
color_view: &view_target,
|
||||
color_format: self.config.format.remove_srgb_suffix(),
|
||||
size,
|
||||
depth,
|
||||
depth_size,
|
||||
view_proj,
|
||||
jittered,
|
||||
camera: frame.camera,
|
||||
entities: frame.entities,
|
||||
spans: frame.entity_spans,
|
||||
atlas: self.hud.atlas_texture.as_ref(),
|
||||
quad_indices: &self.indices,
|
||||
});
|
||||
}
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder);
|
||||
}
|
||||
@@ -7592,6 +7804,25 @@ mod sprite_upload_tests {
|
||||
assert_eq!(chain.capacity(), chain.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_animated_layers_masks_opaque_layer() {
|
||||
use crate::mesher::{self, SectionMesh, TerrainVertex};
|
||||
let opaque_vertex =
|
||||
TerrainVertex::new([0.0, 0.0, 0.0], [0.0, 0.0], 7 | mesher::OPAQUE_LAYER, 0, 0);
|
||||
let translucent_vertex = TerrainVertex::new([0.0, 0.0, 0.0], [0.0, 0.0], 12, 0, 0);
|
||||
let mut mesh = SectionMesh {
|
||||
solid: vec![opaque_vertex; 4],
|
||||
translucent: vec![translucent_vertex; 4],
|
||||
..Default::default()
|
||||
};
|
||||
mesh.note_sprite_layer(7 | mesher::OPAQUE_LAYER);
|
||||
mesh.note_sprite_layer(12);
|
||||
mesh.finish_sprite_layers();
|
||||
|
||||
let animated = super::collect_animated_layers(&mesh, |layer| layer == 7 || layer == 12);
|
||||
assert_eq!(&*animated, &[7, 12]);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the system says about the window's display, kept up to date: on Wayland, the
|
||||
|
||||
@@ -72,6 +72,8 @@ pub(super) struct SpriteAnimator {
|
||||
size: u32,
|
||||
/// The layers the last `animate` rewrote, counted over all arrays.
|
||||
changed: Vec<u32>,
|
||||
/// Whether each sprite layer has an animation.
|
||||
is_animated: Vec<bool>,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
@@ -147,6 +149,11 @@ impl SpriteAnimator {
|
||||
group,
|
||||
});
|
||||
}
|
||||
let max_layer = animations.iter().map(|a| a.layer).max().unwrap_or(0);
|
||||
let mut is_animated = vec![false; (max_layer + 1) as usize];
|
||||
for a in animations {
|
||||
is_animated[a.layer as usize] = true;
|
||||
}
|
||||
let Some((pipeline, layout)) = pipeline else {
|
||||
let groups = keys
|
||||
.iter()
|
||||
@@ -168,6 +175,7 @@ impl SpriteAnimator {
|
||||
},
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
is_animated,
|
||||
};
|
||||
};
|
||||
let frames: Vec<_> = frames
|
||||
@@ -247,9 +255,23 @@ impl SpriteAnimator {
|
||||
writer: Writer::Gpu { jobs, pipeline },
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
is_animated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the sprite layer has an animation.
|
||||
pub fn is_animated(&self, layer: u16) -> bool {
|
||||
self.is_animated
|
||||
.get(layer as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Number of words needed to mark the animated layers visible this frame.
|
||||
pub fn active_layer_words(&self) -> usize {
|
||||
self.is_animated.len().div_ceil(u64::BITS as usize)
|
||||
}
|
||||
|
||||
/// Whether the jobs can run on the GPU: compute shaders writing a
|
||||
/// storage texture and reading two storage buffers (not OpenGL before
|
||||
/// 4.3).
|
||||
@@ -269,14 +291,16 @@ impl SpriteAnimator {
|
||||
}
|
||||
|
||||
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
|
||||
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
|
||||
/// frame or mix changed (`SpriteContents.AnimationState.tick`), skipping
|
||||
/// sprites not present in `active` when provided.
|
||||
pub fn animate(
|
||||
&mut self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
active: Option<&[u64]>,
|
||||
) {
|
||||
if self.collect(tick) == 0 {
|
||||
if self.collect(tick, active) == 0 {
|
||||
return;
|
||||
}
|
||||
let (jobs, pipeline) = match &self.writer {
|
||||
@@ -336,10 +360,18 @@ impl SpriteAnimator {
|
||||
|
||||
/// Queue a job for every sprite whose blend at `tick` differs from what
|
||||
/// its layer shows; the number of jobs.
|
||||
fn collect(&mut self, tick: u64) -> usize {
|
||||
fn collect(&mut self, tick: u64, active: Option<&[u64]>) -> usize {
|
||||
let mut count = 0;
|
||||
self.changed.clear();
|
||||
for sprite in &mut self.sprites {
|
||||
if let Some(active) = active {
|
||||
let layer = sprite.animation.layer as usize;
|
||||
let word = layer / u64::BITS as usize;
|
||||
let bit = 1_u64 << (layer % u64::BITS as usize);
|
||||
if active.get(word).is_none_or(|active| active & bit == 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let blend = sprite.animation.blend(tick);
|
||||
if sprite.shown == Some(blend) {
|
||||
continue;
|
||||
@@ -827,7 +859,7 @@ mod tests {
|
||||
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
|
||||
animator.animate(&mut encoder, &mut uploads, tick);
|
||||
animator.animate(&mut encoder, &mut uploads, tick, None);
|
||||
writes.push(
|
||||
animator
|
||||
.sprites
|
||||
@@ -864,4 +896,56 @@ mod tests {
|
||||
// its frame turns over (every 2 ticks, but 12 shows 7's frame).
|
||||
assert_eq!(writes, [3, 0, 2, 3, 2, 3, 3, 0, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_sprites_are_skipped_by_collect() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = 16;
|
||||
let clips = [
|
||||
(1, 16, r#"{"frametime":1}"#),
|
||||
(3, 16, r#"{"frametime":1}"#),
|
||||
(5, 16, r#"{"frametime":1}"#),
|
||||
];
|
||||
let mut mips = vec![Settings::default(); 6];
|
||||
let animations: Vec<_> = clips
|
||||
.iter()
|
||||
.map(|&(layer, native, metadata)| {
|
||||
mips[layer as usize] = settings(native, Strategy::Cutout);
|
||||
let frames = (0..3)
|
||||
.map(|f| crate::sprites::resample(&frame(native, layer * 7 + f), native, size))
|
||||
.collect();
|
||||
crate::sprites::test_frames(layer, frames, metadata)
|
||||
})
|
||||
.collect();
|
||||
// Construct with CPU writer (empty textures is fine for collect testing)
|
||||
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &[], 2, false);
|
||||
assert!(animator.is_animated(1));
|
||||
assert!(!animator.is_animated(2));
|
||||
assert!(animator.is_animated(3));
|
||||
assert!(animator.is_animated(5));
|
||||
|
||||
// When active layer is [1], only sprite 1 is collected
|
||||
let active = [1_u64 << 1];
|
||||
let count = animator.collect(1, Some(&active));
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(animator.changed, vec![1]);
|
||||
|
||||
// When active is empty, 0 are collected
|
||||
let count = animator.collect(2, Some(&[0]));
|
||||
assert_eq!(count, 0);
|
||||
assert!(animator.changed.is_empty());
|
||||
|
||||
// When active is [3, 5], both 3 and 5 are collected
|
||||
let active = [1_u64 << 3 | 1_u64 << 5];
|
||||
let count = animator.collect(3, Some(&active));
|
||||
assert_eq!(count, 2);
|
||||
assert_eq!(animator.changed, vec![3, 5]);
|
||||
|
||||
// When active is None, all out-of-date sprites update
|
||||
let count = animator.collect(5, None);
|
||||
assert_eq!(count, 3);
|
||||
assert_eq!(animator.changed, vec![1, 3, 5]);
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
}
|
||||
|
||||
/// Whether frames without a shader pack's terrain draw flat: where the backend
|
||||
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11's Vulkan
|
||||
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
|
||||
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
|
||||
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|
||||
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
@@ -137,15 +137,34 @@ pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|
||||
|| is_broken_intel_vulkan(info)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
|
||||
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
|
||||
/// and flicker.
|
||||
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
|
||||
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
|
||||
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
|
||||
/// be drawn with another section's origin and flicker.
|
||||
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Vulkan
|
||||
&& info.vendor == 0x8086
|
||||
&& (info.name.contains("ICL")
|
||||
|| info.name.contains("Ice Lake")
|
||||
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
|
||||
&& (matches!(
|
||||
info.device,
|
||||
0x8a50..=0x8a71 // Gen11: Ice Lake
|
||||
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
|
||||
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
|
||||
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
|
||||
| 0xa700..=0xa7ff // Gen12: Raptor Lake
|
||||
) || [
|
||||
"ICL",
|
||||
"Ice Lake",
|
||||
"TGL",
|
||||
"Tiger Lake",
|
||||
"RKL",
|
||||
"Rocket Lake",
|
||||
"ADL",
|
||||
"Alder Lake",
|
||||
"RPL",
|
||||
"Raptor Lake",
|
||||
]
|
||||
.iter()
|
||||
.any(|tag| info.name.contains(tag)))
|
||||
}
|
||||
|
||||
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
@@ -725,6 +744,23 @@ impl Flat {
|
||||
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
/// Draws `calls` without their index buffer, `instances` times: a shader pack's geometry
|
||||
/// stage reads the indices itself (`hz_indices`) and stands for one emitted triangle per
|
||||
/// instance.
|
||||
pub fn draw_geometry<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
instances: u32,
|
||||
) {
|
||||
for call in calls {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
page(pass, call.page);
|
||||
pass.draw(call.first..call.first + call.count, 0..instances);
|
||||
}
|
||||
}
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them.
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
|
||||
@@ -91,6 +91,19 @@ pub const TIERS: [Tier; 4] = [
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
|
||||
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
|
||||
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
|
||||
/// (`mesher::WHOLE`). The tier windows still decide how much.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Masks {
|
||||
pub parts: f32,
|
||||
pub whole: f32,
|
||||
}
|
||||
pub const MASKS: Masks = Masks {
|
||||
parts: 0.32,
|
||||
whole: 0.0,
|
||||
};
|
||||
/// A step of HDR calibration (`crate::hdr_calibration`): its test picture is drawn in nits
|
||||
/// above the controls instead of the frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -133,6 +146,8 @@ pub struct Hdr {
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
/// Which pixels of a light source shine (`Masks`).
|
||||
pub masks: Masks,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
@@ -276,6 +291,7 @@ impl Hdr {
|
||||
black: 0.0,
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
@@ -586,6 +602,7 @@ mod tests {
|
||||
for source in [
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
include_str!("../hdr_stats.wgsl"),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! HDR emission thresholds (`hdr_stats.wgsl`): for every block sprite, the brightness its
|
||||
//! pixels must reach to shine in HDR output, measured on the GPU from the sprite's own
|
||||
//! darkest and brightest opaque pixel when HDR output turns on. Until then (and with HDR
|
||||
//! off) the texture holds zeros: every pixel of a light source may shine, and hdr.wgsl's
|
||||
//! tier windows alone decide, as before.
|
||||
|
||||
use super::hdr::Masks;
|
||||
|
||||
pub struct EmissionStats {
|
||||
/// One texel per sprite: x the layer in its array, y the array.
|
||||
pub view: wgpu::TextureView,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl EmissionStats {
|
||||
pub fn new(device: &wgpu::Device, split: u32, arrays: u32) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
size: wgpu::Extent3d {
|
||||
width: split,
|
||||
height: arrays,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr_stats.wgsl").into()),
|
||||
});
|
||||
let mut entries: Vec<_> = (0..super::SPRITE_ARRAYS)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: super::SPRITE_ARRAYS,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
entries: &entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_stats"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_stats"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR emission threshold parameters"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Self {
|
||||
view,
|
||||
layout,
|
||||
pipeline,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures every sprite of `sprites` (terrain.wgsl's arrays) at `masks`.
|
||||
pub fn measure(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
sprites: &[wgpu::TextureView],
|
||||
masks: Masks,
|
||||
) {
|
||||
let mut params = [0u8; 16];
|
||||
params[..4].copy_from_slice(&masks.parts.to_le_bytes());
|
||||
params[4..8].copy_from_slice(&masks.whole.to_le_bytes());
|
||||
queue.write_buffer(&self.params, 0, ¶ms);
|
||||
let mut entries: Vec<_> = sprites
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, view)| wgpu::BindGroupEntry {
|
||||
binding: binding as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
})
|
||||
.collect();
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: super::SPRITE_ARRAYS,
|
||||
resource: self.params.as_entire_binding(),
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR emission thresholds"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
}
|
||||
@@ -96,21 +96,8 @@ struct Bounds {
|
||||
|
||||
impl Bounds {
|
||||
fn mesh(mesh: &SectionMesh) -> Option<Self> {
|
||||
let mut positions = mesh
|
||||
.solid
|
||||
.iter()
|
||||
.chain(&mesh.translucent)
|
||||
.map(|v| Vec3::from(v.pos));
|
||||
let first = positions.next()?;
|
||||
let mut bounds = Self {
|
||||
min: first,
|
||||
max: first,
|
||||
};
|
||||
for pos in positions {
|
||||
bounds.min = bounds.min.min(pos);
|
||||
bounds.max = bounds.max.max(pos);
|
||||
}
|
||||
Some(bounds)
|
||||
let [min, max] = mesh.bounds?;
|
||||
Some(Self { min, max })
|
||||
}
|
||||
|
||||
/// In view: the plan alone decides which tiles are in range (a circle),
|
||||
@@ -419,7 +406,8 @@ impl Renderer {
|
||||
let mesh = pending.meshes.remove(&key).expect("pending tile");
|
||||
let [x, _, z] = key.origin();
|
||||
let size = key.size();
|
||||
let keep = around_new.contains(&key)
|
||||
let keep = mesh.keep
|
||||
|| around_new.contains(&key)
|
||||
|| taken.iter().any(|&(cx, cz)| {
|
||||
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
|
||||
});
|
||||
@@ -511,6 +499,7 @@ mod tests {
|
||||
let mesh = SectionMesh {
|
||||
solid: vec![vertex([0.0, -64.0, 0.0]), vertex([1024.0, 100.0, 1024.0])],
|
||||
translucent: vec![vertex([512.0, 120.0, 512.0])],
|
||||
bounds: Some([Vec3::new(0.0, -64.0, 0.0), Vec3::new(1024.0, 120.0, 1024.0)]),
|
||||
..Default::default()
|
||||
};
|
||||
let bounds = Bounds::mesh(&mesh).unwrap();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Render hook points for native mods (docs/native-mods.md): empty functions the renderer calls at
|
||||
//! fixed places of a frame, which a mod's mixins hook like those of
|
||||
//! `horizon-client/src/game/native_hooks.rs`, and listed with them in `native-hooks.json`. API
|
||||
//! 58.
|
||||
|
||||
use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
|
||||
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
|
||||
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
|
||||
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
|
||||
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
|
||||
pub struct WorldDrawn<'a> {
|
||||
pub device: &'a wgpu::Device,
|
||||
pub queue: &'a wgpu::Queue,
|
||||
pub encoder: &'a mut wgpu::CommandEncoder,
|
||||
/// The frame's target and a view of it in `color_format`.
|
||||
pub color: &'a wgpu::Texture,
|
||||
pub color_view: &'a wgpu::TextureView,
|
||||
pub color_format: wgpu::TextureFormat,
|
||||
/// The size of `color`.
|
||||
pub size: [u32; 2],
|
||||
/// The world's depth (Depth32Float, sampleable), and its size: smaller than `size` when the
|
||||
/// world draws at a lower resolution. It holds the frame's depth only when a mod asked for
|
||||
/// it ([`RenderNeeds::world_depth`]) or something else kept it.
|
||||
pub depth: &'a wgpu::TextureView,
|
||||
pub depth_size: [u32; 2],
|
||||
/// Camera-relative world to clip space, without TAA's jitter, and with it (what the world
|
||||
/// was drawn with).
|
||||
pub view_proj: Mat4,
|
||||
pub jittered: Mat4,
|
||||
/// The camera's position in the world.
|
||||
pub camera: DVec3,
|
||||
/// The camera-relative entity geometry of the frame (`Frame::entities`), quads of 4
|
||||
/// vertices, and its runs by entity when a mod asked for them
|
||||
/// ([`RenderNeeds::entity_spans`]) or a shader pack draws them.
|
||||
pub entities: &'a [Vertex],
|
||||
pub spans: &'a [EntitySpan],
|
||||
/// The shared atlas entity UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
|
||||
pub quad_indices: &'a wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
|
||||
/// needs and leaves the rest, so several can ask.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RenderNeeds {
|
||||
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
|
||||
/// their type's name, block entity runs their block state.
|
||||
pub entity_spans: bool,
|
||||
/// Keep the world pass's depth for [`native_world_drawn`] when nothing else does.
|
||||
pub world_depth: bool,
|
||||
}
|
||||
|
||||
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
|
||||
#[inline(never)]
|
||||
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
|
||||
let _ = world;
|
||||
}
|
||||
|
||||
/// Asked as a frame is built and drawn: set what your mod needs.
|
||||
#[inline(never)]
|
||||
pub fn native_render_needs(needs: &mut RenderNeeds) {
|
||||
let _ = needs;
|
||||
}
|
||||
|
||||
/// What the mods need this frame ([`native_render_needs`]).
|
||||
pub fn render_needs() -> RenderNeeds {
|
||||
let mut needs = RenderNeeds::default();
|
||||
native_render_needs(&mut needs);
|
||||
needs
|
||||
}
|
||||
@@ -113,6 +113,16 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
|
||||
/// inputs, for vertex `hz_vertex_index`.
|
||||
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
|
||||
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
|
||||
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
|
||||
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
|
||||
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
|
||||
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
|
||||
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
|
||||
|
||||
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
|
||||
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
|
||||
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
@@ -194,8 +204,15 @@ impl PackAtlas {
|
||||
} else {
|
||||
"vec4(-1.0, -1.0, 0.0, 0.0)"
|
||||
};
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal).
|
||||
let mid_block = if flat {
|
||||
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, 0.0)"
|
||||
} else {
|
||||
"vec4(0.0)"
|
||||
};
|
||||
let attributes = [
|
||||
("mc_Entity", entity),
|
||||
("at_midBlock", mid_block),
|
||||
(
|
||||
"mc_midTexCoord",
|
||||
"vec4((hz_cell + 0.5) * hz_scale, 0.0, 1.0)",
|
||||
|
||||
@@ -11,6 +11,13 @@ pub(super) struct PackImage {
|
||||
pub(super) format: wgpu::TextureFormat,
|
||||
pub(super) dimension: wgpu::TextureViewDimension,
|
||||
pub(super) view: Option<wgpu::TextureView>,
|
||||
/// Last frame's contents, for a pass that samples and stores the image (`copied`).
|
||||
pub(super) copy: Option<wgpu::TextureView>,
|
||||
pub(super) copied: bool,
|
||||
/// The contents before a compute pass that reads them so (`snapshot`), apart from `copy`,
|
||||
/// which a later pass may still sample.
|
||||
pub(super) before: Option<wgpu::TextureView>,
|
||||
pub(super) snapshotted: bool,
|
||||
/// The render size the image was made for (relative images follow it).
|
||||
made_for: [u32; 2],
|
||||
}
|
||||
@@ -35,13 +42,17 @@ pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
|
||||
},
|
||||
def,
|
||||
view: None,
|
||||
copy: None,
|
||||
copied: false,
|
||||
before: None,
|
||||
snapshotted: false,
|
||||
made_for: [0; 2],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's internal format: a storage format of the same channels.
|
||||
fn format(f: Format) -> wgpu::TextureFormat {
|
||||
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
|
||||
use wgpu::TextureFormat as T;
|
||||
let pick = |r, rg, rgba| match f.channels {
|
||||
1 => r,
|
||||
@@ -106,43 +117,158 @@ pub(super) fn resize_images(
|
||||
// A relative image larger than the render size stops at the device's limit.
|
||||
let [width, height, depth] = image.def.extent(size);
|
||||
let [width, height] = [width.min(largest), height.min(largest)];
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(&image.def.name),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: depth,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
|
||||
wgpu::TextureDimension::D3
|
||||
} else {
|
||||
wgpu::TextureDimension::D2
|
||||
},
|
||||
format: image.format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
image.view = Some(texture.create_view(&Default::default()));
|
||||
let make = |usage| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(&image.def.name),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: depth,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
|
||||
wgpu::TextureDimension::D3
|
||||
} else {
|
||||
wgpu::TextureDimension::D2
|
||||
},
|
||||
format: image.format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
// Atomics on a texture need it made for them (Metal ignores them otherwise).
|
||||
let atomic = device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
|
||||
&& matches!(
|
||||
image.format,
|
||||
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
|
||||
);
|
||||
let atomic = if atomic {
|
||||
wgpu::TextureUsages::STORAGE_ATOMIC
|
||||
} else {
|
||||
wgpu::TextureUsages::empty()
|
||||
};
|
||||
image.view = Some(make(
|
||||
wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| atomic,
|
||||
));
|
||||
image.copy = image
|
||||
.copied
|
||||
.then(|| make(wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::STORAGE_BINDING));
|
||||
image.before = image
|
||||
.snapshotted
|
||||
.then(|| make(wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::STORAGE_BINDING));
|
||||
image.made_for = size;
|
||||
}
|
||||
made
|
||||
}
|
||||
|
||||
/// Clears the images the pack clears before each frame.
|
||||
pub(super) fn clear(encoder: &mut wgpu::CommandEncoder, images: &[PackImage]) {
|
||||
/// The most images a pack may have.
|
||||
pub(super) const IMAGES: usize = 64;
|
||||
|
||||
/// Copies `image` as it is before a compute pass whose loads read it so.
|
||||
pub(super) fn snapshot(encoder: &mut wgpu::CommandEncoder, image: &PackImage) {
|
||||
if let (Some(view), Some(before)) = (&image.view, &image.before) {
|
||||
encoder.copy_texture_to_texture(
|
||||
view.texture().as_image_copy(),
|
||||
before.texture().as_image_copy(),
|
||||
view.texture().size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the images of `mask` (bit n: image n), which a pass samples and stores, for that pass
|
||||
/// to sample.
|
||||
pub(super) fn copy(encoder: &mut wgpu::CommandEncoder, images: &[PackImage], mask: u64) {
|
||||
for (_, image) in images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, _)| mask & 1 << k != 0)
|
||||
{
|
||||
if let (Some(view), Some(copy)) = (&image.view, &image.copy) {
|
||||
encoder.copy_texture_to_texture(
|
||||
view.texture().as_image_copy(),
|
||||
copy.texture().as_image_copy(),
|
||||
view.texture().size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the images the pack clears before each frame: with `clear_texture` where the device
|
||||
/// has it (`clear`), else by copying from `zeros`, a buffer of zeros made large enough.
|
||||
pub(super) fn clear(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
images: &[PackImage],
|
||||
clear: bool,
|
||||
zeros: &mut Option<wgpu::Buffer>,
|
||||
) {
|
||||
for view in images
|
||||
.iter()
|
||||
.filter(|i| i.def.clear)
|
||||
.filter_map(|i| i.view.as_ref())
|
||||
{
|
||||
encoder.clear_texture(view.texture(), &Default::default());
|
||||
let texture = view.texture();
|
||||
if clear {
|
||||
encoder.clear_texture(texture, &Default::default());
|
||||
continue;
|
||||
}
|
||||
let size = texture.size();
|
||||
let texel = u64::from(texture.format().block_copy_size(None).unwrap_or(4));
|
||||
let row = (u64::from(size.width) * texel).next_multiple_of(256);
|
||||
// Zeros for a slice of rows at a time, the whole image taken in slices, so a large
|
||||
// volume doesn't need a buffer of its size.
|
||||
let rows = (ZEROS / row).clamp(1, u64::from(size.height)) as u32;
|
||||
let bytes = row * u64::from(rows);
|
||||
if zeros.as_ref().is_none_or(|z| z.size() < bytes) {
|
||||
// New buffers start zeroed.
|
||||
*zeros = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack image zeros"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
}
|
||||
let Some(buffer) = zeros.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
for z in 0..size.depth_or_array_layers {
|
||||
for y in (0..size.height).step_by(rows as usize) {
|
||||
let height = rows.min(size.height - y);
|
||||
encoder.copy_buffer_to_texture(
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(row as u32),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d { x: 0, y, z },
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: size.width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most bytes of zeros kept to clear images without `CLEAR_TEXTURE`.
|
||||
const ZEROS: u64 = 4 << 20;
|
||||
|
||||
/// The pack's storage buffers, bound in set 4.
|
||||
pub(super) struct Buffers {
|
||||
pub(super) layout: wgpu::BindGroupLayout,
|
||||
@@ -153,12 +279,17 @@ pub(super) struct Buffers {
|
||||
}
|
||||
|
||||
impl Buffers {
|
||||
pub(super) fn new(device: &wgpu::Device, objects: BTreeMap<u32, BufferObject>) -> Self {
|
||||
/// The layout of `objects`, bound in `stages`.
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
objects: BTreeMap<u32, BufferObject>,
|
||||
stages: wgpu::ShaderStages,
|
||||
) -> Self {
|
||||
let entries: Vec<_> = objects
|
||||
.keys()
|
||||
.map(|&binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT | wgpu::ShaderStages::COMPUTE,
|
||||
visibility: stages,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: false },
|
||||
has_dynamic_offset: false,
|
||||
@@ -236,3 +367,94 @@ impl Buffers {
|
||||
self.objects.contains_key(&binding)
|
||||
}
|
||||
}
|
||||
|
||||
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
|
||||
/// frames, how many of its texels are not zero, logged.
|
||||
pub(super) struct Dump {
|
||||
name: String,
|
||||
frame: u32,
|
||||
pending: Option<(wgpu::Buffer, usize, [u32; 3], u32)>,
|
||||
}
|
||||
|
||||
impl Dump {
|
||||
pub(super) fn new() -> Option<Self> {
|
||||
let name = std::env::var("HORIZON_SHADER_PACK_DUMP").ok()?;
|
||||
Some(Self {
|
||||
name,
|
||||
frame: 0,
|
||||
pending: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads last frame's copy, once its commands ran.
|
||||
pub(super) fn read(&mut self, device: &wgpu::Device) {
|
||||
let Some((buffer, texel, [w, h, d], row)) = self.pending.take() else {
|
||||
return;
|
||||
};
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
let _ = device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
});
|
||||
let Ok(data) = buffer.slice(..).get_mapped_range() else {
|
||||
return;
|
||||
};
|
||||
let (mut set, mut total) = (0usize, 0usize);
|
||||
for z in 0..d as usize {
|
||||
for y in 0..h as usize {
|
||||
let start = (z * h as usize + y) * row as usize;
|
||||
for x in 0..w as usize {
|
||||
let at = start + x * texel;
|
||||
total += 1;
|
||||
if data[at..at + texel].iter().any(|&b| b != 0) {
|
||||
set += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"shader pack image {}: {set} of {total} texels set",
|
||||
self.name
|
||||
);
|
||||
}
|
||||
|
||||
/// Copies the image for `read` every 60 frames.
|
||||
pub(super) fn copy(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
images: &[PackImage],
|
||||
) {
|
||||
self.frame += 1;
|
||||
if !self.frame.is_multiple_of(60) {
|
||||
return;
|
||||
}
|
||||
let Some(image) = images.iter().find(|i| i.def.name == self.name) else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = &image.view else { return };
|
||||
let size = view.texture().size();
|
||||
let texel = image.format.block_copy_size(None).unwrap_or(4) as usize;
|
||||
let row = (size.width as usize * texel).next_multiple_of(256) as u32;
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack image dump"),
|
||||
size: u64::from(row) * u64::from(size.height) * u64::from(size.depth_or_array_layers),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
view.texture().as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(row),
|
||||
rows_per_image: Some(size.height),
|
||||
},
|
||||
},
|
||||
size,
|
||||
);
|
||||
let dims = [size.width, size.height, size.depth_or_array_layers];
|
||||
self.pending = Some((buffer, texel, dims, row));
|
||||
}
|
||||
}
|
||||
@@ -186,17 +186,31 @@ impl Entities {
|
||||
enum Image {
|
||||
Color(usize),
|
||||
Custom(usize),
|
||||
/// The copy of a buffer or a pack image taken before a dispatch, which its loads read.
|
||||
ColorBefore(usize),
|
||||
CustomBefore(usize),
|
||||
}
|
||||
|
||||
impl Image {
|
||||
fn buffer(self) -> Option<usize> {
|
||||
match self {
|
||||
Image::Color(b) => Some(b),
|
||||
Image::Custom(_) => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The buffers and the pack images (masks) `images` read as copies taken before a dispatch.
|
||||
fn befores(images: &[(u32, Image)]) -> (u16, u64) {
|
||||
images
|
||||
.iter()
|
||||
.fold((0, 0), |(colors, customs), i| match i.1 {
|
||||
Image::ColorBefore(b) => (colors | 1 << b, customs),
|
||||
Image::CustomBefore(k) => (colors, customs | 1 << k),
|
||||
_ => (colors, customs),
|
||||
})
|
||||
}
|
||||
|
||||
/// The color buffers of `images`, as a mask.
|
||||
fn image_buffers(images: &[(u32, Image)]) -> u16 {
|
||||
images
|
||||
@@ -205,6 +219,83 @@ fn image_buffers(images: &[(u32, Image)]) -> u16 {
|
||||
.fold(0, |m, b| m | 1 << b)
|
||||
}
|
||||
|
||||
/// The format of the sink of passes that write only images.
|
||||
const SINK: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
|
||||
|
||||
/// What packs use of the device beyond drawing. OpenGL ES and older drivers lack some of it: a
|
||||
/// program that needs what is missing is skipped with a note, and the game runs on without it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Gpu {
|
||||
compute: bool,
|
||||
/// Images and storage buffers written by fragment shaders.
|
||||
fragment_storage: bool,
|
||||
/// Storage read and written by vertex shaders (a geometry stage).
|
||||
vertex_storage: bool,
|
||||
/// `clear_texture`; without it images are cleared by copying zeros.
|
||||
clear: bool,
|
||||
adapter_formats: bool,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
fn of(adapter: &wgpu::Adapter, features: wgpu::Features) -> Self {
|
||||
use wgpu::DownlevelFlags as D;
|
||||
let flags = adapter.get_downlevel_capabilities().flags;
|
||||
let mut gpu = Gpu {
|
||||
compute: flags.contains(D::COMPUTE_SHADERS),
|
||||
fragment_storage: flags.contains(D::FRAGMENT_WRITABLE_STORAGE),
|
||||
vertex_storage: flags.contains(D::VERTEX_STORAGE)
|
||||
&& features.contains(wgpu::Features::VERTEX_WRITABLE_STORAGE),
|
||||
clear: features.contains(wgpu::Features::CLEAR_TEXTURE),
|
||||
adapter_formats: features
|
||||
.contains(wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES),
|
||||
};
|
||||
// `HORIZON_SHADER_PACK_WITHOUT=compute,fragment-storage,vertex-storage,clear` tries the
|
||||
// fallbacks on a device that has it all.
|
||||
let without = std::env::var("HORIZON_SHADER_PACK_WITHOUT").unwrap_or_default();
|
||||
for w in without.split(',') {
|
||||
match w.trim() {
|
||||
"compute" => gpu.compute = false,
|
||||
"fragment-storage" => gpu.fragment_storage = false,
|
||||
"vertex-storage" => gpu.vertex_storage = false,
|
||||
"clear" => gpu.clear = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
gpu
|
||||
}
|
||||
|
||||
/// Whether textures of `format` can be bound as images.
|
||||
fn storable(
|
||||
self,
|
||||
adapter: &wgpu::Adapter,
|
||||
format: wgpu::TextureFormat,
|
||||
features: wgpu::Features,
|
||||
) -> bool {
|
||||
let usages = if self.adapter_formats {
|
||||
adapter.get_texture_format_features(format).allowed_usages
|
||||
} else {
|
||||
format.guaranteed_format_features(features).allowed_usages
|
||||
};
|
||||
(self.compute || self.fragment_storage)
|
||||
&& usages.contains(wgpu::TextureUsages::STORAGE_BINDING)
|
||||
}
|
||||
|
||||
/// The stages that may bind images and storage buffers.
|
||||
fn storage_stages(self) -> wgpu::ShaderStages {
|
||||
let mut stages = wgpu::ShaderStages::NONE;
|
||||
if self.compute {
|
||||
stages |= wgpu::ShaderStages::COMPUTE;
|
||||
}
|
||||
if self.fragment_storage {
|
||||
stages |= wgpu::ShaderStages::FRAGMENT;
|
||||
}
|
||||
if self.vertex_storage {
|
||||
stages |= wgpu::ShaderStages::VERTEX;
|
||||
}
|
||||
stages
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass's pipeline: a draw, or a compute dispatch.
|
||||
#[derive(Clone)]
|
||||
enum Pipeline {
|
||||
@@ -259,6 +350,15 @@ struct Pass {
|
||||
copies: u16,
|
||||
/// Compute programs dispatched before this pass.
|
||||
compute: Vec<Pass>,
|
||||
/// Writes no buffer, only images: draws into `Targets::sink`.
|
||||
sink: bool,
|
||||
/// Instances to draw for a geometry stage folded into the vertex stage; 0 without one.
|
||||
geometry: u32,
|
||||
/// The pack's images (bit n: `image.*` n) this pass samples and stores: sampled from a copy
|
||||
/// taken just before it.
|
||||
image_copies: u64,
|
||||
/// The pack's images a compute pass's loads read as they were before it: copied first.
|
||||
snapshots: u64,
|
||||
}
|
||||
|
||||
impl Pass {
|
||||
@@ -292,6 +392,8 @@ struct Targets {
|
||||
scratch: [Option<wgpu::TextureView>; BUFFERS],
|
||||
/// Each buffer's size: the render size, or its `size.buffer`.
|
||||
sizes: [[u32; 2]; BUFFERS],
|
||||
/// The attachment of passes that write only images.
|
||||
sink: wgpu::TextureView,
|
||||
}
|
||||
|
||||
/// What a pack's gbuffers programs read and draw from the game's world.
|
||||
@@ -408,6 +510,13 @@ pub struct PackPost {
|
||||
dummy_1d: wgpu::TextureView,
|
||||
/// `image.*` and `bufferObject.*`.
|
||||
images: Vec<pack_images::PackImage>,
|
||||
/// Set 5 of programs with a geometry stage.
|
||||
indices: Option<wgpu::BindGroupLayout>,
|
||||
shadow_culling: horizon_shaders::ShadowCulling,
|
||||
gpu: Gpu,
|
||||
/// Zeros to clear images by copying, where the device has no `clear_texture`.
|
||||
zeros: Option<wgpu::Buffer>,
|
||||
dump: Option<pack_images::Dump>,
|
||||
buffers: pack_images::Buffers,
|
||||
textures: Vec<pack_textures::PackTexture>,
|
||||
/// The stand-in shadow map and its comparison sampler; cleared on the first frame.
|
||||
@@ -752,8 +861,14 @@ impl TerrainList {
|
||||
|
||||
/// Draws the game's terrain of one list into a pass; calls its third argument with each arena
|
||||
/// page before drawing from it.
|
||||
pub(super) type TerrainDraw<'a> =
|
||||
dyn FnMut(&mut wgpu::RenderPass<'_>, TerrainList, &dyn Fn(&mut wgpu::RenderPass<'_>, u32)) + 'a;
|
||||
/// Draws a list's terrain: indexed, or for a program with a geometry stage, without indices and
|
||||
/// with that many instances (and its index buffer bound in set 5 by this layout).
|
||||
pub(super) type TerrainDraw<'a> = dyn FnMut(
|
||||
&mut wgpu::RenderPass<'_>,
|
||||
TerrainList,
|
||||
&dyn Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
Option<(u32, &wgpu::BindGroupLayout)>,
|
||||
) + 'a;
|
||||
|
||||
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
|
||||
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
|
||||
@@ -762,6 +877,9 @@ pub(super) struct ShadowView {
|
||||
pub cull: glam::Mat4,
|
||||
pub light: glam::Vec3,
|
||||
pub distance: Option<f32>,
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: `mc_Entity`'s table (-1 pairs when empty)
|
||||
@@ -816,6 +934,11 @@ struct Resources<'a> {
|
||||
/// The images this device can't make.
|
||||
left_out: &'a [horizon_shaders::CustomImage],
|
||||
buffers: &'a pack_images::Buffers,
|
||||
/// Set 5 of a program with a geometry stage: the draw's indices.
|
||||
indices: Option<&'a wgpu::BindGroupLayout>,
|
||||
gpu: Gpu,
|
||||
/// The buffers whose format can be bound as an image.
|
||||
storable: u16,
|
||||
}
|
||||
|
||||
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
|
||||
@@ -1014,6 +1137,22 @@ fn bindings(
|
||||
let (target, actual) = match (buffer, own) {
|
||||
(_, Some(k)) => (Image::Custom(k), pack.images[k].format),
|
||||
(Some(b), None) => (Image::Color(b), formats[b]),
|
||||
(None, None) if i.before.is_some() => {
|
||||
// Bound as the image it copies.
|
||||
let of = i.before.as_deref().unwrap_or_default();
|
||||
let buffer = of
|
||||
.strip_prefix("colorimg")
|
||||
.and_then(|n| n.parse::<usize>().ok())
|
||||
.filter(|&n| n < BUFFERS && !group.shadow());
|
||||
match (buffer, pack.images.iter().position(|c| c.def.name == of)) {
|
||||
(_, Some(k)) => (Image::CustomBefore(k), pack.images[k].format),
|
||||
(Some(b), None) => (Image::ColorBefore(b), formats[b]),
|
||||
(None, None) => {
|
||||
notes.push(format!("{name}: image `{of}` is not provided yet"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
notes.push(format!("{name}: image `{}` is not provided yet", i.name));
|
||||
return None;
|
||||
@@ -1041,9 +1180,18 @@ fn bindings(
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
if let Image::Color(b) | Image::ColorBefore(b) = target
|
||||
&& pack.storable & 1 << b == 0
|
||||
{
|
||||
notes.push(format!(
|
||||
"{name}: `{}` can't be an image on this GPU; skipped",
|
||||
i.name
|
||||
));
|
||||
return None;
|
||||
}
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: i.binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT | wgpu::ShaderStages::COMPUTE,
|
||||
visibility: pack.gpu.storage_stages(),
|
||||
ty: wgpu::BindingType::StorageTexture {
|
||||
access,
|
||||
format,
|
||||
@@ -1133,6 +1281,21 @@ fn build_pass(
|
||||
let program = p.program.take()?;
|
||||
let interface = &program.interface;
|
||||
let draw = interface.draw;
|
||||
let storage = !interface.images.is_empty() || !interface.buffers.is_empty();
|
||||
if storage && !pack.gpu.fragment_storage {
|
||||
notes.push(format!(
|
||||
"{}: this GPU can't write images from a fragment shader; skipped",
|
||||
p.name
|
||||
));
|
||||
return None;
|
||||
}
|
||||
if p.geometry_triangles > 0 && (pack.indices.is_none() || !pack.gpu.vertex_storage) {
|
||||
notes.push(format!(
|
||||
"{}: this GPU can't run a geometry stage; skipped",
|
||||
p.name
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let (entries, samplers, slots, reads, shadow_reads, images) = bindings(
|
||||
features, interface, &p.name, p.group, &p.buffers, geometry, formats, shadow, notes, pack,
|
||||
)?;
|
||||
@@ -1168,7 +1331,16 @@ fn build_pass(
|
||||
while targets.last().is_some_and(Option::is_none) {
|
||||
targets.pop();
|
||||
}
|
||||
if !final_pass && writes.is_empty() {
|
||||
// A program that only writes images draws into a sink that keeps nothing; one drawing
|
||||
// geometry has the depth buffer, and draws with no colour target at all.
|
||||
let sink = !final_pass && writes.is_empty() && !program.interface.images.is_empty();
|
||||
if sink && geometry.is_none() {
|
||||
targets = vec![Some(wgpu::ColorTargetState {
|
||||
format: SINK,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})];
|
||||
} else if !final_pass && writes.is_empty() && !sink {
|
||||
notes.push(format!("{}: writes no buffer; skipped", p.name));
|
||||
return None;
|
||||
}
|
||||
@@ -1197,6 +1369,7 @@ fn build_pass(
|
||||
draw.then_some(draws),
|
||||
geometry.and_then(|g| g.ids),
|
||||
(!interface.buffers.is_empty()).then_some(&pack.buffers.layout),
|
||||
pack.indices.filter(|_| p.geometry_triangles > 0),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
@@ -1215,12 +1388,17 @@ fn build_pass(
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: geometry.map_or(&[], |g| g.buffers),
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or(&[], |g| g.buffers),
|
||||
},
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry.filter(|g| g.cull).map(|_| wgpu::Face::Back),
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
@@ -1259,6 +1437,7 @@ fn build_pass(
|
||||
if !missing.is_empty() {
|
||||
notes.push(format!("{}: zero for now: {}", p.name, missing.join(" ")));
|
||||
}
|
||||
let image_copies = image_copies(&samplers, &images);
|
||||
Some(Pass {
|
||||
ubo: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(&p.name),
|
||||
@@ -1291,9 +1470,24 @@ fn build_pass(
|
||||
buffers: !interface.buffers.is_empty(),
|
||||
images,
|
||||
compute: Vec::new(),
|
||||
sink,
|
||||
geometry: p.geometry_triangles,
|
||||
image_copies,
|
||||
snapshots: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The pack's images a pass both samples and stores.
|
||||
fn image_copies(samplers: &[Binding], images: &[(u32, Image)]) -> u64 {
|
||||
samplers
|
||||
.iter()
|
||||
.filter_map(|s| match s.source {
|
||||
Source::Image(k) if images.iter().any(|i| i.1 == Image::Custom(k)) => Some(k),
|
||||
_ => None,
|
||||
})
|
||||
.fold(0, |m, k| m | 1 << k)
|
||||
}
|
||||
|
||||
/// The pass of compute program `c` of a `group` program: `None` (with a note) when wgpu refuses
|
||||
/// it or it needs what the game can't give.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1368,6 +1562,8 @@ fn build_compute(
|
||||
}
|
||||
};
|
||||
let written = image_buffers(&images);
|
||||
let image_copies = image_copies(&samplers, &images);
|
||||
let (before_colors, snapshots) = befores(&images);
|
||||
Some(Pass {
|
||||
ubo: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(&name),
|
||||
@@ -1393,7 +1589,7 @@ fn build_compute(
|
||||
samplers,
|
||||
writes: Vec::new(),
|
||||
flip: 0,
|
||||
copies: written & reads,
|
||||
copies: written & reads | before_colors,
|
||||
reads: reads | written,
|
||||
shadow_reads,
|
||||
written,
|
||||
@@ -1405,6 +1601,10 @@ fn build_compute(
|
||||
buffers: !interface.buffers.is_empty(),
|
||||
images,
|
||||
compute: Vec::new(),
|
||||
sink: false,
|
||||
geometry: 0,
|
||||
image_copies,
|
||||
snapshots,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1413,6 +1613,7 @@ impl PackPost {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
mut post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
@@ -1463,29 +1664,78 @@ impl PackPost {
|
||||
.filter(|_| i < 2)
|
||||
.map_or(formats[i], |f| f[i])
|
||||
});
|
||||
let (mut storage, mut copied) = (0u16, 0u16);
|
||||
// An image this device can't make: the programs using it are skipped.
|
||||
let (mut storage, mut copied, mut image_copied, mut snapshotted) = (0u16, 0u16, 0u64, 0u64);
|
||||
let gpu = Gpu::of(adapter, features);
|
||||
if !gpu.compute {
|
||||
notes.push("this GPU has no compute shaders: compute programs are skipped".into());
|
||||
}
|
||||
// An image this device can't make or store into: the programs using it are skipped.
|
||||
let mut image_defs = std::mem::take(&mut post.images);
|
||||
let mut left_out = Vec::new();
|
||||
image_defs.retain(|def| match pack_images::makeable(device, def) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
image_defs.retain(|def| {
|
||||
let made = pack_images::makeable(device, def).and_then(|()| {
|
||||
if gpu.storable(adapter, pack_images::format(def.format), features) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("this GPU can't store into its format".into())
|
||||
}
|
||||
});
|
||||
if let Err(e) = &made {
|
||||
notes.push(format!("image `{}`: {e}", def.name));
|
||||
left_out.push(def.clone());
|
||||
false
|
||||
}
|
||||
made.is_ok()
|
||||
});
|
||||
// A mask of a pass's images holds this many.
|
||||
let kept = image_defs.len().min(pack_images::IMAGES);
|
||||
for def in image_defs.drain(kept..) {
|
||||
notes.push(format!(
|
||||
"image `{}`: past the {} images a pack may have",
|
||||
def.name,
|
||||
pack_images::IMAGES
|
||||
));
|
||||
left_out.push(def);
|
||||
}
|
||||
let images = pack_images::images(image_defs);
|
||||
let buffers = pack_images::Buffers::new(device, std::mem::take(&mut post.buffer_objects));
|
||||
let mut objects = std::mem::take(&mut post.buffer_objects);
|
||||
if gpu.storage_stages().is_empty() {
|
||||
objects.clear();
|
||||
}
|
||||
let buffers = pack_images::Buffers::new(device, objects, gpu.storage_stages());
|
||||
let storable = (0..BUFFERS)
|
||||
.filter(|&i| gpu.storable(adapter, formats[i], features))
|
||||
.fold(0u16, |m, i| m | 1 << i);
|
||||
let indices = gpu.vertex_storage.then(|| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack geometry indices"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
})
|
||||
});
|
||||
let pack = Resources {
|
||||
textures: &textures,
|
||||
images: &images,
|
||||
left_out: &left_out,
|
||||
buffers: &buffers,
|
||||
indices: indices.as_ref(),
|
||||
gpu,
|
||||
storable,
|
||||
};
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
let in_shadow = p.group.shadow();
|
||||
let compute = std::mem::take(&mut p.compute);
|
||||
let mut compute = std::mem::take(&mut p.compute);
|
||||
if !gpu.compute {
|
||||
compute.clear();
|
||||
p.program.as_ref()?;
|
||||
}
|
||||
let group = p.group;
|
||||
let mut computes: Vec<Pass> = compute
|
||||
.into_iter()
|
||||
@@ -1547,6 +1797,8 @@ impl PackPost {
|
||||
used |= p.reads | p.written | p.flip;
|
||||
}
|
||||
storage |= image_buffers(&p.images);
|
||||
image_copied |= p.image_copies;
|
||||
snapshotted |= p.snapshots;
|
||||
copied |= p.copies;
|
||||
}
|
||||
Some(pass)
|
||||
@@ -1986,8 +2238,20 @@ impl PackPost {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| noise(device, queue)),
|
||||
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
|
||||
images,
|
||||
images: {
|
||||
let mut images = images;
|
||||
for (k, image) in images.iter_mut().enumerate() {
|
||||
image.copied = image_copied & 1 << k != 0;
|
||||
image.snapshotted = snapshotted & 1 << k != 0;
|
||||
}
|
||||
images
|
||||
},
|
||||
buffers,
|
||||
indices,
|
||||
shadow_culling: post.shadow_culling,
|
||||
gpu,
|
||||
zeros: None,
|
||||
dump: pack_images::Dump::new(),
|
||||
dummy_3d: device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack 3D stand-in"),
|
||||
@@ -2080,6 +2344,7 @@ impl PackPost {
|
||||
pass: &mut Pass,
|
||||
out: [u32; 2],
|
||||
) -> wgpu::BindGroup {
|
||||
pack_images::copy(encoder, &self.images, pass.image_copies);
|
||||
if let Some(targets) = &self.targets {
|
||||
for i in (0..BUFFERS).filter(|i| pass.copies & 1 << i != 0) {
|
||||
if let (Some(t), Some(copy)) = (&targets.color[i], &targets.scratch[i]) {
|
||||
@@ -2185,6 +2450,9 @@ impl PackPost {
|
||||
&self.dummy_1d
|
||||
}
|
||||
Source::Dummy => &self.dummy,
|
||||
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
|
||||
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
|
||||
}
|
||||
Source::Image(k) => self.images[k].view.as_ref().unwrap_or(&self.dummy),
|
||||
Source::Custom { index, .. } => match &self.textures[index].image {
|
||||
pack_textures::Image::View(v) => v,
|
||||
@@ -2224,6 +2492,8 @@ impl PackPost {
|
||||
targets.color[i].as_ref().map_or(&self.dummy, |t| &t[side])
|
||||
}
|
||||
Image::Custom(k) => self.images[k].view.as_ref().unwrap_or(&self.dummy),
|
||||
Image::ColorBefore(i) => targets.scratch[i].as_ref().unwrap_or(&self.dummy),
|
||||
Image::CustomBefore(k) => self.images[k].before.as_ref().unwrap_or(&self.dummy),
|
||||
};
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding,
|
||||
@@ -2262,6 +2532,9 @@ impl PackPost {
|
||||
[n(0), n(1), 1]
|
||||
});
|
||||
let (pipeline, groups) = (pipeline.clone(), groups);
|
||||
for image in (0..self.images.len()).filter(|k| pass.snapshots & 1 << k != 0) {
|
||||
pack_images::snapshot(encoder, &self.images[image]);
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some(&pass.name),
|
||||
@@ -2381,7 +2654,12 @@ impl PackPost {
|
||||
rp.set_bind_group(3, group, &[]);
|
||||
}
|
||||
};
|
||||
draw(&mut rp, list, &page);
|
||||
let geometry = self
|
||||
.indices
|
||||
.as_ref()
|
||||
.filter(|_| pass.geometry > 0)
|
||||
.map(|indices| (pass.geometry, indices));
|
||||
draw(&mut rp, list, &page, geometry);
|
||||
}
|
||||
if !pass.group.shadow() {
|
||||
self.mips_valid &= !pass.written;
|
||||
@@ -2822,6 +3100,11 @@ impl PackPost {
|
||||
cull: pack_uniforms::shadow_culling(frame, shadow),
|
||||
light: pack_uniforms::shadow_light(frame, shadow),
|
||||
distance: (shadow.render_mul > 0.0).then_some(shadow.distance * shadow.render_mul),
|
||||
unculled: match self.shadow_culling {
|
||||
horizon_shaders::ShadowCulling::On => None,
|
||||
horizon_shaders::ShadowCulling::Off => Some(f32::INFINITY),
|
||||
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2933,6 +3216,15 @@ impl PackPost {
|
||||
let mut attachment = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
if self.storage & 1 << i != 0 {
|
||||
attachment |= wgpu::TextureUsages::STORAGE_BINDING;
|
||||
// Atomics on a texture need it made for them (Metal ignores them otherwise).
|
||||
if device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
|
||||
&& matches!(
|
||||
self.formats[i],
|
||||
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
|
||||
)
|
||||
{
|
||||
attachment |= wgpu::TextureUsages::STORAGE_ATOMIC;
|
||||
}
|
||||
}
|
||||
if self.copied & 1 << i != 0 {
|
||||
attachment |= wgpu::TextureUsages::COPY_SRC;
|
||||
@@ -2986,18 +3278,31 @@ impl PackPost {
|
||||
});
|
||||
let scratch = std::array::from_fn(|i| {
|
||||
(self.copied & 1 << i != 0).then(|| {
|
||||
// A copy also read as an image before a dispatch.
|
||||
let image = if self.storage & 1 << i != 0 {
|
||||
wgpu::TextureUsages::STORAGE_BINDING
|
||||
} else {
|
||||
wgpu::TextureUsages::empty()
|
||||
};
|
||||
texture_sized(
|
||||
&format!("colortex{i} copy"),
|
||||
self.formats[i],
|
||||
wgpu::TextureUsages::COPY_DST,
|
||||
wgpu::TextureUsages::COPY_DST | image,
|
||||
1,
|
||||
sizes[i],
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
})
|
||||
});
|
||||
let sink = texture(
|
||||
"Shader pack sink",
|
||||
SINK,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
sink,
|
||||
sizes,
|
||||
scratch,
|
||||
color,
|
||||
@@ -3147,9 +3452,18 @@ impl PackPost {
|
||||
if self.targets.is_none() {
|
||||
return;
|
||||
}
|
||||
// Images the shadow and shadowcomp programs write start the frame cleared, as every
|
||||
// other program sees them.
|
||||
pack_images::clear(encoder, &self.images);
|
||||
// Images start the frame cleared, before the shadow pass writes them.
|
||||
if let Some(dump) = &mut self.dump {
|
||||
dump.read(device);
|
||||
dump.copy(device, encoder, &self.images);
|
||||
}
|
||||
pack_images::clear(
|
||||
device,
|
||||
encoder,
|
||||
&self.images,
|
||||
self.gpu.clear,
|
||||
&mut self.zeros,
|
||||
);
|
||||
self.run_shadow(device, queue, encoder, terrain);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
@@ -3329,6 +3643,8 @@ impl PackPost {
|
||||
if final_pass {
|
||||
attachments.push(attach(screen, overwrite));
|
||||
shown = true;
|
||||
} else if pass.sink {
|
||||
attachments.push(attach(&targets.sink, overwrite));
|
||||
} else {
|
||||
for &(location, buffer) in &pass.writes {
|
||||
let l = location as usize;
|
||||
|
||||
@@ -162,6 +162,15 @@ pub const NAMES: &[&str] = &[
|
||||
"shadowModelViewInverse",
|
||||
"shadowProjection",
|
||||
"shadowProjectionInverse",
|
||||
"cameraPositionInt",
|
||||
"cameraPositionFract",
|
||||
"previousCameraPositionInt",
|
||||
"previousCameraPositionFract",
|
||||
"eyePosition",
|
||||
"relativeEyePosition",
|
||||
"playerLookVector",
|
||||
"playerBodyVector",
|
||||
"firstPersonCamera",
|
||||
];
|
||||
|
||||
/// Values carried from frame to frame.
|
||||
@@ -172,7 +181,7 @@ pub struct FrameUniforms {
|
||||
start: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frame: u32,
|
||||
previous: Option<(Vec3, Mat4, Mat4)>,
|
||||
previous: Option<(glam::DVec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
/// The pack's shadow settings (7.1).
|
||||
@@ -294,10 +303,23 @@ impl FrameUniforms {
|
||||
frame.depth_far,
|
||||
);
|
||||
let camera = frame.camera.as_vec3();
|
||||
let (previous_camera, previous_view, previous_projection) =
|
||||
let (previous_exact, previous_view, previous_projection) =
|
||||
self.previous
|
||||
.unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO));
|
||||
self.previous = Some((camera, view, projection));
|
||||
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
|
||||
let previous_camera = previous_exact.as_vec3();
|
||||
self.previous = Some((frame.camera, view, projection));
|
||||
// The camera's whole blocks and the rest, exact however far from the origin.
|
||||
let whole = |p: glam::DVec3| p.floor();
|
||||
let int = |p: glam::DVec3| Value::i(&whole(p).as_ivec3().to_array());
|
||||
let fract = |p: glam::DVec3| Value::f(&(p - whole(p)).as_vec3().to_array());
|
||||
// The player's, not the camera's: in a third-person view the camera is behind (or in
|
||||
// front of, turned around) the eye the pack lights from.
|
||||
let [yaw, pitch] = frame.eye_rotation.map(f32::to_radians);
|
||||
let look = Vec3::new(
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
);
|
||||
|
||||
let to_view = |v: Vec3| view.transform_vector3(v);
|
||||
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
|
||||
@@ -342,6 +364,15 @@ impl FrameUniforms {
|
||||
match name {
|
||||
"cameraPosition" => v3(camera),
|
||||
"previousCameraPosition" => v3(previous_camera),
|
||||
"cameraPositionInt" => int(frame.camera),
|
||||
"cameraPositionFract" => fract(frame.camera),
|
||||
"previousCameraPositionInt" => int(previous_exact),
|
||||
"previousCameraPositionFract" => fract(previous_exact),
|
||||
// The camera is the eye in first person.
|
||||
"eyePosition" => v3(frame.eye.as_vec3()),
|
||||
"relativeEyePosition" => v3((frame.camera - frame.eye).as_vec3()),
|
||||
"playerLookVector" | "playerBodyVector" => v3(look),
|
||||
"firstPersonCamera" => Value::i(&[i32::from(frame.first_person)]),
|
||||
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
|
||||
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
|
||||
"sunPosition" => v3(sun),
|
||||
@@ -453,6 +484,14 @@ fn components(name: &str) -> usize {
|
||||
match name {
|
||||
"cameraPosition"
|
||||
| "previousCameraPosition"
|
||||
| "cameraPositionInt"
|
||||
| "cameraPositionFract"
|
||||
| "previousCameraPositionInt"
|
||||
| "previousCameraPositionFract"
|
||||
| "eyePosition"
|
||||
| "relativeEyePosition"
|
||||
| "playerLookVector"
|
||||
| "playerBodyVector"
|
||||
| "upPosition"
|
||||
| "sunPosition"
|
||||
| "moonPosition"
|
||||
|
||||
@@ -147,6 +147,34 @@ impl Style {
|
||||
dragging && active,
|
||||
);
|
||||
}
|
||||
pub fn slider_ext(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
(x, y, w): (f32, f32, f32),
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
focused: bool,
|
||||
can_change_value: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
ui::slider_ext(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
label,
|
||||
value,
|
||||
hover && active,
|
||||
focused,
|
||||
can_change_value,
|
||||
active,
|
||||
dragging && active,
|
||||
);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
&self,
|
||||
|
||||
@@ -474,6 +474,19 @@ impl H {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn slider_steps(self) -> Option<u32> {
|
||||
match self {
|
||||
H::ChunkUpdates => Some(64),
|
||||
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
|
||||
H::Workers => Some(max_workers()),
|
||||
H::ResolutionScale => Some(30),
|
||||
H::HdrPaperWhite => Some(41),
|
||||
H::HdrPeak => Some(37),
|
||||
H::HdrBlackLevel => Some(100),
|
||||
H::HdrUiBrightness => Some(15),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn label(self, s: &Settings) -> Text {
|
||||
let g = &s.horizon;
|
||||
match self {
|
||||
@@ -777,6 +790,39 @@ impl Item {
|
||||
s.horizon.preset = Preset::Custom;
|
||||
}
|
||||
}
|
||||
pub fn slider_steps(self) -> Option<u32> {
|
||||
match self {
|
||||
Item::Vanilla(o) => o.slider_steps(),
|
||||
Item::Horizon(h) => h.slider_steps(),
|
||||
}
|
||||
}
|
||||
pub fn step_slider(self, s: &mut Settings, forward: bool, width: f32) {
|
||||
if !self.available() || self.blocked(s).is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(pos) = self.slider(s) else {
|
||||
return;
|
||||
};
|
||||
let next_pos = if let Some(steps) = self.slider_steps() {
|
||||
let steps = steps as f32;
|
||||
let current = (pos * steps).round();
|
||||
let next = if forward {
|
||||
current + 1.0
|
||||
} else {
|
||||
current - 1.0
|
||||
};
|
||||
(next / steps).clamp(0.0, 1.0)
|
||||
} else {
|
||||
let delta = 1.0 / (width - 8.0).max(1.0);
|
||||
if forward {
|
||||
(pos + delta).min(1.0)
|
||||
} else {
|
||||
(pos - delta).max(0.0)
|
||||
}
|
||||
};
|
||||
self.set_slider(s, next_pos);
|
||||
self.touched(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the options screens must do after an interaction here.
|
||||
@@ -795,8 +841,8 @@ pub enum Action {
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Target {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
Item(Item),
|
||||
Button(Action),
|
||||
}
|
||||
@@ -814,6 +860,8 @@ pub struct GraphicsUi {
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
pub focus: Option<Target>,
|
||||
pub can_change_value: bool,
|
||||
/// The key binding the options screen is waiting on (`Action::BindKey`).
|
||||
pub binding: Option<&'static str>,
|
||||
}
|
||||
@@ -822,6 +870,8 @@ impl GraphicsUi {
|
||||
pub fn reset(&mut self) {
|
||||
self.scroll = ui::Scroll::default();
|
||||
self.dragging = None;
|
||||
self.focus = None;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
/// The scrollbar's column: right of the descriptions.
|
||||
fn scrollbar_x(w: f32) -> f32 {
|
||||
@@ -939,8 +989,10 @@ impl GraphicsUi {
|
||||
let mut index = 0;
|
||||
let mut tooltip = None;
|
||||
for wd in &widgets {
|
||||
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
|
||||
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
|
||||
let is_focused = self.focus == Some(wd.target);
|
||||
let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
|
||||
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom)))
|
||||
|| is_focused;
|
||||
match wd.target {
|
||||
Target::Item(item) => {
|
||||
let (cost, text) = descriptions[index];
|
||||
@@ -961,13 +1013,15 @@ impl GraphicsUi {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
Some(value) => STYLE.slider_ext(
|
||||
batch,
|
||||
atlas,
|
||||
(wd.x, wd.y, wd.w),
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
is_focused,
|
||||
self.can_change_value && is_focused,
|
||||
live,
|
||||
self.dragging == Some(item),
|
||||
),
|
||||
@@ -1072,11 +1126,16 @@ impl GraphicsUi {
|
||||
if wd.list && (y < top || y >= bottom || wd.y < top || wd.y + 20.0 > bottom) {
|
||||
continue;
|
||||
}
|
||||
self.focus = Some(wd.target);
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
Target::Button(action) => {
|
||||
self.can_change_value = false;
|
||||
vec![Action::ButtonClick, action]
|
||||
}
|
||||
// A disabled control does nothing: no sound, no preset change.
|
||||
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
self.can_change_value = false;
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
|
||||
@@ -1095,10 +1154,12 @@ impl GraphicsUi {
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.can_change_value = true;
|
||||
self.dragging = Some(item);
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
vec![Action::SliderDrag]
|
||||
} else {
|
||||
self.can_change_value = false;
|
||||
item.cycle(s, right, framebuffer);
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
}
|
||||
@@ -1118,6 +1179,246 @@ impl GraphicsUi {
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
self.scroll.wheel(amount, 18.0, top, bottom, content);
|
||||
}
|
||||
|
||||
/// Mouse wheel over a control cycles it (cycle button). Sliders and disabled
|
||||
/// rows fall through to scroll the page, matching Vanilla list behavior.
|
||||
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
|
||||
/// cycle item is keyboard-focused (`self.focus`), the wheel steps/cycles it
|
||||
/// regardless of cursor position. Vanilla 26.3 does not intercept the
|
||||
/// wheel for sliders at all.
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
level: Level,
|
||||
s: &mut Settings,
|
||||
cursor: (f32, f32),
|
||||
(w, h): (f32, f32),
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Action> {
|
||||
// Horizon improvement: focused slider/cycle intercepts wheel first.
|
||||
if amount != 0.0
|
||||
&& self.can_change_value
|
||||
&& let Some(Target::Item(item)) = self.focus
|
||||
&& item.available()
|
||||
&& item.blocked(s).is_none()
|
||||
{
|
||||
if item.slider(s).is_some() {
|
||||
item.step_slider(s, amount > 0.0, w);
|
||||
return vec![Action::SliderDrag, Action::Changed];
|
||||
} else if !matches!(item, Item::Horizon(H::HdrSettings)) {
|
||||
item.cycle(s, amount > 0.0, framebuffer);
|
||||
return vec![Action::ButtonClick, Action::Changed];
|
||||
}
|
||||
}
|
||||
let (widgets, _, _) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
let hovered = widgets.iter().find(|wd| {
|
||||
ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
|
||||
&& (!wd.list
|
||||
|| (cursor.1 >= top
|
||||
&& cursor.1 < bottom
|
||||
&& wd.y >= top
|
||||
&& wd.y + 20.0 <= bottom))
|
||||
});
|
||||
if let Some(wd) = hovered
|
||||
&& let Target::Item(item) = wd.target
|
||||
&& item.available()
|
||||
&& item.blocked(s).is_none()
|
||||
&& item.slider(s).is_none()
|
||||
&& !matches!(item, Item::Horizon(H::HdrSettings))
|
||||
{
|
||||
if amount != 0.0 {
|
||||
item.cycle(s, amount > 0.0, framebuffer);
|
||||
return vec![Action::ButtonClick, Action::Changed];
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
self.scroll(level, s, h, amount);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Keyboard navigation and value adjustments.
|
||||
pub fn key_modifiers(
|
||||
&mut self,
|
||||
level: Level,
|
||||
s: &mut Settings,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
(w, h): (f32, f32),
|
||||
framebuffer: [i32; 2],
|
||||
) -> (Vec<Action>, bool) {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let (widgets, _, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
let focusables: Vec<(Target, [f32; 4], bool)> = widgets
|
||||
.iter()
|
||||
.filter_map(|wd| match wd.target {
|
||||
Target::Item(item) if item.available() && item.blocked(s).is_none() => {
|
||||
Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list))
|
||||
}
|
||||
Target::Button(_) => Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if focusables.is_empty() {
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
|
||||
// 1. Tab / Shift+Tab navigation
|
||||
if key == K::Tab {
|
||||
let cur = self
|
||||
.focus
|
||||
.and_then(|f| focusables.iter().position(|&(t, ..)| t == f));
|
||||
let next_idx = match cur {
|
||||
Some(i) => {
|
||||
if shift {
|
||||
if i == 0 { focusables.len() - 1 } else { i - 1 }
|
||||
} else {
|
||||
(i + 1) % focusables.len()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if shift {
|
||||
focusables.len() - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
};
|
||||
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
|
||||
self.focus = Some(target);
|
||||
let is_slider = matches!(target, Target::Item(item) if item.slider(s).is_some());
|
||||
self.can_change_value = is_slider;
|
||||
if is_list {
|
||||
if wy < top + 4.0 {
|
||||
self.scroll.amount -= top + 4.0 - wy;
|
||||
} else if wy + wh > bottom - 4.0 {
|
||||
self.scroll.amount += wy + wh - (bottom - 4.0);
|
||||
}
|
||||
self.clamp_scroll(h, content);
|
||||
}
|
||||
return (Vec::new(), true);
|
||||
}
|
||||
|
||||
// 2. Left / Right Arrow keys
|
||||
if matches!(key, K::ArrowLeft | K::ArrowRight) {
|
||||
let forward = key == K::ArrowRight;
|
||||
if let Some(Target::Item(item)) = self.focus
|
||||
&& item.slider(s).is_some()
|
||||
&& self.can_change_value
|
||||
{
|
||||
let width = widgets
|
||||
.iter()
|
||||
.find(|wd| wd.target == Target::Item(item))
|
||||
.map_or(STYLE.control_width, |wd| wd.w);
|
||||
item.step_slider(s, forward, width);
|
||||
return (vec![Action::SliderDrag], true);
|
||||
}
|
||||
if let Some(cur_target) = self.focus
|
||||
&& let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
|
||||
{
|
||||
let from = focusables[cur_pos].1;
|
||||
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
|
||||
let dir = if forward {
|
||||
crate::gamepad::Button::DPadRight
|
||||
} else {
|
||||
crate::gamepad::Button::DPadLeft
|
||||
};
|
||||
if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
|
||||
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
|
||||
self.focus = Some(target);
|
||||
self.can_change_value = false;
|
||||
if is_list {
|
||||
if wy < top + 4.0 {
|
||||
self.scroll.amount -= top + 4.0 - wy;
|
||||
} else if wy + wh > bottom - 4.0 {
|
||||
self.scroll.amount += wy + wh - (bottom - 4.0);
|
||||
}
|
||||
self.clamp_scroll(h, content);
|
||||
}
|
||||
return (Vec::new(), true);
|
||||
}
|
||||
}
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
|
||||
// 3. Up / Down Arrow keys
|
||||
if matches!(key, K::ArrowUp | K::ArrowDown) {
|
||||
let down = key == K::ArrowDown;
|
||||
if let Some(cur_target) = self.focus
|
||||
&& let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
|
||||
{
|
||||
let from = focusables[cur_pos].1;
|
||||
let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
|
||||
let dir = if down {
|
||||
crate::gamepad::Button::DPadDown
|
||||
} else {
|
||||
crate::gamepad::Button::DPadUp
|
||||
};
|
||||
if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
|
||||
let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
|
||||
self.focus = Some(target);
|
||||
self.can_change_value = false;
|
||||
if is_list {
|
||||
if wy < top + 4.0 {
|
||||
self.scroll.amount -= top + 4.0 - wy;
|
||||
} else if wy + wh > bottom - 4.0 {
|
||||
self.scroll.amount += wy + wh - (bottom - 4.0);
|
||||
}
|
||||
self.clamp_scroll(h, content);
|
||||
}
|
||||
return (Vec::new(), true);
|
||||
}
|
||||
} else if !focusables.is_empty() {
|
||||
let first = if down { 0 } else { focusables.len() - 1 };
|
||||
let (target, [_, wy, _, wh], is_list) = focusables[first];
|
||||
self.focus = Some(target);
|
||||
self.can_change_value = false;
|
||||
if is_list {
|
||||
if wy < top + 4.0 {
|
||||
self.scroll.amount -= top + 4.0 - wy;
|
||||
} else if wy + wh > bottom - 4.0 {
|
||||
self.scroll.amount += wy + wh - (bottom - 4.0);
|
||||
}
|
||||
self.clamp_scroll(h, content);
|
||||
}
|
||||
return (Vec::new(), true);
|
||||
}
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
|
||||
// 4. Space / Enter / NumpadEnter
|
||||
if matches!(key, K::Space | K::Enter | K::NumpadEnter)
|
||||
&& let Some(target) = self.focus
|
||||
{
|
||||
match target {
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.can_change_value = !self.can_change_value;
|
||||
return (Vec::new(), true);
|
||||
} else if item == Item::Horizon(H::HdrSettings) {
|
||||
return (vec![Action::ButtonClick, Action::OpenHdr], true);
|
||||
} else {
|
||||
item.cycle(s, shift, framebuffer);
|
||||
return (vec![Action::ButtonClick, Action::Changed], true);
|
||||
}
|
||||
}
|
||||
Target::Button(action) => {
|
||||
return (vec![Action::ButtonClick, action], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Escape: the screen closes on the first press, as `Screen.keyPressed` handles it
|
||||
// before the focused slider.
|
||||
if key == K::Escape {
|
||||
self.can_change_value = false;
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
|
||||
(Vec::new(), false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1514,4 +1815,159 @@ mod tests {
|
||||
}
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_slider_keyboard_navigation_and_selection_toggle_parity() {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 600.0);
|
||||
let fb = [854, 480];
|
||||
|
||||
s.horizon.chunk_updates = 10;
|
||||
s.horizon.resolution_scale = 100;
|
||||
|
||||
// 1. Tab navigates down to Chunk Updates slider (in Level::Advanced)
|
||||
let mut found_chunk_updates = false;
|
||||
for _ in 0..15 {
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
|
||||
if ui.focus == Some(Target::Item(Item::Horizon(H::ChunkUpdates))) {
|
||||
found_chunk_updates = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_chunk_updates);
|
||||
assert!(
|
||||
ui.can_change_value,
|
||||
"Tab onto slider activates can_change_value"
|
||||
);
|
||||
|
||||
// 2. Right Arrow steps chunk updates from 10 to 11
|
||||
let (actions, handled) =
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
|
||||
assert!(handled);
|
||||
assert_eq!(actions, vec![Action::SliderDrag]);
|
||||
assert_eq!(s.horizon.chunk_updates, 11);
|
||||
|
||||
// 3. Left Arrow steps back to 10
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
|
||||
assert_eq!(s.horizon.chunk_updates, 10);
|
||||
|
||||
// 4. Space toggles can_change_value to false
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::Space, false, size, fb);
|
||||
assert!(!ui.can_change_value);
|
||||
|
||||
// 5. Space toggles can_change_value back to true
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, fb);
|
||||
assert!(ui.can_change_value);
|
||||
|
||||
// 6. Escape is left to the screen, which closes, even while a slider changes values
|
||||
let (actions, handled) =
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::Escape, false, size, fb);
|
||||
assert!(!handled);
|
||||
assert!(actions.is_empty());
|
||||
assert!(!ui.can_change_value);
|
||||
|
||||
// 8. Tab to ResolutionScale (steps by 5%)
|
||||
let mut found_res = false;
|
||||
for _ in 0..20 {
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
|
||||
if ui.focus == Some(Target::Item(Item::Horizon(H::ResolutionScale))) {
|
||||
found_res = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_res);
|
||||
assert!(ui.can_change_value);
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
|
||||
assert_eq!(s.horizon.resolution_scale, 105);
|
||||
ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
|
||||
assert_eq!(s.horizon.resolution_scale, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_cycle_wheel_cycles_and_slider_falls_through_to_scroll() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 300.0);
|
||||
let fb = [854, 480];
|
||||
|
||||
// 1. Wheel on a Cycle button: Far Detail cycles mode
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let far = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(Item::Horizon(H::FarDetail))))
|
||||
.unwrap();
|
||||
let cursor = (far.x + 5.0, far.y + 5.0);
|
||||
s.horizon.far_detail = FarDetail::Full;
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
|
||||
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
|
||||
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
|
||||
s.horizon.chunk_updates = 16;
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let cu = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(Item::Horizon(H::ChunkUpdates))))
|
||||
.unwrap();
|
||||
let cursor = (cu.x + 5.0, cu.y + 5.0);
|
||||
let scroll_before = ui.scroll.amount;
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(s.horizon.chunk_updates, 16);
|
||||
assert!(ui.scroll.amount > scroll_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_step_slider_matches_set_slider_for_all_options() {
|
||||
let options = [
|
||||
H::ChunkUpdates,
|
||||
H::UploadBudget,
|
||||
H::Workers,
|
||||
H::ResolutionScale,
|
||||
H::HdrPaperWhite,
|
||||
H::HdrPeak,
|
||||
H::HdrBlackLevel,
|
||||
H::HdrUiBrightness,
|
||||
];
|
||||
for h in options {
|
||||
let item = Item::Horizon(h);
|
||||
let steps = h.slider_steps().expect("steps defined");
|
||||
for i in 0..steps {
|
||||
let pos = i as f32 / steps as f32;
|
||||
let mut s1 = Settings::default();
|
||||
let mut s2 = Settings::default();
|
||||
s1.horizon.hdr = true;
|
||||
s2.horizon.hdr = true;
|
||||
h.set_slider(&mut s1, pos);
|
||||
|
||||
item.step_slider(&mut s1, true, 200.0);
|
||||
let next_pos = (i + 1) as f32 / steps as f32;
|
||||
h.set_slider(&mut s2, next_pos);
|
||||
assert_eq!(
|
||||
item.slider(&s1),
|
||||
item.slider(&s2),
|
||||
"{h:?} step forward from {i}"
|
||||
);
|
||||
}
|
||||
for i in 1..=steps {
|
||||
let pos = i as f32 / steps as f32;
|
||||
let mut s1 = Settings::default();
|
||||
let mut s2 = Settings::default();
|
||||
s1.horizon.hdr = true;
|
||||
s2.horizon.hdr = true;
|
||||
h.set_slider(&mut s1, pos);
|
||||
|
||||
item.step_slider(&mut s1, false, 200.0);
|
||||
let prev_pos = (i - 1) as f32 / steps as f32;
|
||||
h.set_slider(&mut s2, prev_pos);
|
||||
assert_eq!(
|
||||
item.slider(&s1),
|
||||
item.slider(&s2),
|
||||
"{h:?} step backward from {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Horizon's HDR emission thresholds (gfx/hdr_stats.rs), drawn when HDR output
|
||||
// turns on: for every block sprite, the brightness its pixels must reach to
|
||||
// shine, measured against the sprite itself rather than a fixed level, so a
|
||||
// resource pack's light sources shine where they are bright in their own
|
||||
// drawing. One texel per sprite (x the layer, y the array): r for a light
|
||||
// source whose bright parts shine (a torch's flame, not its handle), g for one
|
||||
// that shines all over (a froglight), b 1 when the sprite has an opaque pixel.
|
||||
@group(0) @binding(0) var sprites: texture_2d_array<f32>;
|
||||
@group(0) @binding(1) var sprites_1: texture_2d_array<f32>;
|
||||
@group(0) @binding(2) var sprites_2: texture_2d_array<f32>;
|
||||
@group(0) @binding(3) var sprites_3: texture_2d_array<f32>;
|
||||
@group(0) @binding(4) var sprites_4: texture_2d_array<f32>;
|
||||
@group(0) @binding(5) var sprites_5: texture_2d_array<f32>;
|
||||
@group(0) @binding(6) var sprites_6: texture_2d_array<f32>;
|
||||
@group(0) @binding(7) var sprites_7: texture_2d_array<f32>;
|
||||
|
||||
struct Params {
|
||||
// Where between the sprite's darkest and brightest opaque pixel the
|
||||
// threshold lies, for bright parts and for all over (hdr.rs `Masks`).
|
||||
parts: f32,
|
||||
whole: f32,
|
||||
};
|
||||
@group(0) @binding(8) var<uniform> params: Params;
|
||||
|
||||
// hdr.wgsl's brightness: the mean of luminance and the brightest channel, in
|
||||
// linear light.
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
return 0.5 * (dot(l, vec3(0.2126, 0.7152, 0.0722)) + max(l.r, max(l.g, l.b)));
|
||||
}
|
||||
|
||||
fn texel(array: u32, at: vec2<i32>, layer: i32) -> vec4<f32> {
|
||||
switch array {
|
||||
case 0u: { return textureLoad(sprites, at, layer, 0); }
|
||||
case 1u: { return textureLoad(sprites_1, at, layer, 0); }
|
||||
case 2u: { return textureLoad(sprites_2, at, layer, 0); }
|
||||
case 3u: { return textureLoad(sprites_3, at, layer, 0); }
|
||||
case 4u: { return textureLoad(sprites_4, at, layer, 0); }
|
||||
case 5u: { return textureLoad(sprites_5, at, layer, 0); }
|
||||
case 6u: { return textureLoad(sprites_6, at, layer, 0); }
|
||||
default: { return textureLoad(sprites_7, at, layer, 0); }
|
||||
}
|
||||
}
|
||||
|
||||
@vertex fn vs_stats(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_stats(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let layer = i32(pos.x);
|
||||
let array = u32(pos.y);
|
||||
// Every array holds sprites of the same size.
|
||||
let size = vec2<i32>(textureDimensions(sprites));
|
||||
var lo = 1.0;
|
||||
var hi = 0.0;
|
||||
var opaque = false;
|
||||
for (var y = 0; y < size.y; y++) {
|
||||
for (var x = 0; x < size.x; x++) {
|
||||
let c = texel(array, vec2(x, y), layer);
|
||||
if c.a >= 0.5 {
|
||||
let b = brightness(c.rgb);
|
||||
lo = min(lo, b);
|
||||
hi = max(hi, b);
|
||||
opaque = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !opaque {
|
||||
return vec4(1.0, 1.0, 0.0, 1.0);
|
||||
}
|
||||
return vec4(mix(lo, hi, params.parts), mix(lo, hi, params.whole), 1.0, 1.0);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ pub mod mod_list;
|
||||
pub mod model_library;
|
||||
pub mod models;
|
||||
pub mod multiline_edit;
|
||||
pub mod native_emissive;
|
||||
pub mod options_file;
|
||||
pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
@@ -73,6 +74,7 @@ pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod section_mask;
|
||||
pub mod settings;
|
||||
pub mod shader_options;
|
||||
pub mod shader_pack_list;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
|
||||
+1668
-433
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
//! Exposed voxel-column faces, merged without changing their block-scale UVs.
|
||||
use super::{Column, Span, TileKey};
|
||||
use super::{Column, Span, Spans, TileKey};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex, fluid_face},
|
||||
@@ -12,10 +12,10 @@ use std::collections::BTreeMap;
|
||||
|
||||
const CELLS: usize = 16;
|
||||
|
||||
pub(super) struct EdgeColumn {
|
||||
pub(super) struct EdgeColumn<'a> {
|
||||
pub start: i32,
|
||||
pub end: i32,
|
||||
pub column: Column,
|
||||
pub column: &'a Column,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -60,11 +60,11 @@ impl Mesher {
|
||||
pub(super) fn mesh(
|
||||
&mut self,
|
||||
key: TileKey,
|
||||
columns: &[Column],
|
||||
columns: &[&Column],
|
||||
covered: &FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
context: &WorkerContext,
|
||||
neighbors: &[Vec<EdgeColumn>; 4],
|
||||
neighbors: &[Vec<EdgeColumn<'_>>; 4],
|
||||
finer: &[TileKey],
|
||||
) -> SectionMesh {
|
||||
mesh_columns_with_edges(
|
||||
@@ -296,6 +296,7 @@ fn mesh_columns_with_neighbors(
|
||||
) -> SectionMesh {
|
||||
assert!(neighbors.is_empty() || neighbors.len() == 4 * CELLS);
|
||||
let stride = key.size() / CELLS as i32;
|
||||
let columns: Vec<&Column> = columns.iter().collect();
|
||||
let edges = std::array::from_fn(|side| {
|
||||
neighbors
|
||||
.get(side * CELLS..(side + 1) * CELLS)
|
||||
@@ -305,11 +306,19 @@ fn mesh_columns_with_neighbors(
|
||||
.map(|(row, column)| EdgeColumn {
|
||||
start: row as i32 * stride,
|
||||
end: (row as i32 + 1) * stride,
|
||||
column: column.clone(),
|
||||
column,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
mesh_columns_with_edges(key, columns, covered, covered_bounds, &edges, &[], material)
|
||||
mesh_columns_with_edges(
|
||||
key,
|
||||
&columns,
|
||||
covered,
|
||||
covered_bounds,
|
||||
&edges,
|
||||
&[],
|
||||
material,
|
||||
)
|
||||
}
|
||||
/// From this tile level up a cell is 64 blocks wide or more: the strata
|
||||
/// inside the ground cannot be told apart, so a column is its surface.
|
||||
@@ -325,7 +334,7 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
|
||||
min: crate::chunk::MIN_Y as i16,
|
||||
..*span
|
||||
};
|
||||
let mut spans = Vec::with_capacity(2);
|
||||
let mut spans = Spans::with_capacity(2);
|
||||
match below.last() {
|
||||
Some(under) if fluid(top) => {
|
||||
spans.push(ground(under));
|
||||
@@ -341,33 +350,44 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
|
||||
}
|
||||
fn mesh_columns_with_edges(
|
||||
key: TileKey,
|
||||
columns: &[Column],
|
||||
columns: &[&Column],
|
||||
covered: &FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
neighbors: &[Vec<EdgeColumn>; 4],
|
||||
neighbors: &[Vec<EdgeColumn<'_>>; 4],
|
||||
finer: &[TileKey],
|
||||
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
assert_eq!(columns.len(), CELLS * CELLS);
|
||||
let heightmap_columns;
|
||||
let heightmap_neighbors;
|
||||
// The heightmap columns are built here and borrowed like the tile's own.
|
||||
let heightmap_columns: Vec<Column>;
|
||||
let heightmap_refs: Vec<&Column>;
|
||||
let heightmap_edges: [Vec<Column>; 4];
|
||||
let heightmap_neighbors: [Vec<EdgeColumn<'_>>; 4];
|
||||
let (columns, neighbors) = if key.level >= HEIGHTMAP_LEVEL {
|
||||
let [ox, _, oz] = key.origin();
|
||||
let mut fluid = |span: &Span| material(span, Direction::Up, [ox, oz]).fluid != 0;
|
||||
heightmap_columns = columns
|
||||
.iter()
|
||||
.map(|column| heightmap(column, &mut fluid))
|
||||
.collect::<Vec<_>>();
|
||||
heightmap_neighbors = neighbors.each_ref().map(|side| {
|
||||
.collect();
|
||||
heightmap_edges = neighbors.each_ref().map(|side| {
|
||||
side.iter()
|
||||
.map(|edge| EdgeColumn {
|
||||
.map(|edge| heightmap(edge.column, &mut fluid))
|
||||
.collect()
|
||||
});
|
||||
heightmap_refs = heightmap_columns.iter().collect();
|
||||
heightmap_neighbors = std::array::from_fn(|side| {
|
||||
neighbors[side]
|
||||
.iter()
|
||||
.zip(&heightmap_edges[side])
|
||||
.map(|(edge, column)| EdgeColumn {
|
||||
start: edge.start,
|
||||
end: edge.end,
|
||||
column: heightmap(&edge.column, &mut fluid),
|
||||
column,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.collect()
|
||||
});
|
||||
(&heightmap_columns[..], &heightmap_neighbors)
|
||||
(&heightmap_refs[..], &heightmap_neighbors)
|
||||
} else {
|
||||
(columns, neighbors)
|
||||
};
|
||||
@@ -417,18 +437,16 @@ fn mesh_columns_with_edges(
|
||||
[ox, oz]
|
||||
}
|
||||
};
|
||||
let mut materials = Vec::new();
|
||||
let prepared: Vec<Vec<Segment<'_>>> = filled
|
||||
.iter()
|
||||
.map(|column| &**column)
|
||||
.chain(neighbors.iter().flatten().map(|edge| &edge.column))
|
||||
.map(|column| column.as_ref())
|
||||
.chain(neighbors.iter().flatten().map(|edge| edge.column))
|
||||
.enumerate()
|
||||
.map(|(index, column)| {
|
||||
let at = centre(index);
|
||||
let materials: Vec<_> = column
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| material(s, Direction::Up, at))
|
||||
.collect();
|
||||
materials.clear();
|
||||
materials.extend(column.spans.iter().map(|s| material(s, Direction::Up, at)));
|
||||
column
|
||||
.spans
|
||||
.iter()
|
||||
@@ -562,7 +580,6 @@ fn mesh_columns_with_edges(
|
||||
x1,
|
||||
),
|
||||
] {
|
||||
let side = material(segment.span, direction, centre(z * CELLS + x));
|
||||
// Near meshes may have culled against the real neighbor.
|
||||
// Keep a finite seal where their full-detail area begins:
|
||||
// opaque only, as the near water carries on into the far
|
||||
@@ -577,13 +594,20 @@ fn mesh_columns_with_edges(
|
||||
u1,
|
||||
segment,
|
||||
&[],
|
||||
side,
|
||||
|| material(segment.span, direction, centre(z * CELLS + x)),
|
||||
own_sky,
|
||||
);
|
||||
}
|
||||
} else if !edge {
|
||||
add_side(
|
||||
&mut faces, direction, plane, u0, u1, segment, column, side,
|
||||
&mut faces,
|
||||
direction,
|
||||
plane,
|
||||
u0,
|
||||
u1,
|
||||
segment,
|
||||
column,
|
||||
|| material(segment.span, direction, centre(z * CELLS + x)),
|
||||
own_sky,
|
||||
);
|
||||
} else if (0..CELLS as i32).contains(&nx)
|
||||
@@ -597,7 +621,7 @@ fn mesh_columns_with_edges(
|
||||
u1,
|
||||
segment,
|
||||
&prepared[nz as usize * CELLS + nx as usize],
|
||||
side,
|
||||
|| material(segment.span, direction, centre(z * CELLS + x)),
|
||||
own_sky,
|
||||
);
|
||||
} else {
|
||||
@@ -617,7 +641,7 @@ fn mesh_columns_with_edges(
|
||||
u1,
|
||||
segment,
|
||||
&[],
|
||||
side,
|
||||
|| material(segment.span, direction, centre(z * CELLS + x)),
|
||||
own_sky,
|
||||
);
|
||||
}
|
||||
@@ -635,7 +659,13 @@ fn mesh_columns_with_edges(
|
||||
end,
|
||||
segment,
|
||||
&prepared[edge_offsets[edge_side] + i],
|
||||
side,
|
||||
|| {
|
||||
material(
|
||||
segment.span,
|
||||
direction,
|
||||
centre(z * CELLS + x),
|
||||
)
|
||||
},
|
||||
own_sky,
|
||||
);
|
||||
}
|
||||
@@ -654,6 +684,7 @@ fn mesh_columns_with_edges(
|
||||
emit(&mut mesh, key, rect)
|
||||
}
|
||||
}
|
||||
mesh.finish_sprite_layers();
|
||||
mesh
|
||||
}
|
||||
|
||||
@@ -683,15 +714,17 @@ mod refinement_tests {
|
||||
biome: 0,
|
||||
light: 240,
|
||||
}]
|
||||
.into()
|
||||
};
|
||||
256
|
||||
];
|
||||
let columns: Vec<&Column> = columns.iter().collect();
|
||||
let mesh = mesh_columns_with_edges(
|
||||
parent,
|
||||
&columns,
|
||||
&FxHashSet::default(),
|
||||
None,
|
||||
&std::array::from_fn(|_| Vec::new()),
|
||||
&[Vec::new(), Vec::new(), Vec::new(), Vec::new()],
|
||||
&[fine],
|
||||
|_, _, _| Material {
|
||||
faces: [
|
||||
@@ -752,8 +785,8 @@ mod refinement_tests {
|
||||
/// block. Unknown neighbours, and cells that `whole` rejects (they meet
|
||||
/// full-detail or finer terrain), count as open; those cells keep their gaps.
|
||||
fn fill_buried_gaps<'a>(
|
||||
columns: &'a [Column],
|
||||
neighbors: &[Vec<EdgeColumn>; 4],
|
||||
columns: &'a [&'a Column],
|
||||
neighbors: &'a [Vec<EdgeColumn<'a>>; 4],
|
||||
stride: i32,
|
||||
whole: impl Fn(usize, usize) -> bool,
|
||||
mut fluid: impl FnMut(&Span) -> bool,
|
||||
@@ -779,19 +812,17 @@ fn fill_buried_gaps<'a>(
|
||||
};
|
||||
let meets =
|
||||
|(lo, hi): (i16, i16), (other_lo, other_hi): (i16, i16)| lo < other_hi && other_lo < hi;
|
||||
let tops: Vec<_> = (0..CELLS * CELLS)
|
||||
let tops: [i16; CELLS * CELLS] = std::array::from_fn(|i| {
|
||||
if whole(i % CELLS, i / CELLS) {
|
||||
top(columns[i])
|
||||
} else {
|
||||
i16::MIN
|
||||
}
|
||||
});
|
||||
let gaps: Vec<Vec<(usize, i16, i16)>> = (0..CELLS * CELLS)
|
||||
.map(|i| {
|
||||
if whole(i % CELLS, i / CELLS) {
|
||||
top(&columns[i])
|
||||
} else {
|
||||
i16::MIN
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let gaps: Vec<_> = (0..CELLS * CELLS)
|
||||
.map(|i| {
|
||||
if whole(i % CELLS, i / CELLS) {
|
||||
gaps_of(&columns[i])
|
||||
gaps_of(columns[i])
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -805,7 +836,7 @@ fn fill_buried_gaps<'a>(
|
||||
};
|
||||
let edge = |side: usize, cell: usize| {
|
||||
touching(side, cell)
|
||||
.map(|edge| top(&edge.column))
|
||||
.map(|edge| top(edge.column))
|
||||
.min()
|
||||
.unwrap_or(i16::MIN)
|
||||
};
|
||||
@@ -813,7 +844,7 @@ fn fill_buried_gaps<'a>(
|
||||
// see: it counts as open.
|
||||
let edge_gap = |side: usize, cell: usize, gap: (i16, i16)| {
|
||||
touching(side, cell).any(|edge| {
|
||||
gaps_of(&edge.column)
|
||||
gaps_of(edge.column)
|
||||
.iter()
|
||||
.any(|&(_, lo, hi)| meets((lo, hi), gap))
|
||||
})
|
||||
@@ -878,12 +909,13 @@ fn fill_buried_gaps<'a>(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, column)| {
|
||||
let column = *column;
|
||||
if open[i].iter().all(|&open| open) {
|
||||
return Cow::Borrowed(column);
|
||||
}
|
||||
let mut filled = Column {
|
||||
surface: column.surface,
|
||||
spans: Vec::with_capacity(column.spans.len()),
|
||||
spans: Spans::with_capacity(column.spans.len()),
|
||||
};
|
||||
for (k, &span) in column.spans.iter().enumerate() {
|
||||
let mut span = span;
|
||||
@@ -918,13 +950,22 @@ fn add_side(
|
||||
u1: f32,
|
||||
segment: &Segment<'_>,
|
||||
neighbor: &[Segment<'_>],
|
||||
side: Material,
|
||||
mut side: impl FnMut() -> Material,
|
||||
own_sky: u16,
|
||||
) {
|
||||
let (open, sky) = neighbor.last().map_or((f32::NEG_INFINITY, own_sky), |top| {
|
||||
(f32::from(top.span.max), top.span.light & 0xff)
|
||||
});
|
||||
let mut cached = None;
|
||||
let mut add = |v0: f32, v1: f32| {
|
||||
let side = match cached {
|
||||
Some(side) => side,
|
||||
None => {
|
||||
let side = side();
|
||||
cached = Some(side);
|
||||
side
|
||||
}
|
||||
};
|
||||
let mut lit = side;
|
||||
for paint in lit.faces.iter_mut().flatten() {
|
||||
paint.light = (paint.light & 0xff00) | (paint.light & 0xff).max(sky);
|
||||
@@ -1033,6 +1074,7 @@ fn emit(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
|
||||
/// `uv_scale` shrinks the UVs, in blocks from the face's corner, of a face
|
||||
/// too wide for them.
|
||||
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect, uv_scale: f32) {
|
||||
mesh.note_sprite_layer(key.layer);
|
||||
let Rect { u0, v0, u1, v1 } = rect;
|
||||
let p = f32::from_bits(key.plane);
|
||||
let mut points = match key.direction {
|
||||
@@ -1096,7 +1138,7 @@ mod tests {
|
||||
(0..CELLS * CELLS)
|
||||
.map(|_| Column {
|
||||
surface: None,
|
||||
spans: Vec::new(),
|
||||
spans: Spans::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1176,13 +1218,13 @@ mod tests {
|
||||
let fluid = |s: &Span| s.state.0 == 2;
|
||||
let strata = Column {
|
||||
surface: None,
|
||||
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)],
|
||||
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)].into(),
|
||||
};
|
||||
let flat = heightmap(&strata, fluid);
|
||||
assert_eq!(flat.spans, [span(-64, 64, 4)]);
|
||||
let sea = Column {
|
||||
surface: None,
|
||||
spans: vec![span(0, 50, 1), span(50, 62, 2)],
|
||||
spans: vec![span(0, 50, 1), span(50, 62, 2)].into(),
|
||||
};
|
||||
let flat = heightmap(&sea, fluid);
|
||||
assert_eq!(flat.spans, [span(-64, 50, 1), span(50, 62, 2)]);
|
||||
@@ -1197,7 +1239,8 @@ mod tests {
|
||||
span(0, top - 4, 1),
|
||||
span(top - 4, top - 1, 3),
|
||||
span(top - 1, top, 4),
|
||||
],
|
||||
]
|
||||
.into(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -1257,7 +1300,7 @@ mod tests {
|
||||
fn flat_solid_cube_merges_to_six_faces_with_block_scaled_uvs() {
|
||||
let mut columns = columns();
|
||||
for column in &mut columns {
|
||||
column.spans = vec![span(0, 16, 1)]
|
||||
column.spans = vec![span(0, 16, 1)].into()
|
||||
}
|
||||
let mesh = mesh(&columns);
|
||||
assert_eq!(mesh.solid.len(), 6 * 4);
|
||||
@@ -1292,7 +1335,7 @@ mod tests {
|
||||
#[test]
|
||||
fn gaps_and_real_bottoms_remain_open_without_floor_curtains() {
|
||||
let mut columns = columns();
|
||||
columns[0].spans = vec![span(3, 5, 1), span(8, 9, 1)];
|
||||
columns[0].spans = vec![span(3, 5, 1), span(8, 9, 1)].into();
|
||||
let mesh = mesh(&columns);
|
||||
assert_eq!(mesh.solid.len(), 12 * 4);
|
||||
assert!(
|
||||
@@ -1311,8 +1354,8 @@ mod tests {
|
||||
#[test]
|
||||
fn neighbor_hides_only_its_occupied_side_intervals() {
|
||||
let mut columns = columns();
|
||||
columns[0].spans = vec![span(0, 10, 1)];
|
||||
columns[1].spans = vec![span(0, 3, 1), span(7, 10, 1)];
|
||||
columns[0].spans = vec![span(0, 10, 1)].into();
|
||||
columns[1].spans = vec![span(0, 3, 1), span(7, 10, 1)].into();
|
||||
let mesh = mesh(&columns);
|
||||
let opening: Vec<_> = mesh
|
||||
.solid
|
||||
@@ -1346,9 +1389,9 @@ mod tests {
|
||||
};
|
||||
// A reduced column whose lower blocks were buried (no light), next to
|
||||
// a lower column, and to one with a cave below its top.
|
||||
columns[0].spans = vec![dark(0, 9), span(9, 10, 1)];
|
||||
columns[1].spans = vec![span(0, 4, 1)];
|
||||
columns[CELLS].spans = vec![span(0, 2, 1), span(6, 12, 1)];
|
||||
columns[0].spans = vec![dark(0, 9), span(9, 10, 1)].into();
|
||||
columns[1].spans = vec![span(0, 4, 1)].into();
|
||||
columns[CELLS].spans = vec![span(0, 2, 1), span(6, 12, 1)].into();
|
||||
let mesh = mesh(&columns);
|
||||
let quads = mesh.solid.as_chunks::<4>().0;
|
||||
let bounds = |q: &[TerrainVertex; 4]| {
|
||||
@@ -1379,19 +1422,21 @@ mod tests {
|
||||
fn air_under_a_canopy_stays_open_through_a_clearing() {
|
||||
let column = |spans: Vec<Span>| Column {
|
||||
surface: None,
|
||||
spans,
|
||||
spans: spans.into(),
|
||||
};
|
||||
// The tile's neighbours are solid hills: no open air from the sides.
|
||||
let hill_column = column(vec![span(0, 20, 1)]);
|
||||
let hill = || EdgeColumn {
|
||||
start: 0,
|
||||
end: CELLS as i32,
|
||||
column: column(vec![span(0, 20, 1)]),
|
||||
column: &hill_column,
|
||||
};
|
||||
let neighbors = [vec![hill()], vec![hill()], vec![hill()], vec![hill()]];
|
||||
let fill = |columns: &[Column]| -> Vec<Vec<Span>> {
|
||||
fill_buried_gaps(columns, &neighbors, 1, |_, _| true, |_| false)
|
||||
let columns: Vec<&Column> = columns.iter().collect();
|
||||
fill_buried_gaps(&columns, &neighbors, 1, |_, _| true, |_| false)
|
||||
.iter()
|
||||
.map(|filled| filled.spans.clone())
|
||||
.map(|filled| filled.spans.as_slice().to_vec())
|
||||
.collect()
|
||||
};
|
||||
let mut forest = vec![column(vec![span(0, 5, 1), span(10, 14, 3)]); CELLS * CELLS];
|
||||
@@ -1402,23 +1447,27 @@ mod tests {
|
||||
forest[5 * CELLS + 5] = column(vec![span(0, 5, 1)]);
|
||||
let filled = fill(&forest);
|
||||
for (filled, column) in filled.iter().zip(&forest) {
|
||||
assert_eq!(filled, &column.spans);
|
||||
assert_eq!(filled.as_slice(), column.spans.as_slice());
|
||||
}
|
||||
// A cave two columns wide sealed in rock is still filled.
|
||||
let mut rock = vec![column(vec![span(0, 20, 1)]); CELLS * CELLS];
|
||||
for cave in [5 * CELLS + 5, 5 * CELLS + 6] {
|
||||
rock[cave] = column(vec![span(0, 8, 1), span(12, 20, 1)]);
|
||||
}
|
||||
assert!(fill(&rock).iter().all(|spans| spans == &[span(0, 20, 1)]));
|
||||
assert!(
|
||||
fill(&rock)
|
||||
.iter()
|
||||
.all(|spans| spans.as_slice() == [span(0, 20, 1)])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn caves_no_neighbor_sees_into_add_no_faces() {
|
||||
let mut columns = columns();
|
||||
for column in &mut columns {
|
||||
column.spans = vec![span(0, 20, 1)];
|
||||
column.spans = vec![span(0, 20, 1)].into();
|
||||
}
|
||||
let cave = 5 * CELLS + 5;
|
||||
columns[cave].spans = vec![span(0, 8, 1), span(12, 20, 1)];
|
||||
columns[cave].spans = vec![span(0, 8, 1), span(12, 20, 1)].into();
|
||||
let at = |mesh: &SectionMesh, y: f32| {
|
||||
mesh.solid
|
||||
.as_chunks::<4>()
|
||||
@@ -1437,14 +1486,14 @@ mod tests {
|
||||
.all(|v| v.pos[1] == 0.0 || v.pos[1] == 20.0)
|
||||
);
|
||||
// A lower neighbour's open air looks in: the cave stays.
|
||||
columns[cave + 1].spans = vec![span(0, 10, 1)];
|
||||
columns[cave + 1].spans = vec![span(0, 10, 1)].into();
|
||||
let open = mesh(&columns);
|
||||
assert_eq!((at(&open, 8.0), at(&open, 12.0)), (1, 1));
|
||||
// Kelp leaves a gap in water: water fills it, without inner faces.
|
||||
for column in &mut columns {
|
||||
column.spans = vec![span(0, 5, 1), span(5, 10, 2)];
|
||||
column.spans = vec![span(0, 5, 1), span(5, 10, 2)].into();
|
||||
}
|
||||
columns[cave].spans = vec![span(0, 5, 1), span(5, 7, 2), span(8, 10, 2)];
|
||||
columns[cave].spans = vec![span(0, 5, 1), span(5, 7, 2), span(8, 10, 2)].into();
|
||||
let water = mesh(&columns);
|
||||
assert!(
|
||||
water
|
||||
@@ -1458,12 +1507,13 @@ mod tests {
|
||||
#[test]
|
||||
fn greedy_faces_preserve_material_and_light_boundaries() {
|
||||
let mut columns = columns();
|
||||
columns[0].spans = vec![span(0, 1, 1)];
|
||||
columns[1].spans = vec![span(0, 1, 3)];
|
||||
columns[0].spans = vec![span(0, 1, 1)].into();
|
||||
columns[1].spans = vec![span(0, 1, 3)].into();
|
||||
columns[2].spans = vec![Span {
|
||||
light: 16,
|
||||
..span(0, 1, 3)
|
||||
}];
|
||||
}]
|
||||
.into();
|
||||
let mesh = mesh(&columns);
|
||||
let tops: Vec<_> = mesh
|
||||
.solid
|
||||
@@ -1482,7 +1532,7 @@ mod tests {
|
||||
#[test]
|
||||
fn water_keeps_ground_surface_and_culls_shared_fluid_faces() {
|
||||
let mut columns = columns();
|
||||
columns[0].spans = vec![span(0, 3, 1), span(3, 6, 2)];
|
||||
columns[0].spans = vec![span(0, 3, 1), span(3, 6, 2)].into();
|
||||
columns[1].spans = columns[0].spans.clone();
|
||||
let mesh = mesh(&columns);
|
||||
assert!(
|
||||
@@ -1509,42 +1559,44 @@ mod tests {
|
||||
fn coarse_edge_clips_separate_fine_neighbor_heights() {
|
||||
let mut columns = columns();
|
||||
for column in &mut columns {
|
||||
column.spans = vec![span(0, 16, 1)];
|
||||
column.spans = vec![span(0, 16, 1)].into();
|
||||
}
|
||||
let key = TileKey {
|
||||
x: -1,
|
||||
z: -1,
|
||||
level: 1,
|
||||
};
|
||||
let mut neighbors: [Vec<EdgeColumn>; 4] = std::array::from_fn(|_| {
|
||||
let mut neighbors: [Vec<EdgeColumn<'_>>; 4] = std::array::from_fn(|_| {
|
||||
vec![EdgeColumn {
|
||||
start: 0,
|
||||
end: 32,
|
||||
column: columns[0].clone(),
|
||||
column: &columns[0],
|
||||
}]
|
||||
});
|
||||
// One fine column is lower than both the coarse cell and its sibling.
|
||||
// Sampling only the first neighbor would incorrectly hide this wall.
|
||||
let low = Column {
|
||||
surface: None,
|
||||
spans: vec![span(0, 8, 1)].into(),
|
||||
};
|
||||
neighbors[0] = vec![
|
||||
EdgeColumn {
|
||||
start: 0,
|
||||
end: 1,
|
||||
column: columns[0].clone(),
|
||||
column: &columns[0],
|
||||
},
|
||||
EdgeColumn {
|
||||
start: 1,
|
||||
end: 2,
|
||||
column: Column {
|
||||
surface: None,
|
||||
spans: vec![span(0, 8, 1)],
|
||||
},
|
||||
column: &low,
|
||||
},
|
||||
EdgeColumn {
|
||||
start: 2,
|
||||
end: 32,
|
||||
column: columns[0].clone(),
|
||||
column: &columns[0],
|
||||
},
|
||||
];
|
||||
let columns: Vec<&Column> = columns.iter().collect();
|
||||
let mesh = mesh_columns_with_edges(
|
||||
key,
|
||||
&columns,
|
||||
@@ -1572,7 +1624,7 @@ mod tests {
|
||||
fn tile_halo_culls_shared_walls_but_keeps_steps_and_gaps() {
|
||||
let mut columns = columns();
|
||||
for column in &mut columns {
|
||||
column.spans = vec![span(0, 16, 1)];
|
||||
column.spans = vec![span(0, 16, 1)].into();
|
||||
}
|
||||
let mut neighbors = columns[..4 * CELLS].to_vec();
|
||||
let key = TileKey {
|
||||
@@ -1596,10 +1648,10 @@ mod tests {
|
||||
);
|
||||
|
||||
for column in &mut neighbors[CELLS..2 * CELLS] {
|
||||
column.spans = vec![span(0, 8, 1)];
|
||||
column.spans = vec![span(0, 8, 1)].into();
|
||||
}
|
||||
for column in &mut neighbors[..CELLS] {
|
||||
column.spans = vec![span(0, 3, 1), span(7, 16, 1)];
|
||||
column.spans = vec![span(0, 3, 1), span(7, 16, 1)].into();
|
||||
}
|
||||
let mesh = mesh_columns_with_neighbors(key, &columns, &covered, None, &neighbors, material);
|
||||
assert_eq!(mesh.solid.len(), 4 * 4);
|
||||
@@ -1626,7 +1678,7 @@ mod tests {
|
||||
fn negative_parent_clips_chunks_without_internal_piece_walls() {
|
||||
let mut columns = columns();
|
||||
for column in &mut columns {
|
||||
column.spans = vec![span(10, 12, 1)]
|
||||
column.spans = vec![span(10, 12, 1)].into()
|
||||
}
|
||||
let key = TileKey {
|
||||
x: -1,
|
||||
|
||||
@@ -125,6 +125,8 @@ mod vertex_tests {
|
||||
assert_eq!(super::emitting(rgb, 0), rgb);
|
||||
assert_eq!(super::emitting(rgb, super::INCANDESCENT), 0xFD56_3412);
|
||||
assert_eq!(super::emitting(rgb, super::VERY_BRIGHT), 0xFB56_3412);
|
||||
assert_eq!(super::emitting(rgb, super::LOW | super::WHOLE), 0xFA56_3412);
|
||||
assert_eq!(super::emitting(rgb, super::WHOLE), rgb);
|
||||
assert_eq!(
|
||||
super::emitting(rgb, super::VERY_BRIGHT) | super::EMISSION_BITS,
|
||||
rgb
|
||||
@@ -132,14 +134,16 @@ mod vertex_tests {
|
||||
}
|
||||
#[test]
|
||||
fn light_sources_shine_by_kind_not_light_level() {
|
||||
use super::{BRIGHT, INCANDESCENT, LOW, hdr_tier};
|
||||
use super::{BRIGHT, INCANDESCENT, LOW, WHOLE, hdr_tier};
|
||||
assert_eq!(hdr_tier("glowstone", 15), BRIGHT);
|
||||
// A candle's flame is a particle: the block keeps only its wick.
|
||||
assert_eq!(hdr_tier("torch", 14), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("red_candle", 3), LOW);
|
||||
assert_eq!(hdr_tier("glow_lichen", 7), 0);
|
||||
assert_eq!(hdr_tier("stone", 0), 0);
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), LOW);
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), LOW | WHOLE);
|
||||
assert_eq!(hdr_tier("lava", 15), INCANDESCENT | WHOLE);
|
||||
assert_eq!(hdr_tier("red_candle_cake", 3), 0);
|
||||
assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
|
||||
assert_eq!(hdr_tier("sea_pickle", 15), 0);
|
||||
@@ -227,8 +231,14 @@ pub struct SectionMesh {
|
||||
/// each frame by `block_entity_render` like vanilla's per-section
|
||||
/// `SectionCompiler.Results.blockEntities`.
|
||||
pub block_entities: Vec<([i32; 3], u32)>,
|
||||
/// Sprite layers emitted while building this mesh. Kept distinct before
|
||||
/// upload so the renderer does not rescan every terrain vertex.
|
||||
pub sprite_layers: Vec<u16>,
|
||||
/// Axis-aligned bounding box [min, max] of actual terrain mesh vertices in section-local space [0, 16].
|
||||
pub bounds: Option<[glam::Vec3; 2]>,
|
||||
/// A far mesh was clipped around finer tiles or resident chunks. Keep
|
||||
/// the previous GPU mesh while its replacements fade in.
|
||||
pub keep: bool,
|
||||
}
|
||||
impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
@@ -238,11 +248,27 @@ impl Default for SectionMesh {
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
sprite_layers: Vec::new(),
|
||||
bounds: None,
|
||||
keep: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectionMesh {
|
||||
/// Record a sprite layer used by a quad. Opaque is a vertex-only flag and
|
||||
/// is removed here so the renderer can compare the actual atlas layer.
|
||||
pub fn note_sprite_layer(&mut self, layer: u16) {
|
||||
let layer = layer & !crate::mesher::OPAQUE_LAYER;
|
||||
if !self.sprite_layers.contains(&layer) {
|
||||
self.sprite_layers.push(layer);
|
||||
}
|
||||
}
|
||||
/// Finish the compact layer list after meshing has emitted all faces.
|
||||
pub fn finish_sprite_layers(&mut self) {
|
||||
self.sprite_layers.sort_unstable();
|
||||
self.sprite_layers.dedup();
|
||||
}
|
||||
|
||||
/// Padding applied to mesh vertex bounds to prevent clipping at the frustum
|
||||
/// boundary when shader packs with vertex displacement (e.g. waving foliage, water) are active.
|
||||
pub const BOUNDS_PAD: f32 = 0.5;
|
||||
@@ -252,14 +278,17 @@ impl SectionMesh {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
self.sprite_layers.clear();
|
||||
self.sides = Sides::default();
|
||||
self.visibility = ALL_VISIBLE;
|
||||
self.bounds = None;
|
||||
self.keep = false;
|
||||
}
|
||||
pub(crate) fn capacity_bytes(&self) -> usize {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
|
||||
}
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
/// leave out those facing away from the eye (`sides`).
|
||||
@@ -465,6 +494,7 @@ pub struct MeshTables {
|
||||
water_overlay: u16,
|
||||
lava_still: u16,
|
||||
lava_flow: u16,
|
||||
animated_layers: Vec<bool>,
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
@@ -499,6 +529,11 @@ impl MeshTables {
|
||||
.map(|&l| l as u16)
|
||||
.ok_or_else(|| format!("missing fluid sprite {name}").into())
|
||||
};
|
||||
let max_animated = render.animations.iter().map(|a| a.layer).max().unwrap_or(0);
|
||||
let mut animated_layers = vec![false; max_animated as usize + 1];
|
||||
for animation in &render.animations {
|
||||
animated_layers[animation.layer as usize] = true;
|
||||
}
|
||||
let mut covers = std::collections::HashMap::new();
|
||||
let mut info = Vec::with_capacity(facts.states.len());
|
||||
for (id, s) in facts.states.iter().enumerate() {
|
||||
@@ -621,8 +656,16 @@ impl MeshTables {
|
||||
water_overlay: layer("minecraft:block/water_overlay")?,
|
||||
lava_still: layer("minecraft:block/lava_still")?,
|
||||
lava_flow: layer("minecraft:block/lava_flow")?,
|
||||
animated_layers,
|
||||
})
|
||||
}
|
||||
/// Whether a terrain sprite layer has an animated clip.
|
||||
pub fn is_animated(&self, layer: u16) -> bool {
|
||||
self.animated_layers
|
||||
.get(layer as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
pub fn facts(&self, state: BlockStateId) -> Option<MeshFacts> {
|
||||
let i = self.info.get(state.0 as usize)?;
|
||||
Some(MeshFacts {
|
||||
@@ -823,9 +866,10 @@ mod reuse_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
|
||||
/// byte's high nibble carries the fluid face flags (`fluid_face`); the shaders ignore the
|
||||
/// vertex alpha, so both travel to the material stage untouched.
|
||||
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, plus 4 for one that shines all over
|
||||
/// (`WHOLE`), so the usual 0xFF means none. The byte's high nibble carries the fluid face
|
||||
/// flags (`fluid_face`); the shaders ignore the vertex alpha, so both travel to the
|
||||
/// material stage untouched.
|
||||
pub const EMISSION_BITS: u32 = 0x0F00_0000;
|
||||
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
|
||||
/// a torch's, it only lights less around it. 0 stays as in SDR.
|
||||
@@ -834,9 +878,18 @@ pub const INCANDESCENT: u8 = 2;
|
||||
pub const BRIGHT: u8 = 3;
|
||||
/// The sun and lightning.
|
||||
pub const VERY_BRIGHT: u8 = 4;
|
||||
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`).
|
||||
/// Added to a tier: the light source shines all over (a froglight), not only where its
|
||||
/// sprite is brightest (a torch's flame, not its handle). See `gfx/hdr.rs` `Masks`.
|
||||
pub const WHOLE: u8 = 8;
|
||||
/// `color` marked as shining at `tier` (0 to `VERY_BRIGHT`, plus `WHOLE`).
|
||||
pub fn emitting(color: u32, tier: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
|
||||
let level = (tier & !WHOLE).min(VERY_BRIGHT);
|
||||
let code = if level > 0 && tier & WHOLE != 0 {
|
||||
level + 4
|
||||
} else {
|
||||
level
|
||||
};
|
||||
color & !EMISSION_BITS | u32::from(15 - code) << 24
|
||||
}
|
||||
/// The high nibble of `TerrainVertex::color`'s alpha byte: 8 plus the face (`Direction`
|
||||
/// as usize) on a fluid quad, 0xF on every other. `terrain.wgsl` reads only the low nibble
|
||||
@@ -853,26 +906,36 @@ pub fn fluid_face(color: u32, face: usize) -> u32 {
|
||||
pub fn hdr_tier(name: &str, level: u8) -> u8 {
|
||||
match name {
|
||||
_ if level == 0 => 0,
|
||||
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace"
|
||||
| "redstone_lamp" => BRIGHT,
|
||||
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
|
||||
| "sea_lantern" => INCANDESCENT,
|
||||
"glowstone"
|
||||
| "soul_lantern"
|
||||
| "soul_torch"
|
||||
| "soul_wall_torch"
|
||||
| "beacon"
|
||||
| "end_rod"
|
||||
| "furnace"
|
||||
| "redstone_lamp"
|
||||
| "brewing_stand"
|
||||
| "enchanting_table"
|
||||
| "medium_amethyst_bud"
|
||||
| "small_amethyst_bud" => BRIGHT,
|
||||
"campfire" | "soul_campfire" | "soul_fire" | "lava_cauldron" | "crying_obsidian"
|
||||
| "amethyst_cluster" | "large_amethyst_bud" => BRIGHT | WHOLE,
|
||||
"torch" | "wall_torch" | "lantern" | "sea_lantern" => INCANDESCENT,
|
||||
"fire" | "lava" => INCANDESCENT | WHOLE,
|
||||
// Glowing at every charge, not only the brighter ones.
|
||||
"respawn_anchor" => INCANDESCENT,
|
||||
"redstone_torch"
|
||||
| "redstone_wall_torch"
|
||||
| "magma_block"
|
||||
| "ochre_froglight"
|
||||
| "verdant_froglight"
|
||||
| "pearlescent_froglight"
|
||||
| "shroomlight" => LOW,
|
||||
// Its flame is a particle.
|
||||
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
|
||||
"ochre_froglight" | "verdant_froglight" | "pearlescent_froglight" | "jack_o_lantern" => {
|
||||
LOW | WHOLE
|
||||
}
|
||||
"redstone_torch" | "redstone_wall_torch" | "magma_block" | "shroomlight" => LOW,
|
||||
// Its flame is a particle; the cake's frosting must not shine in daylight.
|
||||
_ if name.ends_with("candle_cake") => 0,
|
||||
_ if name.ends_with("candle") => LOW,
|
||||
// One tier at every oxidation stage, waxed or not.
|
||||
_ if name.ends_with("copper_bulb") => LOW,
|
||||
// A faint glow in the dark, as in SDR.
|
||||
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0,
|
||||
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame"
|
||||
| "ender_chest" => 0,
|
||||
_ if level >= 10 => INCANDESCENT,
|
||||
_ => LOW,
|
||||
}
|
||||
@@ -1136,6 +1199,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.finish_sprite_layers();
|
||||
out
|
||||
}
|
||||
/// `BlockColors` tint source `index`, or `None` for an untinted (-1) layer.
|
||||
@@ -1169,7 +1233,9 @@ impl Mesher<'_> {
|
||||
state: &Info,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
let tier = state.tier.max(hdr_tier("", q.light_emission));
|
||||
// A quad's own light (a model's glowing part) may raise the tier; whether it
|
||||
// shines all over stays the block's.
|
||||
let tier = (state.tier & !WHOLE).max(hdr_tier("", q.light_emission)) | state.tier & WHOLE;
|
||||
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
|
||||
let directional = if self.shader_pack_lighting {
|
||||
1.0
|
||||
@@ -1191,6 +1257,9 @@ impl Mesher<'_> {
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
if self.tables.is_animated(face.layer as u16) {
|
||||
out.note_sprite_layer(face.layer as u16);
|
||||
}
|
||||
let target = if face.translucent {
|
||||
&mut out.translucent
|
||||
} else {
|
||||
|
||||
@@ -240,7 +240,7 @@ pub(super) fn tesselate(
|
||||
let emission = if water {
|
||||
0
|
||||
} else {
|
||||
crate::mesher::INCANDESCENT
|
||||
crate::mesher::hdr_tier("lava", 15)
|
||||
};
|
||||
let color = |by: f32| {
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
@@ -320,12 +320,16 @@ pub(super) fn tesselate(
|
||||
} else {
|
||||
&mut out.solid
|
||||
};
|
||||
let mut sprite_layers = Vec::new();
|
||||
let mut quad = |v: [([f32; 3], [f32; 2]); 4],
|
||||
layer: u16,
|
||||
color: u32,
|
||||
light: u16,
|
||||
back: bool,
|
||||
face: usize| {
|
||||
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
|
||||
sprite_layers.push(layer);
|
||||
}
|
||||
let color = fluid_face(color, face);
|
||||
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
|
||||
target.extend(v.map(vertex));
|
||||
@@ -455,6 +459,7 @@ pub(super) fn tesselate(
|
||||
d as usize,
|
||||
);
|
||||
}
|
||||
out.sprite_layers.extend(sprite_layers);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Optional, texture-matched native overlays. No extra draw calls or world light changes.
|
||||
use crate::sprites::Sprites;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
pub const MAX: usize = 32;
|
||||
|
||||
/// Load only bounded, explicitly named emissive textures from the active pack.
|
||||
pub fn load(root: &Path, sprites: &mut Sprites) {
|
||||
let mut names: Vec<_> = crate::atlas_sources::list_textures(root)
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
name.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("emissive/"))
|
||||
})
|
||||
.take(MAX)
|
||||
.collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
let file = crate::atlas_sources::texture_path(root, &name, "textures");
|
||||
if std::fs::metadata(file).is_ok_and(|m| m.len() <= 1 << 20) {
|
||||
let _ = sprites.insert(root, &name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only loaded base/overlay pairs are sent to the GPU; sorted for vertex lookup.
|
||||
pub fn mappings<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
requests: impl IntoIterator<Item = (&'a str, &'a str)>,
|
||||
) -> Vec<[u32; 4]> {
|
||||
let mut pairs = BTreeMap::new();
|
||||
for (base, overlay) in requests.into_iter().take(MAX) {
|
||||
if let (Some(&b), Some(&o)) = (layers.get(base), layers.get(overlay)) {
|
||||
pairs.entry(b).or_insert(o);
|
||||
}
|
||||
}
|
||||
pairs.into_iter().map(|(b, o)| [b, o, 0, 0]).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_existing_pairs_are_mapped() {
|
||||
let layers = [
|
||||
("minecraft:block/ore".into(), 3),
|
||||
("mine:emissive/ore".into(), 7),
|
||||
]
|
||||
.into();
|
||||
assert_eq!(
|
||||
mappings(
|
||||
&layers,
|
||||
[
|
||||
("minecraft:block/ore", "mine:emissive/ore"),
|
||||
("missing", "mine:emissive/ore")
|
||||
]
|
||||
),
|
||||
vec![[3, 7, 0, 0]]
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -829,6 +829,15 @@ impl PackScreen {
|
||||
/// is the time since the search box took the focus.
|
||||
pub fn draw(&self, m: &Measure, mouse: (i32, i32), blink_ms: u64) -> Vec<Draw> {
|
||||
let mut out = Vec::new();
|
||||
// PackSelectionScreen, or the ConfirmScreen over it (its title and
|
||||
// its message).
|
||||
crate::widgets::begin_screen(|| match &self.confirm {
|
||||
Some(c) => crate::widgets::ScreenInfo::new(
|
||||
"packs/confirm",
|
||||
crate::widgets::join_for_narration(&[&c.title.plain(), &c.message.plain()]),
|
||||
),
|
||||
None => crate::widgets::ScreenInfo::new("packs", self.title.plain()),
|
||||
});
|
||||
if let Some(c) = &self.confirm {
|
||||
let ((tx, ty), (lines, mx, my), buttons) = self.confirm_layout(m, c);
|
||||
d::string_widget(&mut out, m, &c.title, tx, ty, 0);
|
||||
@@ -874,6 +883,11 @@ impl PackScreen {
|
||||
true,
|
||||
hover || self.focus == Focus::Button(i),
|
||||
);
|
||||
if i == 0 {
|
||||
crate::widgets::last_narratable(|n| {
|
||||
n.hint = Some(tr("pack.folderInfo").plain());
|
||||
});
|
||||
}
|
||||
if i == 0 && hover {
|
||||
d::tooltip(&mut out, m, &tr("pack.folderInfo"), mouse);
|
||||
}
|
||||
@@ -887,6 +901,38 @@ impl PackScreen {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let header = if l.selected_side {
|
||||
"pack.selected.title"
|
||||
} else {
|
||||
"pack.available.title"
|
||||
};
|
||||
// TransferableSelectionList: the row under the mouse, else the
|
||||
// selected one; the header narrates its title, a pack "Selected:
|
||||
// <title>".
|
||||
crate::widgets::narratable(|| {
|
||||
use crate::widgets::{ListKind, Narratable, Row as Narrated};
|
||||
let narrated = |i: usize| {
|
||||
let text = match &l.entries[i].row {
|
||||
Row::Header => tr(header).plain(),
|
||||
Row::Pack(id) => {
|
||||
let title = self
|
||||
.info(id)
|
||||
.map_or_else(|| Text::literal(id.clone()), |p| p.title.clone());
|
||||
Text::translate("narrator.select", &[title]).plain()
|
||||
}
|
||||
};
|
||||
Narrated::new(i, text)
|
||||
};
|
||||
Narratable::list(
|
||||
ListKind::Object,
|
||||
l.entries.len(),
|
||||
hovered.map(narrated),
|
||||
l.selected.filter(|i| *i < l.entries.len()).map(narrated),
|
||||
[l.x as f32, l.y as f32, l.width as f32, l.height as f32],
|
||||
)
|
||||
.key(crate::widgets::key_of(&("packs", side)))
|
||||
.focused(l.focused)
|
||||
});
|
||||
let prefix = if self.in_world { "inworld_" } else { "" };
|
||||
let right = l.x + l.width;
|
||||
d::blit(
|
||||
@@ -926,12 +972,7 @@ impl PackScreen {
|
||||
let (cx, cy) = (left + 2, e.y + 2);
|
||||
match &e.row {
|
||||
Row::Header => {
|
||||
let key = if l.selected_side {
|
||||
"pack.selected.title"
|
||||
} else {
|
||||
"pack.available.title"
|
||||
};
|
||||
let t = tr(key).with_style(&Style {
|
||||
let t = tr(header).with_style(&Style {
|
||||
underlined: Some(true),
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
|
||||
@@ -448,8 +448,16 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
|
||||
Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?,
|
||||
};
|
||||
// Gson reads past a UTF-8 byte order mark; serde_json rejects it.
|
||||
serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok()
|
||||
parse_meta(&bytes)
|
||||
}
|
||||
/// The first JSON value of a `pack.mcmeta`: what follows it is ignored, as some packs sign the
|
||||
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
|
||||
/// serde_json rejects it.
|
||||
fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
|
||||
.into_iter()
|
||||
.next()?
|
||||
.ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
@@ -1158,6 +1166,14 @@ mod tests {
|
||||
assert!(!is_downloaded_pack_id("file/Mine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_after_the_metadata_does_not_hide_a_pack() {
|
||||
let meta = br#"{"pack":{"description":"d","pack_format":97}}
|
||||
// Signed by the pack's author"#;
|
||||
assert!(parse_meta(meta).is_some());
|
||||
assert!(parse_meta(b"not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcmeta_with_utf8_bom_is_read() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
|
||||
|
||||
@@ -422,6 +422,15 @@ pub fn button(
|
||||
active: bool,
|
||||
highlighted: bool,
|
||||
) {
|
||||
// Its narration: hovered is the mouse over it (`isHovered`), which the
|
||||
// narrator tests.
|
||||
crate::widgets::narratable(|| {
|
||||
crate::widgets::Narratable::button(
|
||||
label.plain(),
|
||||
[x as f32, y as f32, w as f32, h as f32],
|
||||
active,
|
||||
)
|
||||
});
|
||||
let sprite_id = if !active {
|
||||
"widget/button_disabled"
|
||||
} else if highlighted {
|
||||
@@ -469,6 +478,8 @@ pub struct TextField {
|
||||
pub text_shadow: bool,
|
||||
pub hint: Option<Text>,
|
||||
pub suggestion: Option<String>,
|
||||
/// `getMessage`: what the narrator calls the box.
|
||||
pub message: String,
|
||||
}
|
||||
fn chars(s: &str) -> usize {
|
||||
s.chars().count()
|
||||
@@ -511,6 +522,7 @@ impl TextField {
|
||||
text_shadow: true,
|
||||
hint: None,
|
||||
suggestion: None,
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
pub fn inner_width(&self) -> i32 {
|
||||
@@ -717,15 +729,17 @@ impl TextField {
|
||||
blink_ms: u64,
|
||||
format: Option<&dyn Fn(&str, usize) -> Text>,
|
||||
) {
|
||||
crate::widgets::target_as(
|
||||
[
|
||||
self.x as f32,
|
||||
self.y as f32,
|
||||
self.width as f32,
|
||||
self.height as f32,
|
||||
],
|
||||
crate::widgets::Kind::Field,
|
||||
);
|
||||
let rect = [
|
||||
self.x as f32,
|
||||
self.y as f32,
|
||||
self.width as f32,
|
||||
self.height as f32,
|
||||
];
|
||||
crate::widgets::target_as(rect, crate::widgets::Kind::Field);
|
||||
crate::widgets::narratable(|| {
|
||||
crate::widgets::Narratable::edit_box(self.message.clone(), self.value.clone(), rect)
|
||||
.focused(self.focused)
|
||||
});
|
||||
if self.bordered {
|
||||
let s = if self.focused {
|
||||
"widget/text_field_highlighted"
|
||||
|
||||
@@ -202,6 +202,8 @@ pub struct Settings {
|
||||
/// The packs in `shaderpacks/`, listed when the Shader Packs page opens; the pick is
|
||||
/// `horizon.shader_pack`.
|
||||
pub shader_packs: Vec<crate::shader_pack_list::Entry>,
|
||||
/// The picked pack's options while its Shader Options page is open.
|
||||
pub shader_options: Option<crate::shader_options::ShaderOptions>,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
@@ -285,6 +287,7 @@ impl Default for Settings {
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
shader_packs: Vec::new(),
|
||||
shader_options: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,6 +470,7 @@ impl Settings {
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
shader_packs: Vec::new(),
|
||||
shader_options: None,
|
||||
auto_jump: f.bool("autoJump"),
|
||||
language: f.string("lang"),
|
||||
force_unicode: f.bool("forceUnicodeFont"),
|
||||
@@ -661,6 +665,22 @@ impl Settings {
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `Options.getFinalSoundSourceVolume`: a source's volume times master's.
|
||||
pub fn final_volume(&self, source: &str) -> f32 {
|
||||
if source == "master" {
|
||||
self.master_volume
|
||||
} else {
|
||||
self.volume(source) * self.master_volume
|
||||
}
|
||||
}
|
||||
/// `Options.narrator`: the `narrator` id in options.txt.
|
||||
pub fn narrator(&self) -> horizon_narrator::Status {
|
||||
horizon_narrator::Status::by_id(self.file.int("narrator"))
|
||||
}
|
||||
pub fn set_narrator(&mut self, status: horizon_narrator::Status) {
|
||||
self.file
|
||||
.set("narrator", crate::options_file::V::Int(status.id()));
|
||||
}
|
||||
/// `Options.getBackgroundColor(float)`: the text backdrop colour, `default`
|
||||
/// opacity unless `backgroundForChatOnly` is off.
|
||||
pub fn text_background(&self, default: f32) -> u32 {
|
||||
@@ -809,6 +829,19 @@ pub enum Opt {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn the_narrator_reads_its_status_and_the_voice_under_master() {
|
||||
let s = Settings::parse("narrator:2\nsoundCategory_master:0.5\nsoundCategory_voice:0.5\n");
|
||||
assert_eq!(s.narrator(), horizon_narrator::Status::Chat);
|
||||
assert_eq!(s.final_volume("voice"), 0.25);
|
||||
assert_eq!(s.final_volume("master"), 0.5);
|
||||
let mut s = s;
|
||||
s.set_narrator(horizon_narrator::Status::Off);
|
||||
assert_eq!(
|
||||
Settings::parse(&s.serialize()).narrator(),
|
||||
horizon_narrator::Status::Off
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_option_set_keeps_horizon_files() {
|
||||
let mut s = Settings::parse("fov:70\n");
|
||||
s.mods.enabled.insert("zoom".into());
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
//! The Shader Options page of a `shaders/` pack: its options on the screens the pack lays out,
|
||||
//! with the names its `lang` files give them, and the values the player picked.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use horizon_shaders::{Lang, PackOption, ScreenItem, Screens};
|
||||
|
||||
use crate::lang;
|
||||
use crate::text::Text;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShaderOptions {
|
||||
/// The pack's file name in `shaderpacks/`.
|
||||
pub pack: String,
|
||||
pub options: BTreeMap<String, PackOption>,
|
||||
pub screens: Screens,
|
||||
pub lang: Lang,
|
||||
/// The values that differ from the defaults.
|
||||
pub chosen: BTreeMap<String, String>,
|
||||
/// The screens opened from the main one, innermost last.
|
||||
pub path: Vec<String>,
|
||||
/// Something changed since the page opened.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
impl ShaderOptions {
|
||||
pub fn new(
|
||||
pack: String,
|
||||
options: BTreeMap<String, PackOption>,
|
||||
screens: Screens,
|
||||
lang: Lang,
|
||||
chosen: BTreeMap<String, String>,
|
||||
) -> Self {
|
||||
let mut out = Self {
|
||||
pack,
|
||||
options,
|
||||
screens,
|
||||
lang,
|
||||
chosen: BTreeMap::new(),
|
||||
path: Vec::new(),
|
||||
changed: false,
|
||||
};
|
||||
for (name, value) in chosen {
|
||||
out.set(&name, value);
|
||||
}
|
||||
out.changed = false;
|
||||
out
|
||||
}
|
||||
|
||||
/// The screen shown: the innermost opened, else the main one.
|
||||
pub fn screen(&self) -> &str {
|
||||
self.path.last().map_or("", String::as_str)
|
||||
}
|
||||
|
||||
pub fn items(&self) -> &[ScreenItem] {
|
||||
self.screens
|
||||
.screen(self.screen())
|
||||
.map_or(&[], |(items, _)| items)
|
||||
}
|
||||
|
||||
pub fn value(&self, name: &str) -> Option<&str> {
|
||||
self.chosen
|
||||
.get(name)
|
||||
.or_else(|| self.options.get(name).map(|o| &o.default))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
fn set(&mut self, name: &str, value: String) {
|
||||
let Some(option) = self.options.get(name).filter(|o| o.values.contains(&value)) else {
|
||||
return;
|
||||
};
|
||||
if value == option.default {
|
||||
self.chosen.remove(name);
|
||||
} else {
|
||||
self.chosen.insert(name.to_owned(), value);
|
||||
}
|
||||
self.changed = true;
|
||||
}
|
||||
|
||||
/// The option's next value (the previous one with `back`), wrapping around.
|
||||
pub fn cycle(&mut self, name: &str, back: bool) {
|
||||
let (Some(option), Some(value)) = (self.options.get(name), self.value(name)) else {
|
||||
return;
|
||||
};
|
||||
let n = option.values.len();
|
||||
let at = option.values.iter().position(|v| v == value);
|
||||
let next = match (at, back) {
|
||||
(Some(i), false) => (i + 1) % n,
|
||||
(Some(i), true) => (i + n - 1) % n,
|
||||
(None, _) => 0,
|
||||
};
|
||||
let value = option.values[next].clone();
|
||||
self.set(name, value);
|
||||
}
|
||||
|
||||
/// Where the slider of option `name` stands (its value's place in its list, 0 to 1), when
|
||||
/// the pack shows it as one.
|
||||
pub fn slider_position(&self, name: &str) -> Option<f32> {
|
||||
let option = self.options.get(name)?;
|
||||
if option.switch || option.values.len() < 2 || !self.screens.sliders.contains(name) {
|
||||
return None;
|
||||
}
|
||||
let at = option
|
||||
.values
|
||||
.iter()
|
||||
.position(|v| Some(v.as_str()) == self.value(name))?;
|
||||
Some(at as f32 / (option.values.len() - 1) as f32)
|
||||
}
|
||||
|
||||
/// Option `name` at the value nearest place `position` (0 to 1) of its list.
|
||||
pub fn set_position(&mut self, name: &str, position: f32) {
|
||||
let Some(option) = self.options.get(name) else {
|
||||
return;
|
||||
};
|
||||
let last = option.values.len().saturating_sub(1);
|
||||
let at = (position.clamp(0.0, 1.0) * last as f32).round() as usize;
|
||||
if let Some(value) = option.values.get(at).cloned()
|
||||
&& Some(value.as_str()) != self.value(name)
|
||||
{
|
||||
self.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Option `name` back at the pack's default.
|
||||
pub fn reset_option(&mut self, name: &str) {
|
||||
if self.chosen.remove(name).is_some() {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile after the current one (the first when none matches) with its values.
|
||||
pub fn next_profile(&mut self) {
|
||||
let profiles = &self.screens.profiles;
|
||||
if profiles.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current = self.current_profile().map(str::to_owned);
|
||||
let at = profiles
|
||||
.iter()
|
||||
.position(|(n, _)| Some(n) == current.as_ref());
|
||||
let (_, values) = profiles[at.map_or(0, |i| (i + 1) % profiles.len())].clone();
|
||||
for (name, value) in values {
|
||||
self.set(&name, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_profile(&self) -> Option<&str> {
|
||||
self.screens
|
||||
.current_profile(|n| self.value(n).map(str::to_owned))
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
if !self.chosen.is_empty() {
|
||||
self.chosen.clear();
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn named(&self, key: &str, or: &str) -> Text {
|
||||
Text::legacy(self.lang.get(key).unwrap_or(or))
|
||||
}
|
||||
|
||||
/// The screen's title: its name in the pack's language, or its key.
|
||||
pub fn title(&self) -> Text {
|
||||
match self.screen() {
|
||||
"" => Text::literal(self.pack.clone()),
|
||||
screen => self.named(&format!("screen.{screen}"), screen),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn screen_label(&self, screen: &str) -> Text {
|
||||
self.named(&format!("screen.{screen}"), screen)
|
||||
.append("...")
|
||||
}
|
||||
|
||||
pub fn profile_label(&self) -> Text {
|
||||
let value = match self.current_profile() {
|
||||
Some(name) => self.named(&format!("profile.{name}"), name),
|
||||
None => Text::literal(lang::get("generator.custom")),
|
||||
};
|
||||
Text::literal(format!("{}: ", lang::get("horizon.shader_options.profile"))).append(value)
|
||||
}
|
||||
|
||||
/// What the pack says of an option, a screen or the profiles (`option.NAME.comment`...).
|
||||
pub fn comment(&self, key: &str) -> Option<Text> {
|
||||
self.lang
|
||||
.get(&format!("{key}.comment"))
|
||||
.map(|c| Text::legacy(&c.replace("\\n", "\n")))
|
||||
}
|
||||
|
||||
/// `option_label` within `width` as `measure` sizes it: the name shortened (`Bloom Up...: 2`)
|
||||
/// when the whole doesn't fit, and whether it was.
|
||||
pub fn option_label_within(
|
||||
&self,
|
||||
name: &str,
|
||||
width: f32,
|
||||
measure: impl Fn(&Text) -> f32,
|
||||
) -> (Text, bool) {
|
||||
let full = self.option_label(name);
|
||||
if measure(&full) <= width {
|
||||
return (full, false);
|
||||
}
|
||||
// The pack's text, cut a character at a time with the `§` codes it holds kept.
|
||||
let key = format!("option.{name}");
|
||||
let caption = self.lang.get(&key).unwrap_or(name);
|
||||
let value = self.value_text(name);
|
||||
let mut chars: Vec<char> = caption.chars().collect();
|
||||
loop {
|
||||
while chars.len() >= 2 && chars[chars.len() - 2] == '§' {
|
||||
chars.truncate(chars.len() - 2);
|
||||
}
|
||||
if chars.pop().is_none() {
|
||||
return (full, true);
|
||||
}
|
||||
// Without the spaces and codes it ends with.
|
||||
let mut end = chars.len();
|
||||
loop {
|
||||
if end >= 2 && chars[end - 2] == '§' {
|
||||
end -= 2;
|
||||
} else if end > 0 && chars[end - 1].is_whitespace() {
|
||||
end -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let cut: String = chars[..end].iter().collect();
|
||||
let label = Text::legacy(&format!("{cut}..."))
|
||||
.append(": ")
|
||||
.append(value.clone());
|
||||
if measure(&label) <= width {
|
||||
return (label, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Name: value`, in the pack's words, the value yellow when it is not the default.
|
||||
pub fn option_label(&self, name: &str) -> Text {
|
||||
self.named(&format!("option.{name}"), name)
|
||||
.append(": ")
|
||||
.append(self.value_text(name))
|
||||
}
|
||||
|
||||
/// The value of option `name` as the pack names it, yellow when it is not the default.
|
||||
fn value_text(&self, name: &str) -> Text {
|
||||
let Some(option) = self.options.get(name) else {
|
||||
return Text::literal(String::new());
|
||||
};
|
||||
let value = self.value(name).unwrap_or_default();
|
||||
let shown = if option.switch {
|
||||
Text::literal(lang::get(if value == "true" {
|
||||
"options.on"
|
||||
} else {
|
||||
"options.off"
|
||||
}))
|
||||
} else {
|
||||
let text = self
|
||||
.lang
|
||||
.get(&format!("value.{name}.{value}"))
|
||||
.unwrap_or(value);
|
||||
let prefix = self.lang.get(&format!("prefix.{name}")).unwrap_or("");
|
||||
let suffix = self.lang.get(&format!("suffix.{name}")).unwrap_or("");
|
||||
Text::legacy(&format!("{prefix}{text}{suffix}"))
|
||||
};
|
||||
if self.chosen.contains_key(name) {
|
||||
shown.with_color(crate::text::YELLOW)
|
||||
} else {
|
||||
shown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_shaders::{Macros, Properties};
|
||||
|
||||
fn options() -> ShaderOptions {
|
||||
let switch = |name: &str, default: &str| PackOption {
|
||||
name: name.into(),
|
||||
default: default.into(),
|
||||
values: vec!["false".into(), "true".into()],
|
||||
switch: true,
|
||||
tested: true,
|
||||
};
|
||||
let options = BTreeMap::from([
|
||||
("AO".to_owned(), switch("AO", "true")),
|
||||
(
|
||||
"LEVEL".to_owned(),
|
||||
PackOption {
|
||||
name: "LEVEL".into(),
|
||||
default: "2".into(),
|
||||
values: vec!["1".into(), "2".into(), "3".into()],
|
||||
switch: false,
|
||||
tested: true,
|
||||
},
|
||||
),
|
||||
]);
|
||||
let text = "screen = <profile> [more]\nscreen.more = AO LEVEL\nsliders = LEVEL AO\n\
|
||||
profile.gone = OLD=1\n\
|
||||
profile.low = !AO LEVEL=1\nprofile.high = AO LEVEL=3\n";
|
||||
let screens = Screens::parse(&Properties::parse(text, &Macros::standard()), &options);
|
||||
ShaderOptions::new(
|
||||
"P.zip".into(),
|
||||
options,
|
||||
screens,
|
||||
Lang::default(),
|
||||
BTreeMap::from([
|
||||
("LEVEL".to_owned(), "2".to_owned()),
|
||||
("AO".to_owned(), "maybe".to_owned()),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_cycle_and_defaults_are_not_kept() {
|
||||
let mut o = options();
|
||||
assert!(o.chosen.is_empty() && !o.changed);
|
||||
o.cycle("LEVEL", false);
|
||||
o.cycle("LEVEL", false);
|
||||
assert_eq!(o.value("LEVEL"), Some("1"));
|
||||
o.cycle("LEVEL", true);
|
||||
o.cycle("AO", false);
|
||||
assert_eq!(
|
||||
o.chosen,
|
||||
BTreeMap::from([
|
||||
("AO".to_owned(), "false".to_owned()),
|
||||
("LEVEL".to_owned(), "3".to_owned())
|
||||
])
|
||||
);
|
||||
assert!(o.changed);
|
||||
o.reset();
|
||||
assert!(o.chosen.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliders_take_the_nearest_value() {
|
||||
let mut o = options();
|
||||
assert_eq!(o.slider_position("LEVEL"), Some(0.5));
|
||||
// A switch is a button even when `sliders` names it.
|
||||
assert_eq!(o.slider_position("AO"), None);
|
||||
o.set_position("LEVEL", 0.9);
|
||||
assert_eq!(o.value("LEVEL"), Some("3"));
|
||||
o.set_position("LEVEL", -1.0);
|
||||
assert_eq!(o.value("LEVEL"), Some("1"));
|
||||
o.set_position("LEVEL", 0.4);
|
||||
assert_eq!(o.value("LEVEL"), Some("2"));
|
||||
assert!(o.chosen.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_resets_comments_and_fitted_labels() {
|
||||
let mut o = options();
|
||||
o.lang = Lang::parse(
|
||||
"option.LEVEL=Level of detail\noption.LEVEL.comment=How much detail.\\nMore costs more.\n",
|
||||
);
|
||||
o.cycle("LEVEL", false);
|
||||
assert_eq!(o.value("LEVEL"), Some("3"));
|
||||
o.reset_option("LEVEL");
|
||||
assert_eq!(o.value("LEVEL"), Some("2"));
|
||||
assert_eq!(
|
||||
o.comment("option.LEVEL").map(|c| c.plain()).as_deref(),
|
||||
Some("How much detail.\nMore costs more.")
|
||||
);
|
||||
assert!(o.comment("option.AO").is_none());
|
||||
// One unit a character.
|
||||
let measure = |t: &Text| t.plain().chars().count() as f32;
|
||||
assert!(!o.option_label_within("LEVEL", 40.0, measure).1);
|
||||
let (fitted, cut) = o.option_label_within("LEVEL", 12.0, measure);
|
||||
assert!(cut);
|
||||
assert_eq!(fitted.plain(), "Level...: 2");
|
||||
o.lang = Lang::parse("option.LEVEL=§aLevel §lof detail§r\n");
|
||||
let (fitted, _) = o.option_label_within("LEVEL", 12.0, measure);
|
||||
assert_eq!(fitted.plain(), "Level...: 2");
|
||||
assert_eq!(fitted.spans[0].style, Text::legacy("§aL").spans[0].style);
|
||||
let (fitted, _) = o.option_label_within("LEVEL", 14.0, measure);
|
||||
assert_eq!(fitted.plain(), "Level of...: 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_follow_the_values() {
|
||||
let mut o = options();
|
||||
assert_eq!(o.current_profile(), None);
|
||||
o.next_profile();
|
||||
assert_eq!(o.current_profile(), Some("low"));
|
||||
o.next_profile();
|
||||
assert_eq!(o.current_profile(), Some("high"));
|
||||
assert_eq!(o.value("LEVEL"), Some("3"));
|
||||
o.next_profile();
|
||||
assert_eq!(o.current_profile(), Some("low"));
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,33 @@ pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
}
|
||||
/// Decorate `region[4]`; the region is row-major (dz, dx) around it.
|
||||
fn decorate(&self, _worker: &mut Self::Worker, _region: &mut [Proto; 9]) {}
|
||||
/// Whether a chunk next to final ones (loaded from the save, as the
|
||||
/// chunks of a world an older version made) is decorated, its features
|
||||
/// spilling into them (`proto_beside_final`, `final_writes`), as vanilla's
|
||||
/// are; otherwise it is left undecorated.
|
||||
fn decorates_beside_final(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The proto chunk of a final chunk, for a decoration region to hold: its
|
||||
/// blocks are `snapshot`'s.
|
||||
fn proto_beside_final(&self, _worker: &mut Self::Worker, _snapshot: &Chunk) -> Option<Proto> {
|
||||
None
|
||||
}
|
||||
/// What decoration wrote into the proto chunk of a final chunk: the blocks
|
||||
/// that differ from `snapshot`, as `([x, y, z], state)`.
|
||||
fn final_writes(&self, _snapshot: &Chunk, _proto: &Proto) -> Vec<([i32; 3], u32)> {
|
||||
Vec::new()
|
||||
}
|
||||
/// The block entities decoration made at the blocks `writes` put into a
|
||||
/// final chunk (a chest of a structure piece, a spawner), with their
|
||||
/// contents.
|
||||
fn final_block_entities(
|
||||
&self,
|
||||
_proto: &Proto,
|
||||
_writes: &[([i32; 3], u32)],
|
||||
) -> Vec<((i32, i32, i32), crate::chunk::block_entity::BlockEntity)> {
|
||||
Vec::new()
|
||||
}
|
||||
fn finish(&self, proto: Proto) -> Chunk;
|
||||
/// Whether `proto` has been decorated (a saved one can come back decorated).
|
||||
fn is_decorated(&self, _proto: &Proto) -> bool {
|
||||
@@ -178,7 +205,10 @@ struct MeshSettings {
|
||||
}
|
||||
enum Job {
|
||||
Generate(i32, i32),
|
||||
Decorate(i32, i32, Box<[Proto; 9]>),
|
||||
/// A region to decorate, with the final chunks in it (region index and
|
||||
/// the chunk as it is): their slots hold nothing until the worker makes
|
||||
/// their proto chunks.
|
||||
Decorate(i32, i32, Box<[Proto; 9]>, Vec<(usize, Chunk)>),
|
||||
Finish(Proto),
|
||||
Light(i32, i32, Box<Region>, u64, bool),
|
||||
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
|
||||
@@ -189,11 +219,19 @@ enum Job {
|
||||
/// is given back here, not in the frame.
|
||||
Free(Vec<((i32, i32, i32), SectionMesh)>),
|
||||
}
|
||||
/// What decoration wrote into the final chunks of its region.
|
||||
#[derive(Default)]
|
||||
pub struct FinalWrites {
|
||||
blocks: Vec<([i32; 3], u32)>,
|
||||
block_entities: Vec<((i32, i32, i32), crate::chunk::block_entity::BlockEntity)>,
|
||||
}
|
||||
pub enum Done {
|
||||
Generated(Chunk),
|
||||
/// A proto chunk, and whether it was decorated (loaded so from the save).
|
||||
Proto(i32, i32, Proto, bool),
|
||||
Decorated(i32, i32, Box<[Proto; 9]>),
|
||||
/// A decorated region, the region indices of its final chunks and what
|
||||
/// decoration wrote into them.
|
||||
Decorated(i32, i32, Box<[Proto; 9]>, Vec<usize>, FinalWrites),
|
||||
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
|
||||
Meshed(i32, i32, i32, SectionMesh, u64),
|
||||
Saved(Vec<((i32, i32), Proto)>),
|
||||
@@ -311,6 +349,9 @@ impl Mailbox {
|
||||
}
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
/// Columns generated and saved as they were left behind: new when they
|
||||
/// next arrive, though read from the save (`World::loaded_chunks`).
|
||||
dropped_new: FxHashSet<(i32, i32)>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
@@ -330,6 +371,10 @@ pub struct Streaming {
|
||||
light_views: FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
decorated: FxHashSet<(i32, i32)>,
|
||||
decorating: usize,
|
||||
/// The final chunks a running decoration writes into: another one that
|
||||
/// reaches them waits, as vanilla's features run one after the other and
|
||||
/// each sees what the one before placed.
|
||||
decorating_finals: HashSet<(i32, i32)>,
|
||||
/// Save jobs in flight.
|
||||
saving: usize,
|
||||
/// Vanilla's decoration order around the player, and the centre and
|
||||
@@ -341,6 +386,8 @@ pub struct Streaming {
|
||||
queue_basis: (((i32, i32), bool, Option<Band>), Vec<((i32, i32), i32)>),
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
/// `ChunkGenerator::decorates_beside_final`.
|
||||
saves_beside_final: bool,
|
||||
lighting: FxHashMap<(i32, i32), u64>,
|
||||
meshing: FxHashMap<(i32, i32, i32), u64>,
|
||||
/// The sequence each section's latest accepted mesh was dispatched at
|
||||
@@ -652,11 +699,34 @@ impl Streaming {
|
||||
Done::Proto(x, z, proto, decorated)
|
||||
}
|
||||
},
|
||||
Job::Decorate(x, z, mut region) => {
|
||||
Job::Decorate(x, z, mut region, finals) => {
|
||||
// The final chunks' proto chunks, made here: a
|
||||
// generated proto chunk holding their blocks.
|
||||
for (i, snapshot) in &finals {
|
||||
if let Some(proto) =
|
||||
generator.proto_beside_final(&mut worker, snapshot)
|
||||
{
|
||||
region[*i] = proto;
|
||||
}
|
||||
}
|
||||
generator.decorate(&mut worker, &mut region);
|
||||
Done::Decorated(x, z, region)
|
||||
let mut writes = FinalWrites::default();
|
||||
for (i, snapshot) in &finals {
|
||||
let blocks = generator.final_writes(snapshot, ®ion[*i]);
|
||||
writes.block_entities.extend(
|
||||
generator.final_block_entities(®ion[*i], &blocks),
|
||||
);
|
||||
writes.blocks.extend(blocks);
|
||||
region[*i] = Box::new(());
|
||||
}
|
||||
let finals = finals.into_iter().map(|(i, _)| i).collect();
|
||||
Done::Decorated(x, z, region, finals, writes)
|
||||
}
|
||||
Job::Finish(proto) => {
|
||||
let mut chunk = generator.finish(proto);
|
||||
chunk.generated = true;
|
||||
Done::Generated(chunk)
|
||||
}
|
||||
Job::Finish(proto) => Done::Generated(generator.finish(proto)),
|
||||
Job::Save(protos) => {
|
||||
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
|
||||
generator.save_protos(&refs);
|
||||
@@ -734,13 +804,16 @@ impl Streaming {
|
||||
protos: Default::default(),
|
||||
light_views: Default::default(),
|
||||
decorated: Default::default(),
|
||||
dropped_new: Default::default(),
|
||||
decorating: 0,
|
||||
decorating_finals: HashSet::new(),
|
||||
saving: 0,
|
||||
decoration_order: DecorationOrder::default(),
|
||||
order_area: None,
|
||||
queue_basis: Default::default(),
|
||||
finishing: Default::default(),
|
||||
decorates: generator.decorates(),
|
||||
saves_beside_final: generator.decorates_beside_final(),
|
||||
lighting: Default::default(),
|
||||
meshing: Default::default(),
|
||||
compile_started: Default::default(),
|
||||
@@ -891,16 +964,43 @@ impl Streaming {
|
||||
let mut queue = self.shared.queue.lock().unwrap();
|
||||
rank_again(&mut queue, |x, z| self.distance(x, z));
|
||||
}
|
||||
fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) {
|
||||
fn receive_decorated(
|
||||
&mut self,
|
||||
x: i32,
|
||||
z: i32,
|
||||
region: [Proto; 9],
|
||||
finals: Vec<usize>,
|
||||
writes: FinalWrites,
|
||||
world: &mut World,
|
||||
) {
|
||||
self.generation_scan.exhausted = false;
|
||||
self.decorating -= 1;
|
||||
for i in &finals {
|
||||
self.decorating_finals
|
||||
.remove(&(x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1));
|
||||
}
|
||||
self.decorated.insert((x, z));
|
||||
for (i, proto) in region.into_iter().enumerate() {
|
||||
// A final chunk keeps its own chunk: only what decoration wrote
|
||||
// into it goes there.
|
||||
if finals.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
let key = (x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1);
|
||||
self.light_views.remove(&key);
|
||||
self.protos.insert(key, Some(proto));
|
||||
self.written.insert(key);
|
||||
}
|
||||
for ([bx, by, bz], state) in writes.blocks {
|
||||
let _ = world.set(bx, by, bz, horizon_world::registry::BlockStateId(state));
|
||||
}
|
||||
// After the blocks: the write of a block with a block entity made a
|
||||
// default one, which the generated one replaces.
|
||||
for (pos, entity) in writes.block_entities {
|
||||
if world.chunks.contains_key(&(pos.0 >> 4, pos.2 >> 4)) {
|
||||
world.block_entities.insert(pos, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Saved proto chunks come back; those now out of range are dropped by
|
||||
/// the next `update`.
|
||||
@@ -943,6 +1043,7 @@ impl Streaming {
|
||||
// not give it back.
|
||||
if !self.ranges().chunk_kept(key.0, key.1, self.decorates) {
|
||||
if chunk.fresh {
|
||||
self.dropped_new.insert(key);
|
||||
self.saves.save_finished(chunk);
|
||||
}
|
||||
return;
|
||||
@@ -966,6 +1067,8 @@ impl Streaming {
|
||||
// A chunk saved with its light needs no light job; its unlit
|
||||
// neighbours still light themselves from it.
|
||||
let loaded = chunk.light_loaded;
|
||||
let new = self.dropped_new.remove(&key) || chunk.generated;
|
||||
world.loaded_chunks.push((key.0, key.1, new));
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
if loaded {
|
||||
self.lit.insert(key);
|
||||
@@ -1156,7 +1259,9 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Decorated(x, z, region, finals, writes) => {
|
||||
self.receive_decorated(x, z, *region, finals, writes, world)
|
||||
}
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
Done::Lit(x, z, sections, seq) => {
|
||||
@@ -1394,22 +1499,36 @@ impl Streaming {
|
||||
}
|
||||
let keys: Vec<(i32, i32)> =
|
||||
(0..9).map(|i| (x + (i % 3) - 1, z + (i / 3) - 1)).collect();
|
||||
// A final neighbour cannot receive writes: skip decoration. Only
|
||||
// worlds saved before proto chunks were saved get here (their
|
||||
// A final neighbour cannot be generated again: a generator that
|
||||
// can decorates beside it (a chunk of an older world), writing
|
||||
// into it as vanilla's features do; the others skip decoration
|
||||
// (worlds saved before proto chunks were saved get here: their
|
||||
// final chunks' neighbours are generated again).
|
||||
if keys.iter().any(|k| world.chunks.contains_key(k)) {
|
||||
if keys.iter().any(|k| self.decorating_finals.contains(k)) {
|
||||
continue;
|
||||
}
|
||||
let finals: Vec<(usize, Chunk)> = keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, k)| Some((i, (**world.chunks.get(k)?).clone())))
|
||||
.collect();
|
||||
if !finals.is_empty() && !self.saves_beside_final {
|
||||
self.decorated.insert((x, z));
|
||||
continue;
|
||||
}
|
||||
if !keys
|
||||
.iter()
|
||||
.all(|k| matches!(self.protos.get(k), Some(Some(_))))
|
||||
{
|
||||
if !keys.iter().enumerate().all(|(i, k)| {
|
||||
finals.iter().any(|(f, _)| *f == i)
|
||||
|| matches!(self.protos.get(k), Some(Some(_)))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let region: Vec<Proto> = keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
.enumerate()
|
||||
.map(|(i, k)| {
|
||||
if finals.iter().any(|(f, _)| *f == i) {
|
||||
return Box::new(()) as Proto;
|
||||
}
|
||||
self.light_views.remove(k);
|
||||
self.protos
|
||||
.insert(*k, None)
|
||||
@@ -1422,7 +1541,9 @@ impl Streaming {
|
||||
Err(_) => continue,
|
||||
};
|
||||
self.decorating += 1;
|
||||
self.submit(Some((x, z)), 1, Job::Decorate(x, z, region));
|
||||
self.decorating_finals
|
||||
.extend(finals.iter().map(|(i, _)| keys[*i]));
|
||||
self.submit(Some((x, z)), 1, Job::Decorate(x, z, region, finals));
|
||||
}
|
||||
}
|
||||
self.schedule_generation(world, workers);
|
||||
@@ -1704,7 +1825,9 @@ impl Streaming {
|
||||
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Some(Done::Decorated(x, z, region, finals, writes)) => {
|
||||
self.receive_decorated(x, z, *region, finals, writes, world)
|
||||
}
|
||||
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Some(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Some(other) => self.deferred.push(other),
|
||||
@@ -1732,7 +1855,9 @@ impl Streaming {
|
||||
let started = std::time::Instant::now();
|
||||
while let Some(done) = self.shared.done.try_recv() {
|
||||
match done {
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Decorated(x, z, region, finals, writes) => {
|
||||
self.receive_decorated(x, z, *region, finals, writes, world)
|
||||
}
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
other => self.deferred.push(other),
|
||||
|
||||
@@ -200,6 +200,31 @@ pub fn sorted_players<'a>(players: &'a [TabPlayer], board: &Scoreboard) -> Vec<&
|
||||
out
|
||||
}
|
||||
|
||||
/// What `setVisible(true)` has the narrator say:
|
||||
/// `multiplayer.player.list.narration` with the listed players' names
|
||||
/// (`ComponentUtils.formatList` with ", ").
|
||||
pub fn narration(players: &[TabPlayer], board: &Scoreboard) -> Text {
|
||||
Text::translate(
|
||||
"multiplayer.player.list.narration",
|
||||
&[listed_names(players, board)],
|
||||
)
|
||||
}
|
||||
|
||||
/// The names the list shows, in its order, ", " between them.
|
||||
fn listed_names(players: &[TabPlayer], board: &Scoreboard) -> Text {
|
||||
sorted_players(players, board)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.fold(Text::empty(), |names, (i, p)| {
|
||||
let names = if i == 0 {
|
||||
names
|
||||
} else {
|
||||
names.append(Text::literal(", "))
|
||||
};
|
||||
names.append(name_for_display(p, board))
|
||||
})
|
||||
}
|
||||
|
||||
/// `MutableComponent.withStyle(ITALIC)` on a component's root.
|
||||
fn italic_root(c: &Comp) -> Comp {
|
||||
use serde_json::Value;
|
||||
@@ -321,11 +346,15 @@ struct Entry {
|
||||
}
|
||||
impl TabList {
|
||||
/// `setVisible`: showing or hiding forgets the heart states.
|
||||
pub fn set_visible(&mut self, visible: bool) {
|
||||
/// `setVisible`: true when the list has just come up, which vanilla
|
||||
/// narrates ([`narration`]).
|
||||
pub fn set_visible(&mut self, visible: bool) -> bool {
|
||||
if self.visible != visible {
|
||||
self.health.clear();
|
||||
self.visible = visible;
|
||||
return visible;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `reset`.
|
||||
pub fn reset(&mut self) {
|
||||
@@ -620,6 +649,20 @@ impl TabList {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_list_is_narrated_once_as_it_comes_up_with_its_names_in_order() {
|
||||
let board = Scoreboard::default();
|
||||
let mut alice = TabPlayer::new(2, "Alice");
|
||||
alice.list_order = -1;
|
||||
let players = [TabPlayer::new(1, "bob"), alice];
|
||||
assert_eq!(listed_names(&players, &board).plain(), "bob, Alice");
|
||||
let mut list = TabList::default();
|
||||
assert!(list.set_visible(true));
|
||||
assert!(!list.set_visible(true), "only as it comes up");
|
||||
assert!(!list.set_visible(false));
|
||||
assert!(list.set_visible(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_ping_and_skins() {
|
||||
let board = Scoreboard::default();
|
||||
|
||||
@@ -24,6 +24,10 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
colored_lights: array<array<vec4<f32>, 2>, 16>,
|
||||
colored_count: vec4<u32>,
|
||||
emissive_layers: array<vec4<u32>, 32>,
|
||||
emissive_count: vec4<u32>,
|
||||
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
|
||||
// triangle's volume with the camera; then each rim vertex's normal against the apex.
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
@@ -44,6 +48,10 @@ struct Globals {
|
||||
@group(0) @binding(9) var sprites_5: texture_2d_array<f32>;
|
||||
@group(0) @binding(10) var sprites_6: texture_2d_array<f32>;
|
||||
@group(0) @binding(11) var sprites_7: texture_2d_array<f32>;
|
||||
// Per block sprite (x the layer in its array, y the array), the brightness its
|
||||
// pixels must reach to shine in HDR output: r where the bright parts shine, g
|
||||
// where all of it does (gfx/hdr_stats.rs, measured from the sprite itself).
|
||||
@group(0) @binding(12) var emission_thresholds: texture_2d<f32>;
|
||||
// How many sprite arrays hold layers (a pipeline constant): 1 for vanilla,
|
||||
// which then samples exactly as before.
|
||||
override SPRITE_ARRAYS: u32 = 1u;
|
||||
@@ -144,7 +152,8 @@ struct TerrainOut {
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(3) spherical: f32,
|
||||
@location(4) cylindrical: f32,
|
||||
// The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`).
|
||||
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
|
||||
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
@location(6) @interpolate(flat) fade: u32,
|
||||
// A distant tile's tile-relative position, for its fade (`faded`).
|
||||
@@ -153,6 +162,7 @@ struct TerrainOut {
|
||||
@location(8) visibility: f32,
|
||||
// The camera-relative height, for the water mirror's cut at the plane.
|
||||
@location(9) height: f32,
|
||||
@location(10) @interpolate(flat) emissive_layer: u32,
|
||||
}
|
||||
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
|
||||
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
|
||||
@@ -223,6 +233,15 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
|
||||
}
|
||||
return level * 16.0;
|
||||
}
|
||||
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
|
||||
var rgb = vec3(0.0);
|
||||
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
|
||||
let at = globals.colored_lights[i][0];
|
||||
let color = globals.colored_lights[i][1];
|
||||
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
|
||||
}
|
||||
return min(rgb, vec3(1.0));
|
||||
}
|
||||
fn sample_lightmap(sky: f32, block: f32) -> vec4<f32> {
|
||||
let uv = vec2(block, sky) * 16.0;
|
||||
return textureSampleLevel(lightmap, lightmap_sampler,
|
||||
@@ -284,8 +303,18 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
out.local = position;
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
out.emissive_layer = 0xffffffffu;
|
||||
for (var i = 0u; i < min(globals.emissive_count.x, 32u); i++) {
|
||||
if globals.emissive_layers[i].x == (layer & 0x7fffu) {
|
||||
out.emissive_layer = globals.emissive_layers[i].y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let block = max(f32(light.y), dynamic_block_light(pos));
|
||||
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
if globals.colored_count.x > 0u {
|
||||
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
|
||||
}
|
||||
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
|
||||
out.height = pos.y;
|
||||
out.spherical = length(pos);
|
||||
@@ -306,6 +335,12 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
linear_fog(cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
// hdr.wgsl's brightness: the mean of luminance and the brightest channel, in
|
||||
// linear light.
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
return 0.5 * (dot(l, vec3(0.2126, 0.7152, 0.0722)) + max(l.r, max(l.g, l.b)));
|
||||
}
|
||||
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
return solid_color(in);
|
||||
}
|
||||
@@ -320,14 +355,29 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
let dy = dpdy(in.local);
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
var color = texel * in.color;
|
||||
// terrain.fsh: the fade-in mixes toward the fog colour.
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
|
||||
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
|
||||
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
|
||||
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
|
||||
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel may
|
||||
// shine, and at which tier (mesher::hdr_tier): 0.525 at the lowest, 0 at the
|
||||
// brightest. Only pixels as bright as their sprite's threshold may: its
|
||||
// bright parts, or all of it. Nothing else reads the frame's alpha.
|
||||
var emissive = 1.0;
|
||||
if in.emission > 0u {
|
||||
let whole = in.emission > 4u;
|
||||
let tier = select(in.emission, in.emission - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
|
||||
if brightness(texel.rgb) >= select(t.r, t.g, whole) {
|
||||
emissive = 0.7 * f32(4u - min(tier, 4u)) / 4.0;
|
||||
}
|
||||
}
|
||||
if in.emissive_layer != 0xffffffffu {
|
||||
let mask = sprite(in.uv, in.emissive_layer);
|
||||
return apply_fog(vec4(mix(color.rgb, mask.rgb, mask.a), mix(emissive, 0.1, mask.a)), in.spherical, in.cylindrical);
|
||||
}
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
// Water, glass and other see-through faces draw whole as they fade in, the
|
||||
|
||||
@@ -353,8 +353,29 @@ pub fn button(
|
||||
label: &Text,
|
||||
active: bool,
|
||||
hover: bool,
|
||||
) {
|
||||
button_as(batch, atlas, x, y, w, h, label, active, hover, |rect| {
|
||||
crate::widgets::Narratable::button(label.plain(), rect, active)
|
||||
});
|
||||
}
|
||||
/// A [`button`] that vanilla narrates as another widget (a `CycleButton`, a
|
||||
/// `LockIconButton`): `narration` builds what it records from its rectangle,
|
||||
/// and only while the narrator records.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn button_as(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
label: &Text,
|
||||
active: bool,
|
||||
hover: bool,
|
||||
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
|
||||
) {
|
||||
crate::widgets::record(&label.plain(), [x, y, w, h], active);
|
||||
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
|
||||
crate::widgets::widget_cursor(hover, active);
|
||||
let sprite = if !active {
|
||||
"gui/sprites/widget/button_disabled"
|
||||
@@ -391,6 +412,27 @@ pub fn slider(
|
||||
hover: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
slider_ext(
|
||||
batch, atlas, x, y, w, label, value, hover, false, false, active, dragging,
|
||||
);
|
||||
}
|
||||
|
||||
/// `AbstractSliderButton.extractWidgetRenderState`: track, 8px handle at `value` (0..1),
|
||||
/// with support for keyboard focus and `canChangeValue`.
|
||||
pub fn slider_ext(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
focused: bool,
|
||||
can_change_value: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
let kind = crate::widgets::Kind::Slider {
|
||||
value,
|
||||
@@ -398,11 +440,14 @@ pub fn slider(
|
||||
};
|
||||
// The control socket's widget tree (`ui`): its label and value.
|
||||
crate::widgets::record_as(&label.plain(), [x, y, w, 20.0], active, kind);
|
||||
crate::widgets::narratable(|| {
|
||||
crate::widgets::Narratable::slider(label.plain(), [x, y, w, 20.0], active).hovered(hover)
|
||||
});
|
||||
if active {
|
||||
// A controller's D-pad can focus it (Horizon's controller support).
|
||||
crate::widgets::target_as([x, y, w, 20.0], kind);
|
||||
}
|
||||
let (track, handle) = slider_sprites(hover, active, dragging);
|
||||
let (track, handle) = slider_sprites_ext(hover, active, focused, can_change_value, dragging);
|
||||
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
|
||||
nine_slice(
|
||||
batch,
|
||||
@@ -421,15 +466,26 @@ pub fn slider(
|
||||
};
|
||||
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
|
||||
}
|
||||
/// Track and handle sprites: the track outline shows only while the slider is
|
||||
/// dragged, hovering highlights just the handle.
|
||||
#[cfg(test)]
|
||||
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
|
||||
let track = if dragging {
|
||||
slider_sprites_ext(hover, active, false, false, dragging)
|
||||
}
|
||||
|
||||
/// Track and handle sprites: the track outline shows while dragged or focused with
|
||||
/// keyboard navigation (!can_change_value), hovering or editing highlights the handle.
|
||||
fn slider_sprites_ext(
|
||||
hover: bool,
|
||||
active: bool,
|
||||
focused: bool,
|
||||
can_change_value: bool,
|
||||
dragging: bool,
|
||||
) -> (&'static str, &'static str) {
|
||||
let track = if dragging || (active && focused && !can_change_value) {
|
||||
"gui/sprites/widget/slider_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider"
|
||||
};
|
||||
let handle = if hover && active {
|
||||
let handle = if active && (hover || can_change_value || dragging) {
|
||||
"gui/sprites/widget/slider_handle_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider_handle"
|
||||
@@ -648,6 +704,7 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::widgets::frame_tooltip(lines);
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|l| atlas.font.width_text(l))
|
||||
@@ -665,7 +722,6 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
}
|
||||
y = y.max(4.0);
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -714,6 +770,9 @@ pub struct EditBox {
|
||||
/// Grey completion shown after the text (EditBox.setSuggestion).
|
||||
pub suggestion: Option<String>,
|
||||
pub text_color: u32,
|
||||
/// `getMessage`: what the narrator calls the box ("<message> edit box:
|
||||
/// <value>").
|
||||
pub message: String,
|
||||
}
|
||||
/// The focused text field of a screen that keeps its values as `String`s
|
||||
/// (the world list's search, world names, seeds, and the server screens): the `EditBox` state that
|
||||
@@ -975,6 +1034,7 @@ impl EditBox {
|
||||
focused_at: Instant::now(),
|
||||
suggestion: None,
|
||||
text_color: rgba(224, 224, 224, 255),
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
pub fn inner_width(&self) -> f32 {
|
||||
@@ -1197,6 +1257,14 @@ impl EditBox {
|
||||
[self.x, self.y, self.width, self.height],
|
||||
crate::widgets::Kind::Field,
|
||||
);
|
||||
crate::widgets::narratable(|| {
|
||||
crate::widgets::Narratable::edit_box(
|
||||
self.message.clone(),
|
||||
self.value.clone(),
|
||||
[self.x, self.y, self.width, self.height],
|
||||
)
|
||||
.focused(self.focused)
|
||||
});
|
||||
if self.bordered {
|
||||
let sprite = if self.focused {
|
||||
"gui/sprites/widget/text_field_highlighted"
|
||||
|
||||
@@ -206,6 +206,9 @@ pub fn end_frame(frames: u64) {
|
||||
*last = std::mem::take(current);
|
||||
*generation += 1;
|
||||
});
|
||||
NARRATION.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::replace(current, Narration::EMPTY);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -225,6 +228,717 @@ pub fn last_labelled() -> Vec<Widget> {
|
||||
WIDGETS.with_borrow(|(_, last, _)| last.clone())
|
||||
}
|
||||
|
||||
// Screen narration: what a frame's screen and widgets would narrate
|
||||
// (`Screen.updateNarrationState` and each `NarratableEntry`), recorded by the
|
||||
// drawers as they draw, for horizon-client's `screen_narrator` to read once the
|
||||
// frame is done. Nothing is recorded (nor built: the record calls take
|
||||
// closures) unless the narrator asked for it with `set_narrating`.
|
||||
|
||||
pub use horizon_narrator::screen::ElementType;
|
||||
|
||||
/// The screen a frame drew, as `Screen` narrates it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ScreenInfo {
|
||||
/// Which screen this is: a new id is a new `Screen` (narrated as it
|
||||
/// opens), the same id a screen still open.
|
||||
pub id: String,
|
||||
/// `getNarrationMessage` (TITLE at depth 0): the title, or its
|
||||
/// override (`joinForNarration(title, message)` for confirm screens).
|
||||
/// Empty for `CommonComponents.EMPTY`, which is narrated all the same.
|
||||
pub title: String,
|
||||
/// `shouldNarrateNavigation`: "Use mouse cursor or Tab button to select
|
||||
/// element" when no widget is narrated.
|
||||
pub navigation: bool,
|
||||
/// `updateNarratedWidget` runs (screens that override
|
||||
/// `updateNarrationState`, the chat, don't).
|
||||
pub widgets: bool,
|
||||
/// The screen's own elements beyond its title, at their depth (an
|
||||
/// element of the title's kind and depth replaces it, as
|
||||
/// `LevelLoadingScreen`'s progress does).
|
||||
pub extra: Vec<(ElementType, u32, Vec<String>)>,
|
||||
/// `getUsageNarration`, when the screen overrides it.
|
||||
pub usage: Option<String>,
|
||||
/// `triggerImmediateNarration(true)` every this many milliseconds
|
||||
/// (`LevelLoadingScreen`'s progress).
|
||||
pub repeat_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl ScreenInfo {
|
||||
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
navigation: true,
|
||||
widgets: true,
|
||||
extra: Vec::new(),
|
||||
usage: None,
|
||||
repeat_ms: None,
|
||||
}
|
||||
}
|
||||
/// One more element of the screen's own.
|
||||
pub fn element(self, kind: ElementType, depth: u32, text: impl Into<String>) -> Self {
|
||||
self.lines(kind, depth, vec![text.into()])
|
||||
}
|
||||
/// One more element of several lines, read as sentences.
|
||||
pub fn lines(mut self, kind: ElementType, depth: u32, lines: Vec<String>) -> Self {
|
||||
self.extra.push((kind, depth, lines));
|
||||
self
|
||||
}
|
||||
pub fn without_widgets(mut self) -> Self {
|
||||
self.widgets = false;
|
||||
self
|
||||
}
|
||||
pub fn without_navigation(mut self) -> Self {
|
||||
self.navigation = false;
|
||||
self
|
||||
}
|
||||
pub fn usage(mut self, usage: impl Into<String>) -> Self {
|
||||
self.usage = Some(usage.into());
|
||||
self
|
||||
}
|
||||
pub fn repeat_every(mut self, ms: u32) -> Self {
|
||||
self.repeat_ms = Some(ms);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What a list narrates its rows as.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ListKind {
|
||||
/// `ObjectSelectionList`: "Selected list row i out of n", the row's
|
||||
/// narration, and the selection's usage when focused.
|
||||
Object,
|
||||
/// `TabNavigationBar`: "Selected tab i out of n", "<title> tab", and
|
||||
/// the tab navigation's usage when focused.
|
||||
Tabs,
|
||||
}
|
||||
|
||||
/// A row of an `ObjectSelectionList` (its `getNarration`) or a tab (its
|
||||
/// title and `getTabExtraNarration`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Row {
|
||||
pub index: usize,
|
||||
pub narration: String,
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn new(index: usize, narration: impl Into<String>) -> Self {
|
||||
Self {
|
||||
index,
|
||||
narration: narration.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A narratable widget's kind and what its narration reads.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum What {
|
||||
/// `Button`: "<message> button".
|
||||
Button { message: String, active: bool },
|
||||
/// A button whose `createNarrationMessage` is its own (TITLE as is), with
|
||||
/// a button's usage.
|
||||
Named { title: String, active: bool },
|
||||
/// `AbstractSliderButton`: "<message> slider".
|
||||
Slider { message: String, active: bool },
|
||||
/// `CycleButton`: "<message> button" and the value a click moves to.
|
||||
Cycle {
|
||||
message: String,
|
||||
next: String,
|
||||
active: bool,
|
||||
},
|
||||
/// A `CycleButton` with its own narration (`withCustomNarration`):
|
||||
/// `title` as is, and the value a click moves to.
|
||||
NamedCycle {
|
||||
title: String,
|
||||
next: String,
|
||||
active: bool,
|
||||
},
|
||||
/// `Checkbox`: "<message> button" and whether a click checks it.
|
||||
Checkbox {
|
||||
message: String,
|
||||
selected: bool,
|
||||
active: bool,
|
||||
},
|
||||
/// `EditBox`: "<message> edit box: <value>"; an inactive one (a box
|
||||
/// that can't be edited now) isn't narrated.
|
||||
EditBox {
|
||||
message: String,
|
||||
value: String,
|
||||
active: bool,
|
||||
},
|
||||
/// A text widget that narrates its message and nothing else
|
||||
/// (`FittingMultiLineTextWidget`, a list's `FocusableTextWidget` header).
|
||||
Text { message: String },
|
||||
/// An `ObjectSelectionList` or a `TabNavigationBar`: `total` rows, the one
|
||||
/// under the mouse and the selected one.
|
||||
List {
|
||||
kind: ListKind,
|
||||
total: usize,
|
||||
hovered: Option<Row>,
|
||||
selected: Option<Row>,
|
||||
},
|
||||
/// A `ContainerObjectSelectionList` of `rows` rows (options, key binds):
|
||||
/// its rows' widgets are the narratables recorded with it as their
|
||||
/// parent. `hovered_row` is the row under the mouse.
|
||||
ContainerList {
|
||||
rows: usize,
|
||||
hovered_row: Option<usize>,
|
||||
},
|
||||
/// `RecipeButton`: "Recipe for <item>".
|
||||
Recipe { item: String, more: bool },
|
||||
/// `RecipeBookComponent`: one narratable, HOVERED whenever `visible`
|
||||
/// (whatever the mouse and the focus), that narrates the widget
|
||||
/// `findNarratableWidget` picks among its own one level in, with no
|
||||
/// position of its own. Its widgets are the narratables recorded with it
|
||||
/// as their parent (row 0).
|
||||
Group { visible: bool },
|
||||
}
|
||||
|
||||
/// A `NarratableEntry` as drawn this frame.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Narratable {
|
||||
/// The widget's identity across frames (`lastNarratable`); 0 derives
|
||||
/// one from its kind and place. A list's children need stable keys.
|
||||
pub key: u64,
|
||||
/// GUI pixels.
|
||||
pub rect: [f32; 4],
|
||||
/// `isHovered`, as the drawer saw it; `None` tests the mouse against
|
||||
/// `rect`.
|
||||
pub hovered: Option<bool>,
|
||||
/// `isFocused` (a text field with the caret, a focused list).
|
||||
pub focused: bool,
|
||||
pub what: What,
|
||||
/// Its tooltip's narration (HINT); empty for none. Without one, the
|
||||
/// tooltip drawn this frame is the hovered widget's.
|
||||
pub hint: Option<String>,
|
||||
/// A row widget of a `ContainerList`: that list's key and the row.
|
||||
pub parent: Option<(u64, usize)>,
|
||||
/// `getTabOrderGroup`: narratables are numbered in this order, then in
|
||||
/// the order they were drawn.
|
||||
pub order: i32,
|
||||
}
|
||||
|
||||
impl Narratable {
|
||||
pub fn new(what: What, rect: [f32; 4]) -> Self {
|
||||
Self {
|
||||
key: 0,
|
||||
rect,
|
||||
hovered: None,
|
||||
focused: false,
|
||||
what,
|
||||
hint: None,
|
||||
parent: None,
|
||||
order: 0,
|
||||
}
|
||||
}
|
||||
pub fn button(message: impl Into<String>, rect: [f32; 4], active: bool) -> Self {
|
||||
Self::new(
|
||||
What::Button {
|
||||
message: message.into(),
|
||||
active,
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
pub fn slider(message: impl Into<String>, rect: [f32; 4], active: bool) -> Self {
|
||||
Self::new(
|
||||
What::Slider {
|
||||
message: message.into(),
|
||||
active,
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
pub fn edit_box(message: impl Into<String>, value: impl Into<String>, rect: [f32; 4]) -> Self {
|
||||
Self::new(
|
||||
What::EditBox {
|
||||
message: message.into(),
|
||||
value: value.into(),
|
||||
active: true,
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
/// A button narrated by its own `createNarrationMessage`.
|
||||
pub fn named(title: impl Into<String>, rect: [f32; 4], active: bool) -> Self {
|
||||
Self::new(
|
||||
What::Named {
|
||||
title: title.into(),
|
||||
active,
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
/// A `CycleButton` showing `message`; a click moves it to `next`.
|
||||
pub fn cycle(
|
||||
message: impl Into<String>,
|
||||
next: impl Into<String>,
|
||||
rect: [f32; 4],
|
||||
active: bool,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
What::Cycle {
|
||||
message: message.into(),
|
||||
next: next.into(),
|
||||
active,
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
pub fn checkbox(
|
||||
message: impl Into<String>,
|
||||
selected: bool,
|
||||
rect: [f32; 4],
|
||||
active: bool,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
What::Checkbox {
|
||||
message: message.into(),
|
||||
selected,
|
||||
active,
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
pub fn text(message: impl Into<String>, rect: [f32; 4]) -> Self {
|
||||
Self::new(
|
||||
What::Text {
|
||||
message: message.into(),
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
/// An `ObjectSelectionList` (or tabs) of `total` rows over `rect`.
|
||||
pub fn list(
|
||||
kind: ListKind,
|
||||
total: usize,
|
||||
hovered: Option<Row>,
|
||||
selected: Option<Row>,
|
||||
rect: [f32; 4],
|
||||
) -> Self {
|
||||
Self::new(
|
||||
What::List {
|
||||
kind,
|
||||
total,
|
||||
hovered,
|
||||
selected,
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
/// A `ContainerObjectSelectionList` of `rows` rows over `rect`.
|
||||
pub fn container_list(rows: usize, hovered_row: Option<usize>, rect: [f32; 4]) -> Self {
|
||||
Self::new(What::ContainerList { rows, hovered_row }, rect)
|
||||
}
|
||||
/// A `RecipeBookComponent`-like group over `rect`, narrated while
|
||||
/// `visible`; its widgets are recorded with [`Self::in_group`].
|
||||
pub fn group(visible: bool, rect: [f32; 4]) -> Self {
|
||||
Self::new(What::Group { visible }, rect)
|
||||
}
|
||||
/// `RecipeButton`: the recipe's result `item`, and whether it has more
|
||||
/// recipes than one.
|
||||
pub fn recipe(item: impl Into<String>, more: bool, rect: [f32; 4]) -> Self {
|
||||
Self::new(
|
||||
What::Recipe {
|
||||
item: item.into(),
|
||||
more,
|
||||
},
|
||||
rect,
|
||||
)
|
||||
}
|
||||
pub fn key(mut self, key: u64) -> Self {
|
||||
self.key = key;
|
||||
self
|
||||
}
|
||||
pub fn hovered(mut self, hovered: bool) -> Self {
|
||||
self.hovered = Some(hovered);
|
||||
self
|
||||
}
|
||||
pub fn focused(mut self, focused: bool) -> Self {
|
||||
self.focused = focused;
|
||||
self
|
||||
}
|
||||
pub fn hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
/// No HINT, whatever tooltip the frame draws: the tooltip isn't the
|
||||
/// widget's (a list's row tooltip, drawn by the list).
|
||||
pub fn without_tooltip(self) -> Self {
|
||||
self.hint("")
|
||||
}
|
||||
/// A widget of row `row` of the `ContainerList` keyed `list`.
|
||||
pub fn child_of(mut self, list: u64, row: usize) -> Self {
|
||||
self.parent = Some((list, row));
|
||||
self
|
||||
}
|
||||
/// A widget of the [`What::Group`] keyed `group`.
|
||||
pub fn in_group(self, group: u64) -> Self {
|
||||
self.child_of(group, 0)
|
||||
}
|
||||
pub fn order(mut self, order: i32) -> Self {
|
||||
self.order = order;
|
||||
self
|
||||
}
|
||||
/// `isActive`: visible and active. Inactive buttons and sliders are left
|
||||
/// out of a screen's narratables.
|
||||
pub fn is_active(&self) -> bool {
|
||||
match &self.what {
|
||||
What::Button { active, .. }
|
||||
| What::Named { active, .. }
|
||||
| What::Slider { active, .. }
|
||||
| What::Cycle { active, .. }
|
||||
| What::NamedCycle { active, .. }
|
||||
| What::Checkbox { active, .. }
|
||||
| What::EditBox { active, .. } => *active,
|
||||
What::Text { .. }
|
||||
| What::List { .. }
|
||||
| What::ContainerList { .. }
|
||||
| What::Recipe { .. }
|
||||
| What::Group { .. } => true,
|
||||
}
|
||||
}
|
||||
/// `key`, or one from the widget's kind and place (never 0).
|
||||
pub fn identity(&self) -> u64 {
|
||||
if self.key != 0 {
|
||||
return self.key;
|
||||
}
|
||||
let place = self.rect.map(|v| v.round() as i32);
|
||||
key_of(&(std::mem::discriminant(&self.what), place))
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable key for a widget from anything that names it (a list's name, an
|
||||
/// option's id), never 0.
|
||||
pub fn key_of(name: &impl std::hash::Hash) -> u64 {
|
||||
use std::hash::Hasher;
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
name.hash(&mut hasher);
|
||||
hasher.finish() | 1
|
||||
}
|
||||
|
||||
/// `CommonComponents.joinForNarration`: the parts, ". " between them.
|
||||
pub fn join_for_narration(parts: &[&str]) -> String {
|
||||
parts.join(". ")
|
||||
}
|
||||
|
||||
/// A frame's narration records.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Narration {
|
||||
pub screen: Option<ScreenInfo>,
|
||||
pub items: Vec<Narratable>,
|
||||
/// The tooltip drawn this frame, its lines joined.
|
||||
pub tooltip: Option<String>,
|
||||
}
|
||||
|
||||
impl Narration {
|
||||
const EMPTY: Narration = Narration {
|
||||
screen: None,
|
||||
items: Vec::new(),
|
||||
tooltip: None,
|
||||
};
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The narrator wants the frames' narration (`Screen.shouldRunNarration`).
|
||||
static NARRATING: Cell<bool> = const { Cell::new(false) };
|
||||
/// Narration recorded since the last `end_frame`, and the last frame's.
|
||||
static NARRATION: RefCell<(Narration, Narration)> =
|
||||
const { RefCell::new((Narration::EMPTY, Narration::EMPTY)) };
|
||||
/// A screen asked for `triggerImmediateNarration(true)`.
|
||||
static NARRATE_NOW: Cell<bool> = const { Cell::new(false) };
|
||||
/// Where the narrator's focused widget was last drawn.
|
||||
static FOCUS: Cell<Option<[f32; 4]>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The narrator tells the drawers where its focused widget is (a click's
|
||||
/// or the controller's), so what only a hovered or focused widget narrates
|
||||
/// (a cycle button's next value) is worked out for it alone.
|
||||
pub fn set_narration_focus(rect: Option<[f32; 4]>) {
|
||||
FOCUS.set(rect);
|
||||
}
|
||||
|
||||
/// Whether the widget at `rect` has the narrator's focus.
|
||||
pub fn narration_focused(rect: [f32; 4]) -> bool {
|
||||
FOCUS
|
||||
.get()
|
||||
.is_some_and(|f| f.iter().zip(rect).all(|(a, b)| (a - b).abs() < 0.5))
|
||||
}
|
||||
|
||||
/// Whether the frames record their narration; the narrator switches it.
|
||||
pub fn set_narrating(on: bool) {
|
||||
NARRATING.set(on);
|
||||
if !on {
|
||||
NARRATION.with_borrow_mut(|(current, _)| *current = Narration::EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn narrating() -> bool {
|
||||
NARRATING.get()
|
||||
}
|
||||
|
||||
/// The screen being drawn: what it narrates and which one it is. The
|
||||
/// narratables recorded before it in the frame are dropped: the last screen
|
||||
/// drawn (an overlay over the screen under it) is the one narrated.
|
||||
pub fn begin_screen(info: impl FnOnce() -> ScreenInfo) {
|
||||
if NARRATING.get() {
|
||||
NARRATION.with_borrow_mut(|(current, _)| {
|
||||
current.screen = Some(info());
|
||||
current.items.clear();
|
||||
current.tooltip = None;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes the screen begun this frame, if one was.
|
||||
pub fn screen_mut(f: impl FnOnce(&mut ScreenInfo)) {
|
||||
if NARRATING.get() {
|
||||
NARRATION.with_borrow_mut(|(current, _)| {
|
||||
if let Some(screen) = &mut current.screen {
|
||||
f(screen);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A narratable widget of the screen begun this frame (nothing without one).
|
||||
pub fn narratable(narratable: impl FnOnce() -> Narratable) {
|
||||
if NARRATING.get() {
|
||||
NARRATION.with_borrow_mut(|(current, _)| {
|
||||
if current.screen.is_some() && current.items.len() < 512 {
|
||||
current.items.push(narratable());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes the narratable recorded last this frame (one a shared drawer
|
||||
/// recorded: an edit box's message, a button's key or row).
|
||||
pub fn last_narratable(f: impl FnOnce(&mut Narratable)) {
|
||||
if NARRATING.get() {
|
||||
NARRATION.with_borrow_mut(|(current, _)| {
|
||||
if let Some(last) = current.items.last_mut() {
|
||||
f(last);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes the narratable recorded last this frame and moves it to `index`
|
||||
/// among the screen's: a widget the screen adds before others it draws first.
|
||||
pub fn last_narratable_at(index: usize, f: impl FnOnce(&mut Narratable)) {
|
||||
if NARRATING.get() {
|
||||
NARRATION.with_borrow_mut(|(current, _)| {
|
||||
if let Some(mut last) = current.items.pop() {
|
||||
f(&mut last);
|
||||
let index = index.min(current.items.len());
|
||||
current.items.insert(index, last);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// How many narratables the frame has so far, for [`move_narratables`].
|
||||
pub fn narratables_mark() -> usize {
|
||||
if !NARRATING.get() {
|
||||
return 0;
|
||||
}
|
||||
NARRATION.with_borrow(|(current, _)| current.items.len())
|
||||
}
|
||||
|
||||
/// Moves the narratables recorded since `mark` to `index`, in their order:
|
||||
/// widgets a screen adds before others it draws first.
|
||||
pub fn move_narratables(mark: usize, index: usize) {
|
||||
if NARRATING.get() {
|
||||
NARRATION.with_borrow_mut(|(current, _)| {
|
||||
let items = &mut current.items;
|
||||
let mark = mark.min(items.len());
|
||||
let index = index.min(mark);
|
||||
let moved = items.len() - mark;
|
||||
items[index..].rotate_right(moved);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Names the edit box recorded last, which a shared drawer recorded without
|
||||
/// its `getMessage`.
|
||||
pub fn name_last_edit_box(name: impl FnOnce() -> String) {
|
||||
last_narratable(|n| {
|
||||
if let What::EditBox { message, .. } = &mut n.what {
|
||||
*message = name();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The tooltip drawn this frame: the hovered widget's narration hint.
|
||||
pub fn frame_tooltip(lines: &[crate::text::Text]) {
|
||||
if NARRATING.get() {
|
||||
NARRATION.with_borrow_mut(|(current, _)| {
|
||||
let text: Vec<String> = lines.iter().map(|l| l.plain()).collect();
|
||||
current.tooltip = Some(text.join(" "));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `triggerImmediateNarration(true)`: narrate what changed at once.
|
||||
pub fn narrate_now() {
|
||||
if NARRATING.get() {
|
||||
NARRATE_NOW.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a screen asked to narrate at once since the last call.
|
||||
pub fn take_narrate_now() -> bool {
|
||||
NARRATE_NOW.replace(false)
|
||||
}
|
||||
|
||||
/// The last drawn frame's narration, taken (the narrator is its only reader).
|
||||
pub fn take_last_narration() -> Narration {
|
||||
NARRATION.with_borrow_mut(|(_, last)| std::mem::replace(last, Narration::EMPTY))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod narration_tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(n: u64) -> Narration {
|
||||
end_frame(n);
|
||||
take_last_narration()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_recorded_or_built_unless_narrating() {
|
||||
set_narrating(false);
|
||||
let _ = frame(1000);
|
||||
begin_screen(|| unreachable!("not built while the narrator is off"));
|
||||
narratable(|| unreachable!());
|
||||
narrate_now();
|
||||
let last = frame(1001);
|
||||
assert!(last.screen.is_none() && last.items.is_empty());
|
||||
assert!(!take_narrate_now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_keeps_the_last_screen_begun_and_its_widgets() {
|
||||
set_narrating(true);
|
||||
let _ = frame(2000);
|
||||
// Without a screen, widgets aren't kept.
|
||||
narratable(|| Narratable::button("Stray", [0.0; 4], true));
|
||||
begin_screen(|| ScreenInfo::new("pause", "Game Menu"));
|
||||
narratable(|| Narratable::button("Back to Game", [0.0, 0.0, 200.0, 20.0], true));
|
||||
frame_tooltip(&[crate::text::Text::literal("Under")]);
|
||||
// An overlay over the pause menu: its own screen and widgets.
|
||||
begin_screen(|| ScreenInfo::new("confirm", "Sure?"));
|
||||
narratable(|| Narratable::button("Yes", [0.0, 30.0, 100.0, 20.0], true));
|
||||
screen_mut(|s| s.title.push('!'));
|
||||
frame_tooltip(&[
|
||||
crate::text::Text::literal("Two"),
|
||||
crate::text::Text::literal("lines"),
|
||||
]);
|
||||
let last = frame(2001);
|
||||
assert_eq!(last.screen.unwrap().title, "Sure?!");
|
||||
assert_eq!(last.items.len(), 1);
|
||||
assert_eq!(
|
||||
last.items[0].what,
|
||||
What::Button {
|
||||
message: "Yes".into(),
|
||||
active: true
|
||||
}
|
||||
);
|
||||
assert_eq!(last.tooltip.as_deref(), Some("Two lines"));
|
||||
// Taken: read once.
|
||||
assert!(take_last_narration().screen.is_none());
|
||||
// A frame not drawn doesn't rotate.
|
||||
begin_screen(|| ScreenInfo::new("title", ""));
|
||||
assert!(frame(2001).screen.is_none());
|
||||
assert_eq!(frame(2002).screen.unwrap().id, "title");
|
||||
// Switching off drops what was recorded.
|
||||
begin_screen(|| ScreenInfo::new("title", ""));
|
||||
set_narrating(false);
|
||||
assert!(frame(2003).screen.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_widget_recorded_can_be_named_after_the_fact() {
|
||||
set_narrating(true);
|
||||
let _ = frame(3000);
|
||||
begin_screen(|| ScreenInfo::new("select_world", "Select World"));
|
||||
// A shared drawer records the box without its message.
|
||||
narratable(|| Narratable::edit_box("", "abc", [0.0; 4]));
|
||||
last_narratable(|n| {
|
||||
if let What::EditBox { message, .. } = &mut n.what {
|
||||
*message = "Search for worlds".into();
|
||||
}
|
||||
n.order = -1;
|
||||
});
|
||||
let last = frame(3001);
|
||||
assert_eq!(
|
||||
last.items[0].what,
|
||||
What::EditBox {
|
||||
message: "Search for worlds".into(),
|
||||
value: "abc".into(),
|
||||
active: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(last.items[0].order, -1);
|
||||
set_narrating(false);
|
||||
last_narratable(|_| unreachable!("nothing recorded while off"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widgets_drawn_late_can_be_moved_to_where_they_were_added() {
|
||||
set_narrating(true);
|
||||
let _ = frame(4000);
|
||||
begin_screen(|| ScreenInfo::new("command_block", ""));
|
||||
narratable(|| Narratable::button("Done", [0.0; 4], true));
|
||||
narratable(|| Narratable::button("Cancel", [0.0; 4], true));
|
||||
let mark = narratables_mark();
|
||||
narratable(|| Narratable::edit_box("Command", "", [0.0; 4]));
|
||||
narratable(|| Narratable::button("Late", [0.0; 4], true));
|
||||
move_narratables(mark, 0);
|
||||
let order: Vec<String> = frame(4001)
|
||||
.items
|
||||
.iter()
|
||||
.map(|n| match &n.what {
|
||||
What::Button { message, .. } | What::EditBox { message, .. } => message.clone(),
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(order, ["Command", "Late", "Done", "Cancel"]);
|
||||
set_narrating(false);
|
||||
assert_eq!(narratables_mark(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widgets_know_who_they_are_and_whether_they_are_narrated() {
|
||||
let a = Narratable::button("A", [10.0, 20.0, 200.0, 20.0], true);
|
||||
let moved = Narratable::button("A", [10.0, 44.0, 200.0, 20.0], true);
|
||||
let slider = Narratable::slider("A", [10.0, 20.0, 200.0, 20.0], true);
|
||||
assert_eq!(
|
||||
a.identity(),
|
||||
Narratable::button("B", a.rect, false).identity()
|
||||
);
|
||||
assert_ne!(a.identity(), moved.identity());
|
||||
assert_ne!(a.identity(), slider.identity());
|
||||
assert_eq!(a.clone().key(7).identity(), 7);
|
||||
assert_ne!(key_of(&"options"), 0);
|
||||
assert!(a.is_active());
|
||||
assert!(!Narratable::button("B", a.rect, false).is_active());
|
||||
assert!(Narratable::edit_box("Search", "", a.rect).is_active());
|
||||
let mut port = Narratable::edit_box("Port", "", a.rect);
|
||||
if let What::EditBox { active, .. } = &mut port.what {
|
||||
*active = false;
|
||||
}
|
||||
assert!(!port.is_active());
|
||||
assert!(Narratable::group(false, a.rect).is_active());
|
||||
let recipe = Narratable::recipe("Torch", true, a.rect).in_group(9);
|
||||
assert_eq!(recipe.parent, Some((9, 0)));
|
||||
assert_eq!(join_for_narration(&["Title", "Message"]), "Title. Message");
|
||||
}
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// The kind's name in the control socket's `ui` reply.
|
||||
pub fn name(self) -> &'static str {
|
||||
|
||||
@@ -70,6 +70,9 @@ pub struct World {
|
||||
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
|
||||
/// Saved entities of chunks that just loaded, spawned by the game.
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
|
||||
/// Chunks that just came into this level from a worker, and whether each
|
||||
/// was generated rather than read from the save: the game tells native mods.
|
||||
pub loaded_chunks: Vec<(i32, i32, bool)>,
|
||||
/// Chunks changed since the last save.
|
||||
pub modified: FxHashSet<(i32, i32)>,
|
||||
/// Block entities, and the order they tick in.
|
||||
@@ -192,6 +195,7 @@ impl World {
|
||||
dirty_light: Default::default(),
|
||||
light_edits: Vec::new(),
|
||||
loaded_entities: Vec::new(),
|
||||
loaded_chunks: Vec::new(),
|
||||
modified: FxHashSet::default(),
|
||||
block_entities: Default::default(),
|
||||
chest_openers: FxHashMap::default(),
|
||||
|
||||
@@ -24,11 +24,15 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.shader_options": "Shader Options",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
@@ -61,6 +62,9 @@
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Shader Options...",
|
||||
"horizon.shader_options.profile": "Profile",
|
||||
"horizon.shader_options.loading": "Reading the shader pack's options...",
|
||||
"horizon.agent.copy_details": "Copy Connection Details",
|
||||
"horizon.multiplayer.join": "Join",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Makes a world that holds chunks "an older version generated": copies the
|
||||
//! overworld chunks of a rectangle from one world into another, each with a
|
||||
//! `blending_data` compound as the 1.18 data fixer leaves on a pre-1.18
|
||||
//! chunk, and drops every other chunk of the destination, so that the chunks
|
||||
//! generated around them blend with them (`Blender.of`).
|
||||
//!
|
||||
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
nbt::{Limits, Tag},
|
||||
region::{ChunkRecord, RegionFile},
|
||||
};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let source = args.next().ok_or("source world")?;
|
||||
let destination = args.next().ok_or("destination world")?;
|
||||
let rect: Vec<i32> = args
|
||||
.next()
|
||||
.ok_or("x0,z0,x1,z1")?
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()?;
|
||||
let [x0, z0, x1, z1]: [i32; 4] = rect.try_into().map_err(|_| "x0,z0,x1,z1")?;
|
||||
let overworld = |world: &str| {
|
||||
Path::new(world)
|
||||
.join("dimensions")
|
||||
.join("minecraft")
|
||||
.join("overworld")
|
||||
.join("region")
|
||||
};
|
||||
let (source, destination) = (overworld(&source), overworld(&destination));
|
||||
fs::create_dir_all(&destination)?;
|
||||
for entry in fs::read_dir(&destination)?.flatten() {
|
||||
if entry.path().extension().is_some_and(|e| e == "mca") {
|
||||
fs::remove_file(entry.path())?;
|
||||
}
|
||||
}
|
||||
let mut copied = 0;
|
||||
for z in z0..=z1 {
|
||||
for x in x0..=x1 {
|
||||
let pos = ChunkPos::new(x, z);
|
||||
let path = source.join(format!("r.{}.{}.mca", x >> 5, z >> 5));
|
||||
let Ok(mut region) = RegionFile::open(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(record) = region.read(pos)? else {
|
||||
continue;
|
||||
};
|
||||
let mut root = record.nbt(Limits::saved())?;
|
||||
if !matches!(root.tag.get("Status"), Some(Tag::String(s)) if s.to_string_lossy().ends_with("full"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Tag::Compound(chunk) = &mut root.tag {
|
||||
chunk.insert(
|
||||
"blending_data".into(),
|
||||
Tag::compound([("min_section", Tag::Int(-4)), ("max_section", Tag::Int(20))]),
|
||||
);
|
||||
}
|
||||
let out = destination.join(format!("r.{}.{}.mca", x >> 5, z >> 5));
|
||||
let mut target = RegionFile::open_writable(&out, true)?;
|
||||
target.write(
|
||||
pos,
|
||||
&ChunkRecord::from_nbt(
|
||||
&root,
|
||||
record.compression,
|
||||
record.timestamp,
|
||||
Limits::saved(),
|
||||
)?,
|
||||
)?;
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
println!("{copied} chunks copied");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1194,6 +1194,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
sea_level: -63,
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
mod_effects: None,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,6 +268,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"N defaults to 20; chat is newest first.", ["horizon-ctl messages 5"], &[opt("n", Ty::Int, "how many chat lines (default 20)")], mcp: true, local: false),
|
||||
cmd!("menu", Observe, "menu", "the open menu's slots and carried stack, with station details", "",
|
||||
["horizon-ctl menu"], &[], mcp: false, local: false),
|
||||
cmd!("narration", Observe, "narration", "what the narrator was last asked to say, oldest first, and its status",
|
||||
"recent holds the last 32 messages the narrator's status let through, said aloud or not; status is the Narrator option (off, all, chat or system) and active whether text-to-speech is there (with HORIZON_NO_AUDIO it is, silently).",
|
||||
["horizon-ctl narration"], &[], mcp: true, local: false),
|
||||
// Move
|
||||
cmd!("move", Move, "move forward|back|left|right SECONDS [--sprint] [--sneak] [--jump]", "walk in a direction relative to the view for a time",
|
||||
"Replies when done with the start and end positions.",
|
||||
|
||||
@@ -13,6 +13,7 @@ pub struct Confirm {
|
||||
pub lines: Vec<(Text, f32, f32)>,
|
||||
/// The yes and no buttons' corners.
|
||||
pub buttons: [(f32, f32); 2],
|
||||
pub focus: Option<usize>,
|
||||
}
|
||||
impl Confirm {
|
||||
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
@@ -20,6 +21,28 @@ impl Confirm {
|
||||
Self {
|
||||
lines,
|
||||
buttons: [buttons[0], buttons[1]],
|
||||
focus: None,
|
||||
}
|
||||
}
|
||||
/// Tab cycles focus between yes (0) and no (1).
|
||||
pub fn tab(&mut self, reverse: bool) {
|
||||
self.focus = match self.focus {
|
||||
Some(0) => Some(1),
|
||||
Some(1) => Some(0),
|
||||
None => Some(if reverse { 1 } else { 0 }),
|
||||
_ => Some(0),
|
||||
};
|
||||
}
|
||||
/// Left or right arrow key focuses yes or no.
|
||||
pub fn arrow(&mut self, left: bool) {
|
||||
self.focus = Some(if left { 0 } else { 1 });
|
||||
}
|
||||
/// The answer of the currently focused button.
|
||||
pub fn answer(&self) -> Option<bool> {
|
||||
match self.focus {
|
||||
Some(0) => Some(true),
|
||||
Some(1) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The answer a click at `cursor` gives: true for yes, false for no.
|
||||
@@ -56,8 +79,9 @@ impl Confirm {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
|
||||
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
for (i, ((bx, by), label)) in self.buttons.into_iter().zip(labels).enumerate() {
|
||||
let is_focused = self.focus == Some(i);
|
||||
let hover = active && (ui::hovered(cursor, bx, by, 150.0, 20.0) || is_focused);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +95,7 @@ pub struct Choice {
|
||||
/// The buttons' corners, left to right.
|
||||
pub buttons: Vec<(f32, f32)>,
|
||||
pub width: f32,
|
||||
pub focus: Option<usize>,
|
||||
}
|
||||
impl Choice {
|
||||
pub fn new(
|
||||
@@ -111,8 +136,47 @@ impl Choice {
|
||||
.map(|i| (bx + (width + 4.0) * i as f32, by))
|
||||
.collect(),
|
||||
width,
|
||||
focus: None,
|
||||
}
|
||||
}
|
||||
/// Tab cycles focus across the buttons.
|
||||
pub fn tab(&mut self, reverse: bool) {
|
||||
let count = self.buttons.len();
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
self.focus = match self.focus {
|
||||
Some(cur) => {
|
||||
if reverse {
|
||||
Some(if cur == 0 { count - 1 } else { cur - 1 })
|
||||
} else {
|
||||
Some((cur + 1) % count)
|
||||
}
|
||||
}
|
||||
None => Some(if reverse { count - 1 } else { 0 }),
|
||||
};
|
||||
}
|
||||
/// Left or right arrow key moves focus.
|
||||
pub fn arrow(&mut self, left: bool) {
|
||||
let count = self.buttons.len();
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
self.focus = match self.focus {
|
||||
Some(cur) => {
|
||||
if left {
|
||||
Some(cur.saturating_sub(1))
|
||||
} else {
|
||||
Some((cur + 1).min(count - 1))
|
||||
}
|
||||
}
|
||||
None => Some(if left { 0 } else { count - 1 }),
|
||||
};
|
||||
}
|
||||
/// The index of the currently focused button.
|
||||
pub fn pressed(&self) -> Option<usize> {
|
||||
self.focus
|
||||
}
|
||||
/// The button a click at `cursor` hits, counted from the left.
|
||||
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option<usize> {
|
||||
self.buttons
|
||||
@@ -130,8 +194,9 @@ impl Choice {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for (&(bx, by), label) in self.buttons.iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
|
||||
for (i, (&(bx, by), label)) in self.buttons.iter().zip(labels).enumerate() {
|
||||
let is_focused = self.focus == Some(i);
|
||||
let hover = ui::hovered(cursor, bx, by, self.width, 20.0) || is_focused;
|
||||
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
|
||||
}
|
||||
}
|
||||
@@ -177,4 +242,50 @@ mod tests {
|
||||
assert_eq!(two.buttons[0].0, 88.0);
|
||||
assert_eq!(two.buttons[1].0, 242.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_and_choice_keyboard_navigation() {
|
||||
let (title, message) = (Text::literal("Title"), Text::literal("Message"));
|
||||
let mut confirm = Confirm::new(&font(), (480.0, 270.0), &title, &message);
|
||||
assert_eq!(confirm.focus, None);
|
||||
assert_eq!(confirm.answer(), None);
|
||||
|
||||
confirm.tab(false);
|
||||
assert_eq!(confirm.focus, Some(0));
|
||||
assert_eq!(confirm.answer(), Some(true));
|
||||
|
||||
confirm.tab(false);
|
||||
assert_eq!(confirm.focus, Some(1));
|
||||
assert_eq!(confirm.answer(), Some(false));
|
||||
|
||||
confirm.tab(true);
|
||||
assert_eq!(confirm.focus, Some(0));
|
||||
|
||||
confirm.arrow(false);
|
||||
assert_eq!(confirm.focus, Some(1));
|
||||
confirm.arrow(true);
|
||||
assert_eq!(confirm.focus, Some(0));
|
||||
|
||||
let mut choice = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
|
||||
assert_eq!(choice.focus, None);
|
||||
assert_eq!(choice.pressed(), None);
|
||||
|
||||
choice.tab(false);
|
||||
assert_eq!(choice.focus, Some(0));
|
||||
assert_eq!(choice.pressed(), Some(0));
|
||||
|
||||
choice.tab(false);
|
||||
assert_eq!(choice.focus, Some(1));
|
||||
|
||||
choice.tab(false);
|
||||
assert_eq!(choice.focus, Some(2));
|
||||
|
||||
choice.tab(false);
|
||||
assert_eq!(choice.focus, Some(0));
|
||||
|
||||
choice.arrow(false);
|
||||
assert_eq!(choice.focus, Some(1));
|
||||
choice.arrow(true);
|
||||
assert_eq!(choice.focus, Some(0));
|
||||
}
|
||||
}
|
||||
@@ -117,8 +117,8 @@ impl Clicks {
|
||||
self.quick_crafting
|
||||
}
|
||||
|
||||
/// A press Horizon handled itself (a double click's `PICKUP_ALL`): its
|
||||
/// release clicks nothing more.
|
||||
/// A press another screen control handled (such as a book button or
|
||||
/// double click's `PICKUP_ALL`): its release clicks nothing more.
|
||||
pub fn skip_release(&mut self) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = true;
|
||||
@@ -269,6 +269,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recipe_book_click_does_not_throw_a_carried_stack_after_the_layout_shifts() {
|
||||
let mut clicks = Clicks::default();
|
||||
let button = Target::<usize>::Nothing;
|
||||
// The book button consumes the press while the player carries a stack.
|
||||
assert_eq!(
|
||||
clicks.press(&button, Button::Left, false, false, false),
|
||||
None
|
||||
);
|
||||
clicks.skip_release();
|
||||
|
||||
// Closing the book shifts the inventory before mouseReleased tests the
|
||||
// pointer again; it may now look like an outside click.
|
||||
assert_eq!(
|
||||
clicks.release(
|
||||
&Target::<usize>::Outside,
|
||||
Button::Left,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
Release::Nothing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_creative_page_release_does_not_throw_a_carried_stack() {
|
||||
let mut clicks = Clicks::default();
|
||||
|
||||
@@ -71,6 +71,12 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let components: Vec<_> = horizon_protocol::item::custom::entries()
|
||||
.map(|(id, name)| (id as u64, name.to_owned()))
|
||||
.collect();
|
||||
if !components.is_empty() {
|
||||
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
|
||||
}
|
||||
identities.insert(root.to_owned(), hash);
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,14 @@ use crate::{
|
||||
items::ItemStack,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry,
|
||||
level::portal_travel::{BorderBounds, find_free_position},
|
||||
};
|
||||
|
||||
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
|
||||
const ITEM_GRAVITY: f64 = 0.04;
|
||||
@@ -549,17 +555,55 @@ impl ExperienceOrb {
|
||||
self.health = (self.health as f32 - damage) as i32;
|
||||
self.health <= 0
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
/// The box of an orb whose `position` (the bottom of the box) is `at`.
|
||||
pub fn box_at(at: Vec3) -> Aabb {
|
||||
let h = 0.25;
|
||||
Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 0.5,
|
||||
self.position.z + h,
|
||||
),
|
||||
Vec3::new(at.x - h, at.y, at.z - h),
|
||||
Vec3::new(at.x + h, at.y + 0.5, at.z + h),
|
||||
)
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
Self::box_at(self.position)
|
||||
}
|
||||
/// `Entity.unstuckIfPossible`, which `ExperienceOrb`'s constructor runs as
|
||||
/// `if (!level.noCollision(box)) unstuckIfPossible(box.getSize())`: the free
|
||||
/// spot nearest the box's centre, searched in a cube of the box's size
|
||||
/// around it. `None` when the box is already free (the orb then keeps its
|
||||
/// exact position) or when nothing free is that close. `shape(p)` is the
|
||||
/// block's collision shape at `p`, in world coordinates.
|
||||
pub fn unstuck(
|
||||
at: Vec3,
|
||||
border: &BorderBounds,
|
||||
mut shape: impl FnMut(BlockPos) -> Option<VoxelShape>,
|
||||
) -> Option<Vec3> {
|
||||
let bounds = Self::box_at(at);
|
||||
let size = bounds.average_size();
|
||||
let lo = bounds.min_position();
|
||||
let cells = |v: f64| (v - 1.0e-7).floor() as i32 - 1..=(v + 1.0e-7).floor() as i32 + 1;
|
||||
// noCollision(box): a block shape reaching into it leaves the orb stuck.
|
||||
let mut stuck = false;
|
||||
'cells: for x in cells(lo.x) {
|
||||
for y in cells(lo.y) {
|
||||
for z in cells(lo.z) {
|
||||
if shape(BlockPos::new(x, y, z))
|
||||
.is_some_and(|s| s.boxes().iter().any(|b| b.intersects(bounds)))
|
||||
{
|
||||
stuck = true;
|
||||
break 'cells;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !stuck {
|
||||
return None;
|
||||
}
|
||||
// `AABB.ofSize(center, size, size, size)`: where the box's centre may
|
||||
// go, a cube of the box's mean size around its centre.
|
||||
let allowed = Aabb::of_size(bounds.center(), Vec3::new(size, size, size));
|
||||
find_free_position(allowed, bounds.center(), bounds.size(), border, shape)
|
||||
.map(|p| p.subtract(Vec3::new(0.0, bounds.size().y / 2.0, 0.0)))
|
||||
}
|
||||
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
|
||||
/// the same value and ids a multiple of 40 apart.
|
||||
pub fn can_merge(&self, id: u32, value: u32) -> bool {
|
||||
@@ -1751,3 +1795,54 @@ mod slime_bounce_tests {
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unstuck_tests {
|
||||
use super::*;
|
||||
|
||||
const BORDER: BorderBounds = BorderBounds {
|
||||
min_x: -1.0e6,
|
||||
min_z: -1.0e6,
|
||||
max_x: 1.0e6,
|
||||
max_z: 1.0e6,
|
||||
};
|
||||
|
||||
/// A level of full blocks at `solid`; every other cell is air.
|
||||
fn shapes(solid: &[(i32, i32, i32)]) -> impl FnMut(BlockPos) -> Option<VoxelShape> + '_ {
|
||||
move |p| {
|
||||
if solid.contains(&(p.x, p.y, p.z)) {
|
||||
VoxelShape::from_box(Aabb::block(p)).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `level.noCollision(box)` over the same cells the search reads.
|
||||
fn free(solid: &[(i32, i32, i32)], b: Aabb) -> bool {
|
||||
!solid
|
||||
.iter()
|
||||
.any(|&(x, y, z)| Aabb::block(BlockPos::new(x, y, z)).intersects(b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_orb_spawned_in_a_block_moves_to_a_free_box() {
|
||||
// One block at y 0..1 and an orb standing in its upper half, as a mob
|
||||
// killed inside one leaves it: its box overlaps the block by 0.1.
|
||||
let solid = [(0, 0, 0)];
|
||||
let at = Vec3::new(0.5, 0.9, 0.5);
|
||||
let moved = ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)).expect("the orb moves");
|
||||
// The nearest free box sits on the block's top face.
|
||||
assert_eq!(moved, Vec3::new(0.5, 1.0, 0.5));
|
||||
assert!(!ExperienceOrb::box_at(moved).intersects(Aabb::block(BlockPos::new(0, 0, 0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_orb_in_open_air_keeps_its_position() {
|
||||
let solid = [(0, 0, 0)];
|
||||
let at = Vec3::new(0.5, 2.0, 0.5);
|
||||
assert!(free(&solid, ExperienceOrb::box_at(at)));
|
||||
// Nothing to move for: the spawn sites keep the position as given.
|
||||
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
|
||||
}
|
||||
}
|
||||
@@ -383,11 +383,26 @@ pub fn mob_to_nbt(
|
||||
put(&mut tag, "angry_at", Tag::IntArray(player_uuid().to_vec()));
|
||||
}
|
||||
}
|
||||
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears.
|
||||
if m.species() == crate::mobs::Species::Mannequin
|
||||
&& let Some(profile) = &m.profile
|
||||
{
|
||||
put(&mut tag, "profile", profile_nbt(profile));
|
||||
// `Mannequin.addAdditionalSaveData`: the profile whose skin it wears, whether it
|
||||
// is `immovable`, and its description (the default is not written; none is `hide_description`).
|
||||
if m.species() == crate::mobs::Species::Mannequin {
|
||||
if let Some(profile) = &m.profile {
|
||||
put(&mut tag, "profile", profile_nbt(profile));
|
||||
}
|
||||
put(
|
||||
&mut tag,
|
||||
"immovable",
|
||||
Tag::Byte(i8::from(m.extra.immovable)),
|
||||
);
|
||||
match &m.extra.description {
|
||||
crate::mobs::MannequinDescription::Default => {}
|
||||
crate::mobs::MannequinDescription::Hidden => {
|
||||
put(&mut tag, "hide_description", Tag::Byte(1));
|
||||
}
|
||||
crate::mobs::MannequinDescription::Custom(component) => {
|
||||
put(&mut tag, "description", component.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Entity.saveWithoutId` writes the flag only when set.
|
||||
if m.name_visible {
|
||||
@@ -578,6 +593,10 @@ pub fn mob_to_nbt(
|
||||
map.insert("powered".into(), Tag::Byte(i8::from(m.extra.charged)));
|
||||
map.insert("ignited".into(), Tag::Byte(i8::from(m.extra.flag)));
|
||||
}
|
||||
// `Ocelot.addAdditionalSaveData`.
|
||||
"minecraft:ocelot" => {
|
||||
map.insert("Trusting".into(), Tag::Byte(i8::from(m.extra.trusting)));
|
||||
}
|
||||
"minecraft:cat" => {
|
||||
if let Some(owner) = m.extra.owner {
|
||||
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
@@ -1408,9 +1427,21 @@ pub fn mob_from_nbt(
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
}
|
||||
}
|
||||
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin.
|
||||
// `Mannequin.readAdditionalSaveData`: the profile comes back with the skin, then
|
||||
// `immovable`, `hide_description` and `description` (`updateDescription`).
|
||||
if m.species() == crate::mobs::Species::Mannequin {
|
||||
m.profile = tag.get("profile").and_then(profile_from_nbt);
|
||||
m.extra.immovable = flag(tag, "immovable");
|
||||
m.extra.description = if flag(tag, "hide_description") {
|
||||
crate::mobs::MannequinDescription::Hidden
|
||||
} else {
|
||||
crate::mobs::MannequinDescription::from_component(Some(
|
||||
&tag.get("description")
|
||||
.cloned()
|
||||
.or_else(|| crate::mobs::MannequinDescription::Default.component())
|
||||
.unwrap_or(Tag::End),
|
||||
))
|
||||
};
|
||||
}
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
@@ -1602,6 +1633,10 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// `Ocelot.readAdditionalSaveData`.
|
||||
if m.species() == crate::mobs::Species::Ocelot {
|
||||
m.extra.trusting = flag(tag, "Trusting");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Cat {
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
@@ -3484,6 +3519,88 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
/// `Ocelot.addAdditionalSaveData` and `readAdditionalSaveData`: `Trusting`.
|
||||
#[test]
|
||||
fn an_ocelot_keeps_its_trust() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(data.types["minecraft:ocelot"].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("Trusting")), Some(0));
|
||||
m.extra.trusting = true;
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("Trusting")), Some(1));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert!(back.extra.trusting);
|
||||
}
|
||||
|
||||
/// `Mannequin.addAdditionalSaveData` and `readAdditionalSaveData`: `immovable`, and the
|
||||
/// description (the default is not written, `hide_description` hides it, a component stays).
|
||||
#[test]
|
||||
fn a_mannequin_keeps_its_immovable_flag_and_description() {
|
||||
use crate::mobs::MannequinDescription as D;
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(data.types["minecraft:mannequin"].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let save = |m: &Mob| mob_to_nbt(m, [1, 2, 3, 4], None, None);
|
||||
// The default description is not written; it comes back.
|
||||
let tag = save(&m);
|
||||
assert!(tag.get("description").is_none() && tag.get("hide_description").is_none());
|
||||
assert_eq!(number(tag.get("immovable")), Some(0));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
(back.extra.immovable, back.extra.description),
|
||||
(false, D::Default)
|
||||
);
|
||||
// Hidden, and immovable.
|
||||
m.extra.immovable = true;
|
||||
m.extra.description = D::Hidden;
|
||||
let tag = save(&m);
|
||||
assert_eq!(number(tag.get("hide_description")), Some(1));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
(back.extra.immovable, back.extra.description),
|
||||
(true, D::Hidden)
|
||||
);
|
||||
// A component of the world's own.
|
||||
let own = Tag::compound([("text", string("Guard")), ("color", string("red"))]);
|
||||
m.extra.description = D::Custom(own.clone());
|
||||
let tag = save(&m);
|
||||
assert_eq!(tag.get("description"), Some(&own));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.description, D::Custom(own));
|
||||
}
|
||||
|
||||
/// The only lib test that registers mod effects (once per process).
|
||||
#[test]
|
||||
fn a_mob_keeps_a_mod_effect() {
|
||||
|
||||
+521
-103
File diff suppressed because it is too large.
Load diff
@@ -1780,7 +1780,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The slots held by shown advancement, recipe and pack toasts, as bits.
|
||||
/// The slots held by shown advancement, recipe, pack and narrator toasts,
|
||||
/// as bits.
|
||||
pub(super) fn toast_slots_used(&self) -> u32 {
|
||||
let packs = self.server_packs.toast_slots();
|
||||
self.adv
|
||||
@@ -1788,6 +1789,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter_map(|t| t.slot)
|
||||
.chain(packs)
|
||||
.chain(self.narrator_toast.as_ref().and_then(|t| t.slot))
|
||||
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
|
||||
}
|
||||
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
|
||||
|
||||
@@ -317,6 +317,12 @@ impl Game<'_> {
|
||||
(title, desc, longest + 3.0 + 5.0)
|
||||
}
|
||||
pub(super) fn draw_advancements(&mut self, batch: &mut HudBatch) {
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new(
|
||||
"advancements",
|
||||
super::screen_info::tr("gui.advancements", &[]),
|
||||
)
|
||||
});
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let view = self.adv_view();
|
||||
let (left, top) = window(w, h);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! like a player (walk, look, break, place, use, click buttons). Actions that
|
||||
//! take time run as a task advanced every frame and reply when they finish or
|
||||
//! time out, so `horizon-ctl break 10 64 3` returns what the block dropped.
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -23,10 +24,8 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
dpi::PhysicalPosition,
|
||||
event::{DeviceId, ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
|
||||
event_loop::ActiveEventLoop,
|
||||
keyboard::KeyCode,
|
||||
};
|
||||
|
||||
@@ -957,7 +956,7 @@ impl Game<'_> {
|
||||
/// Synthesize the pointer moving to GUI pixel (x, y) and a click there.
|
||||
pub(super) fn synth_click(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
x: f32,
|
||||
y: f32,
|
||||
button: MouseButton,
|
||||
@@ -968,7 +967,7 @@ impl Game<'_> {
|
||||
/// the others and a release at the last (one point is a click).
|
||||
fn synth_drag(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
points: &[(f32, f32)],
|
||||
button: MouseButton,
|
||||
) -> Result<()> {
|
||||
@@ -981,7 +980,7 @@ impl Game<'_> {
|
||||
let device_id = DeviceId::dummy();
|
||||
for (i, &(x, y)) in points.iter().enumerate() {
|
||||
let position = PhysicalPosition::new(f64::from(x * scale), f64::from(y * scale));
|
||||
self.window_event(
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
WindowEvent::CursorMoved {
|
||||
@@ -990,7 +989,7 @@ impl Game<'_> {
|
||||
},
|
||||
);
|
||||
if i == 0 {
|
||||
self.window_event(
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
WindowEvent::MouseInput {
|
||||
@@ -1001,7 +1000,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.window_event(
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
WindowEvent::MouseInput {
|
||||
@@ -1035,7 +1034,7 @@ impl Game<'_> {
|
||||
words: &[&str],
|
||||
line: &str,
|
||||
reply: &Sender<String>,
|
||||
event_loop: &ActiveEventLoop,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
) -> Result<Handled> {
|
||||
let name = words[0];
|
||||
let a = parse_args(&words[1..]);
|
||||
@@ -1561,7 +1560,7 @@ impl Game<'_> {
|
||||
.ok_or("no renderer yet")?;
|
||||
let device_id = DeviceId::dummy();
|
||||
let position = PhysicalPosition::new(f64::from(x * scale), f64::from(y * scale));
|
||||
self.window_event(
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
WindowEvent::CursorMoved {
|
||||
@@ -1570,7 +1569,7 @@ impl Game<'_> {
|
||||
},
|
||||
);
|
||||
for _ in 0..steps.abs() as u32 {
|
||||
self.window_event(
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
WindowEvent::MouseWheel {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! falling, `interactAt` equipment swaps by clicked height, the two-hit break
|
||||
//! of `hurtServer` (creative breaks at once), and `ArmorStandRenderer` with
|
||||
//! the default pose, the hit wobble and humanoid armor layers.
|
||||
use super::Game;
|
||||
use super::{Game, InteractTarget};
|
||||
use horizon_client::{
|
||||
entities::ArmorStand,
|
||||
items::ItemStack,
|
||||
@@ -485,12 +485,30 @@ impl Game<'_> {
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
/// `ArmorStand.interactAt`.
|
||||
pub(super) fn interact_armor_stand(&mut self) -> bool {
|
||||
let Some((i, d, point)) = self.armor_stand_hit() else {
|
||||
/// `ArmorStand.interactAt` for the stand under the crosshair, or for the
|
||||
/// stand a guest's interact packet named (its client's click point).
|
||||
pub(super) fn interact_armor_stand(&mut self, target: Option<InteractTarget>) -> bool {
|
||||
let Some((i, d, point)) = (match target {
|
||||
// interactAt gets the packet's location, measured from the stand's
|
||||
// position, so getClickedSlot reads the height the client clicked.
|
||||
Some(t) => Self::named_entity(&self.armor_stands, t.id, |a| a.id).map(|i| {
|
||||
let p = self.armor_stands[i].position;
|
||||
(
|
||||
i,
|
||||
0.0,
|
||||
[
|
||||
p.x + t.location[0],
|
||||
p.y + t.location[1],
|
||||
p.z + t.location[2],
|
||||
],
|
||||
)
|
||||
}),
|
||||
None => self.armor_stand_hit(),
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) {
|
||||
// Only a client's own pick is stopped by a block: the range check passed.
|
||||
if target.is_none() && !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
let held = self.held().cloned();
|
||||
|
||||
@@ -454,6 +454,13 @@ impl Game<'_> {
|
||||
let levels = self.beacon_levels();
|
||||
let sel = self.station.as_ref().map_or([None; 2], |s| s.beacon);
|
||||
let paid = self.station.as_ref().is_some_and(|s| s.inputs[0].is_some());
|
||||
// BeaconScreen draws no title, but has the menu's.
|
||||
super::screen_info::name_container(|| {
|
||||
self.remote_menu_title().map_or_else(
|
||||
|| self.translate("container.beacon", "Beacon"),
|
||||
|t| t.plain(),
|
||||
)
|
||||
});
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(
|
||||
atlas,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
|
||||
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
|
||||
//! saving and `AbstractBoatRenderer` with animated paddles.
|
||||
use super::{Game, Keys, entity_culling::at, view_vector};
|
||||
use super::{Game, InteractTarget, Keys, entity_culling::at, view_vector};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
@@ -938,13 +938,18 @@ impl Game<'_> {
|
||||
}
|
||||
best
|
||||
}
|
||||
/// `AbstractBoat.interact`: board the boat under the crosshair.
|
||||
pub(super) fn interact_boat(&mut self) -> bool {
|
||||
let Some((i, d)) = self.boat_hit() else {
|
||||
/// `AbstractBoat.interact`: board the boat under the crosshair, or the one
|
||||
/// a guest's interact packet named.
|
||||
pub(super) fn interact_boat(&mut self, target: Option<InteractTarget>) -> bool {
|
||||
let Some((i, d)) = (match target {
|
||||
Some(t) => Self::named_entity(&self.boats, t.id, |b| b.id).map(|i| (i, 0.0)),
|
||||
None => self.boat_hit(),
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
if !self.before_block(d) {
|
||||
// Only a client's own pick is stopped by a block: the range check passed.
|
||||
if target.is_none() && !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// AbstractBoat.interact starts with Entity.interact: the lead.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! pixels, 1024 characters, 100 pages), signing it into a written book,
|
||||
//! reading written books, and lecterns (placing a book, turning its pages
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::{Game, lan::LanHost};
|
||||
use super::{Game, lan::LanHost, screen_info};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
@@ -13,6 +13,7 @@ use horizon_client::{
|
||||
menu::{Kind, Menu},
|
||||
text::Text,
|
||||
ui,
|
||||
widgets::{self, Narratable, ScreenInfo},
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
@@ -323,6 +324,16 @@ impl Game<'_> {
|
||||
if !((0..9).contains(&p.slot) || p.slot == super::shield::OFFHAND_SLOT as i32) {
|
||||
return;
|
||||
}
|
||||
if let Some(title) = &p.title
|
||||
&& !self.chat_filter_text(
|
||||
host,
|
||||
conn,
|
||||
&[title],
|
||||
horizon_client::lan::hub::chat_filter::Place::Book,
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.write_book(p.slot as usize, p.pages, p.title)
|
||||
});
|
||||
@@ -542,7 +553,8 @@ impl Game<'_> {
|
||||
let (start, end) = rows[next];
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let slice: String = chars[start..end].iter().collect();
|
||||
let pos = start + self.gui.atlas.font.fit(&slice, x);
|
||||
// MultilineTextField.LINE_SEEK_PIXEL_BIAS: vanilla biases this seek, not the drawn caret.
|
||||
let pos = start + self.gui.atlas.font.fit(&slice, x + 2.0);
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.set_cursor(pos, shift);
|
||||
}
|
||||
@@ -1158,6 +1170,27 @@ impl Game<'_> {
|
||||
let tw = HudBatch::text_width(atlas, &text);
|
||||
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
|
||||
};
|
||||
// PageButton: a button named for where it turns, there while it can;
|
||||
// the view adds the forward one first, the editor the back one.
|
||||
let page_narratables = |back: bool, forward: bool, view: bool| {
|
||||
let back = (back, 43.0, "book.page_button.previous");
|
||||
let forward = (forward, 116.0, "book.page_button.next");
|
||||
let order = if view {
|
||||
[forward, back]
|
||||
} else {
|
||||
[back, forward]
|
||||
};
|
||||
for (show, x, key) in order {
|
||||
if show {
|
||||
widgets::narratable(|| {
|
||||
let rect = [left + x, top + 157.0, 23.0, 13.0];
|
||||
Narratable::button(screen_info::tr(key, &[]), rect, true)
|
||||
.hovered(hover(rect[0], rect[1], rect[2], rect[3]))
|
||||
.order(i32::from(view))
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
let button = |batch: &mut HudBatch, x: f32, bw: f32, label: &str, active: bool| {
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -1178,6 +1211,19 @@ impl Game<'_> {
|
||||
title_edit,
|
||||
..
|
||||
} => {
|
||||
// BookSignScreen: its title, and the title box focused.
|
||||
widgets::begin_screen(|| {
|
||||
ScreenInfo::new("book/sign", screen_info::tr("book.sign.title", &[]))
|
||||
});
|
||||
widgets::narratable(|| {
|
||||
let rect = [((w - 114.0) / 2.0).floor() - 3.0, 50.0, 114.0, 20.0];
|
||||
Narratable::edit_box(
|
||||
screen_info::tr("book.sign.titlebox", &[]),
|
||||
title_edit.value.clone(),
|
||||
rect,
|
||||
)
|
||||
.focused(title_edit.focused)
|
||||
});
|
||||
let edit = tr("book.editTitle", "Enter Book Title:");
|
||||
let tw = HudBatch::text_width(atlas, &edit);
|
||||
batch.text(
|
||||
@@ -1275,6 +1321,16 @@ impl Game<'_> {
|
||||
page_edit,
|
||||
..
|
||||
} => {
|
||||
widgets::begin_screen(|| {
|
||||
screen_info::book_edit(*page, pages.len(), &screen_info::tr)
|
||||
});
|
||||
// The page's MultiLineEditBox (no message), focused, then the
|
||||
// page buttons.
|
||||
widgets::narratable(|| {
|
||||
let rect = [((w - 114.0) / 2.0).floor() - 8.0, 28.0, 122.0, 134.0];
|
||||
Narratable::edit_box("", pages[*page].clone(), rect).focused(page_edit.focused)
|
||||
});
|
||||
page_narratables(*page > 0, true, false);
|
||||
indicator(batch, *page, pages.len());
|
||||
let text = &pages[*page];
|
||||
let lines = self.book_lines(text);
|
||||
@@ -1341,6 +1397,13 @@ impl Game<'_> {
|
||||
page,
|
||||
lectern,
|
||||
} => {
|
||||
widgets::begin_screen(|| {
|
||||
let text = pages.get(*page).map(Text::plain).unwrap_or_default();
|
||||
screen_info::book_view(*page, pages.len(), &text, &screen_info::tr)
|
||||
});
|
||||
// createMenuControls comes before createPageControlButtons
|
||||
// (forward, then back).
|
||||
page_narratables(*page > 0, *page + 1 < pages.len(), true);
|
||||
indicator(batch, *page, pages.len());
|
||||
if let Some(text) = pages.get(*page) {
|
||||
for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() {
|
||||
|
||||
@@ -1022,6 +1022,15 @@ impl Game<'_> {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
return;
|
||||
};
|
||||
// ConfirmLinkScreen: the question and the link (its warning is a
|
||||
// widget of its own, not narrated).
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
super::screen_info::confirm(
|
||||
"chat/confirm_link",
|
||||
&super::screen_info::tr("chat.link.confirm", &[]),
|
||||
url,
|
||||
)
|
||||
});
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
@@ -1123,6 +1132,17 @@ impl Game<'_> {
|
||||
let Some(mut screen) = self.ui.chat_screen.take() else {
|
||||
return;
|
||||
};
|
||||
// ChatScreen.updateNarrationState: the restricted prompt is
|
||||
// `displayMode.showRestrictedPrompt`, the abilities' restrictions.
|
||||
let in_bed = self.ui.in_bed_chat && self.sleeping.is_some();
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
super::screen_info::chat(
|
||||
in_bed,
|
||||
screen.value(),
|
||||
screen.abilities.any_restriction(),
|
||||
&super::screen_info::tr,
|
||||
)
|
||||
});
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
@@ -1143,7 +1163,7 @@ impl Game<'_> {
|
||||
let draws = screen.draw(&m, mouse, blink, background);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
|
||||
self.ui.chat_screen = Some(screen);
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
if in_bed {
|
||||
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
|
||||
let label = Text::literal(horizon_client::lang::get_or(
|
||||
"multiplayer.stopSleeping",
|
||||
@@ -1263,6 +1283,7 @@ impl Game<'_> {
|
||||
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
|
||||
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
|
||||
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
|
||||
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
|
||||
use horizon_world::food::Difficulty;
|
||||
self.world.health_rules.difficulty = match self.ui.difficulty {
|
||||
0 => Difficulty::Peaceful,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! The dedicated server's chat filter, game side (docs/chat-filter.md): what the hub refused is
|
||||
//! written to `logs/chat-filter.log` and told to horizon-server's event stream as `chat_filtered`
|
||||
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators lift mutes with
|
||||
//! `/unmute <player>` (or `unmute` on the console).
|
||||
use super::{Game, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::Action;
|
||||
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::{literal, translatable};
|
||||
use horizon_server_files::user_lists::{BanEntry, NameAndId};
|
||||
|
||||
/// The operator level `/unmute` needs, as `/ban`'s.
|
||||
const UNMUTE_LEVEL: u8 = 3;
|
||||
|
||||
/// The reason a temporary ban gives at login.
|
||||
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hub's events from a filtered text: `ChatFiltered` carried out here, the others logged.
|
||||
pub(super) fn chat_filter_events(&mut self, host: &mut LanHost, events: Vec<HostEvent>) {
|
||||
for event in events {
|
||||
match event {
|
||||
HostEvent::ChatFiltered {
|
||||
sender,
|
||||
name,
|
||||
place,
|
||||
text,
|
||||
count,
|
||||
action,
|
||||
} => self.chat_filtered(host, sender, &name, place, &text, count, action),
|
||||
event => self.lan_log_event(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One refused text: the log, the event, and a temporary ban when it came to that.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn chat_filtered(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
place: Place,
|
||||
text: &str,
|
||||
count: u32,
|
||||
action: Action,
|
||||
) {
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let summary = format!(
|
||||
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
|
||||
place.name(),
|
||||
action.name()
|
||||
);
|
||||
if self.options.dedicated.is_none() {
|
||||
log::info!("{summary}");
|
||||
return;
|
||||
}
|
||||
// The text only goes to the filter's own log, for operators: never to the console or the stream.
|
||||
dedicated::log("INFO", &summary);
|
||||
if let Some(path) = host.hub.chat_filter_log() {
|
||||
let line =
|
||||
horizon_chat_filter::log_line(now, name, uuid, place.name(), count, action, text);
|
||||
if let Err(e) = horizon_chat_filter::append_log(std::path::Path::new(path), &line) {
|
||||
dedicated::log("WARN", &format!("[chat-filter] {path}: {e}"));
|
||||
}
|
||||
}
|
||||
dedicated::events::record(
|
||||
"chat_filtered",
|
||||
name,
|
||||
uuid,
|
||||
serde_json::json!({ "place": place.name(), "hit": count, "action": action.name() }),
|
||||
);
|
||||
let Action::Banned { until } = action else {
|
||||
return;
|
||||
};
|
||||
if let Some(lists) = &self.server_lists {
|
||||
lists.lock().unwrap().bans.add(BanEntry::new(
|
||||
NameAndId {
|
||||
id: uuid,
|
||||
name: name.to_owned(),
|
||||
},
|
||||
Some("Server".into()),
|
||||
Some(until),
|
||||
Some(BAN_REASON.into()),
|
||||
));
|
||||
}
|
||||
let hours = (until - now + 3_599_999) / 3_600_000;
|
||||
dedicated::log(
|
||||
"INFO",
|
||||
&format!("[chat-filter] Banned {name} for {hours} hours: {BAN_REASON}"),
|
||||
);
|
||||
let reason = literal(&format!(
|
||||
"You're banned for {hours} hours: slurs aren't allowed here."
|
||||
));
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.uuid == uuid)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
host.hub.kick(conn, reason.clone());
|
||||
dedicated::events::record(
|
||||
"kick",
|
||||
name,
|
||||
uuid,
|
||||
serde_json::json!({ "reason": "chat filter: temporary ban" }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The message arguments of a guest's command (`/msg`, `/me`, `/say`, `/teammsg`...) through the
|
||||
/// filter: false when it refused them.
|
||||
pub(super) fn chat_filter_command(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
i: usize,
|
||||
command: &str,
|
||||
) -> bool {
|
||||
if !host.hub.chat_filter_on() {
|
||||
return true;
|
||||
}
|
||||
let permission = host.hub.guests[i].permission;
|
||||
let arguments = self.signable_arguments(permission, command);
|
||||
if arguments.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let texts: Vec<&str> = arguments.iter().map(|(_, t)| t.as_str()).collect();
|
||||
let mut events = Vec::new();
|
||||
let allowed = host
|
||||
.hub
|
||||
.chat_allowed(i, &texts, Place::Command, &mut events);
|
||||
self.chat_filter_events(host, events);
|
||||
allowed
|
||||
}
|
||||
|
||||
/// A guest's text for a sign, an anvil or a book through the filter (`conn`'s guest).
|
||||
pub(super) fn chat_filter_text(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
texts: &[&str],
|
||||
place: Place,
|
||||
) -> bool {
|
||||
if !host.hub.chat_filter_on() {
|
||||
return true;
|
||||
}
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return true;
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
let allowed = host.hub.chat_allowed(i, texts, place, &mut events);
|
||||
self.chat_filter_events(host, events);
|
||||
allowed
|
||||
}
|
||||
|
||||
/// `/unmute <player>` from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command
|
||||
/// for the others.
|
||||
pub(super) fn guest_unmute(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let (permission, by) = (g.permission, g.name.clone());
|
||||
let reply = if permission < UNMUTE_LEVEL {
|
||||
translatable("command.unknown.command", &[])
|
||||
} else if words.len() != 2 {
|
||||
literal("/unmute <player>")
|
||||
} else {
|
||||
literal(&self.unmute(host, words[1], &by))
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: reply,
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Lifts `name`'s mute, logged; what to answer.
|
||||
pub(super) fn unmute(&mut self, host: &mut LanHost, name: &str, by: &str) -> String {
|
||||
match host.hub.unmute(name) {
|
||||
Some(name) => {
|
||||
if self.options.dedicated.is_some() {
|
||||
dedicated::log("INFO", &format!("[chat-filter] {by} unmuted {name}"));
|
||||
}
|
||||
format!("Unmuted {name}")
|
||||
}
|
||||
None => format!("{name} isn't muted"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,24 @@ impl Game<'_> {
|
||||
}
|
||||
let m = self.modifiers;
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.contains(k));
|
||||
// InputQuirks.EDIT_SHORTCUT_KEY_MODIFIER: Cmd on macOS, Ctrl elsewhere.
|
||||
let edit = if cfg!(target_os = "macos") {
|
||||
m.super_key() || held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
m.control_key() || held([KeyCode::ControlLeft, KeyCode::ControlRight])
|
||||
};
|
||||
clipboard::edit_shortcut(
|
||||
clipboard::shortcut_letter(&event.logical_key, event.physical_key),
|
||||
edit,
|
||||
self.has_control_down_with_quirk(),
|
||||
m.shift_key() || held([KeyCode::ShiftLeft, KeyCode::ShiftRight]),
|
||||
m.alt_key() || held([KeyCode::AltLeft, KeyCode::AltRight]),
|
||||
)
|
||||
}
|
||||
/// `InputWithModifiers.hasControlDownWithQuirk`: the edit shortcuts'
|
||||
/// modifier (`InputQuirks.EDIT_SHORTCUT_KEY_MODIFIER`: Cmd on macOS, Ctrl
|
||||
/// elsewhere), held on the keyboard or by an agent.
|
||||
pub(super) fn has_control_down_with_quirk(&self) -> bool {
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.contains(k));
|
||||
if cfg!(target_os = "macos") {
|
||||
self.modifiers.super_key() || held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
self.modifiers.control_key() || held([KeyCode::ControlLeft, KeyCode::ControlRight])
|
||||
}
|
||||
}
|
||||
/// Whether the held keys let a key's text through to the focused field
|
||||
/// (`clipboard::types_text`): not with the edit modifier, unless it is
|
||||
/// part of AltGr.
|
||||
|
||||
@@ -169,6 +169,15 @@ impl Game<'_> {
|
||||
let Some(prompt) = &self.code_of_conduct else {
|
||||
return;
|
||||
};
|
||||
// CodeOfConductScreen: WarningScreen's narration, the title and the
|
||||
// text on the next line.
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
let title = super::screen_info::tr("multiplayer.codeOfConduct.title", &[]);
|
||||
horizon_client::widgets::ScreenInfo::new(
|
||||
"code_of_conduct",
|
||||
format!("{title}\n{}", prompt.text),
|
||||
)
|
||||
});
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.player.is_none() {
|
||||
@@ -209,6 +218,17 @@ impl Game<'_> {
|
||||
|
||||
let [x, y, cw, ch] = layout.check;
|
||||
let hover = ui::hovered(cursor, x, y, cw, ch);
|
||||
horizon_client::widgets::narratable(|| {
|
||||
horizon_client::widgets::Narratable::new(
|
||||
horizon_client::widgets::What::Checkbox {
|
||||
message: layout.label.plain(),
|
||||
selected: prompt.stop_showing,
|
||||
active: true,
|
||||
},
|
||||
[x, y, cw, ch],
|
||||
)
|
||||
.hovered(hover)
|
||||
});
|
||||
let sprite = match (prompt.stop_showing, hover) {
|
||||
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
|
||||
(true, false) => "gui/sprites/widget/checkbox_selected",
|
||||
|
||||
@@ -118,6 +118,10 @@ pub(super) struct CombatTracker {
|
||||
/// blast leaves it out (`getEntities(source, box)`). A source guessed
|
||||
/// from the mobs near the centre is still hurt: it may be a bystander.
|
||||
pub(super) explosion_source_named: bool,
|
||||
/// The blast's direct source is in water (`Explosion.getDirectSourceEntity`
|
||||
/// `.isInWater`): paintings, item frames and cushions ignore it
|
||||
/// (`BlockAttachedEntity.ignoreExplosion`).
|
||||
pub(super) explosion_in_water: bool,
|
||||
/// The blast now going off comes from a primed TNT, creeper, wither
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
|
||||
@@ -94,6 +94,18 @@ impl CommandBlockScreen {
|
||||
]);
|
||||
out
|
||||
}
|
||||
/// What button `b` narrates: the switches are `CycleButton`s that show
|
||||
/// only their value (`displayOnlyValue`), "<name>: <value> button" and
|
||||
/// the value a click moves to.
|
||||
fn narration(
|
||||
&self,
|
||||
b: Button,
|
||||
rect: [f32; 4],
|
||||
tr: super::screen_info::Tr<'_>,
|
||||
) -> horizon_client::widgets::Narratable {
|
||||
let state = (self.track_output, self.mode, self.conditional, self.auto);
|
||||
button_narration(b, state, rect, tr)
|
||||
}
|
||||
fn label(&self, b: Button) -> Text {
|
||||
let tr = |key: &str| Text::translate(key, &[]);
|
||||
match b {
|
||||
@@ -127,6 +139,48 @@ impl CommandBlockScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`CommandBlockScreen::narration`] of the switches' state (track output,
|
||||
/// mode, conditional, automatic).
|
||||
fn button_narration(
|
||||
b: Button,
|
||||
(track_output, mode, conditional_on, auto_on): (bool, Mode, bool, bool),
|
||||
rect: [f32; 4],
|
||||
tr: super::screen_info::Tr<'_>,
|
||||
) -> horizon_client::widgets::Narratable {
|
||||
use horizon_client::widgets::Narratable;
|
||||
let full_tr = tr;
|
||||
let tr = |key: &str| full_tr(key, &[]);
|
||||
let o_x = |on: bool| if on { "O" } else { "X" }.to_owned();
|
||||
let conditional = |on: bool| {
|
||||
tr(if on {
|
||||
"advMode.mode.conditional"
|
||||
} else {
|
||||
"advMode.mode.unconditional"
|
||||
})
|
||||
};
|
||||
let auto = |on: bool| {
|
||||
tr(if on {
|
||||
"advMode.mode.autoexec.bat"
|
||||
} else {
|
||||
"advMode.mode.redstoneTriggered"
|
||||
})
|
||||
};
|
||||
let (name, value, next) = match b {
|
||||
Button::Output => ("advMode.trackOutput", o_x(track_output), o_x(!track_output)),
|
||||
Button::Mode => ("advMode.mode", tr(mode.label()), tr(mode.next().label())),
|
||||
Button::Conditional => (
|
||||
"advMode.type",
|
||||
conditional(conditional_on),
|
||||
conditional(!conditional_on),
|
||||
),
|
||||
Button::Auto => ("advMode.triggering", auto(auto_on), auto(!auto_on)),
|
||||
Button::Done => return Narratable::button(tr("gui.done"), rect, true),
|
||||
Button::Cancel => return Narratable::button(tr("gui.cancel"), rect, true),
|
||||
};
|
||||
let full = full_tr("options.generic_value", &[&tr(name), &value]);
|
||||
Narratable::cycle(full, next, rect, true)
|
||||
}
|
||||
|
||||
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
|
||||
registry.state(state)
|
||||
}
|
||||
@@ -542,7 +596,9 @@ impl Game<'_> {
|
||||
};
|
||||
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
|
||||
command.auto_suggestions = self.settings.auto_suggestions;
|
||||
command.input.message = super::screen_info::tr("advMode.command", &[]);
|
||||
let mut previous = EditBox::new(0.0, previous_y(cart.is_some()), 276.0, 20.0);
|
||||
previous.message = super::screen_info::tr("advMode.previousOutput", &[]);
|
||||
previous.max_length = 32500;
|
||||
previous.text_color = UNEDITABLE;
|
||||
previous.focused = false;
|
||||
@@ -576,6 +632,16 @@ impl Game<'_> {
|
||||
self.close_command_block();
|
||||
return;
|
||||
}
|
||||
// AbstractCommandBlockEditScreen: `GameNarrator.NO_TITLE`; its usage
|
||||
// is the suggestions' while they show (`getUsageNarration`).
|
||||
let usage = s.command.suggestions_usage();
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
let info = horizon_client::widgets::ScreenInfo::new("command_block", "");
|
||||
match usage {
|
||||
Some(key) => info.usage(super::screen_info::tr(key, &[])),
|
||||
None => info,
|
||||
}
|
||||
});
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let mid = (w / 2.0).floor();
|
||||
@@ -610,7 +676,18 @@ impl Game<'_> {
|
||||
}
|
||||
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h, s.cart.is_some()) {
|
||||
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
|
||||
ui::button_as(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
bw,
|
||||
20.0,
|
||||
&s.label(b),
|
||||
true,
|
||||
hover,
|
||||
|rect| s.narration(b, rect, &super::screen_info::tr),
|
||||
);
|
||||
}
|
||||
// The command box, then its suggestions over the widgets.
|
||||
let m = Measure(&atlas.font);
|
||||
@@ -621,7 +698,10 @@ impl Game<'_> {
|
||||
s.command.resize(&m, &c, w as i32, h as i32);
|
||||
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
|
||||
let blink = s.opened.elapsed().as_millis() as u64;
|
||||
// The command box is added first.
|
||||
let mark = horizon_client::widgets::narratables_mark();
|
||||
let draws = s.command.draw(&m, mouse, blink, 0);
|
||||
horizon_client::widgets::move_narratables(mark, 0);
|
||||
horizon_client::screen_draw::paint(batch, atlas, &draws);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
@@ -958,3 +1038,60 @@ mod tests {
|
||||
assert_eq!(cart[1].2, block[4].2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod narration_tests {
|
||||
use super::*;
|
||||
use horizon_client::widgets::What;
|
||||
|
||||
fn keys(key: &str, args: &[&str]) -> String {
|
||||
if args.is_empty() {
|
||||
key.to_owned()
|
||||
} else {
|
||||
format!("{key}({})", args.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_switches_narrate_as_cycle_buttons_showing_their_value() {
|
||||
let state = (true, Mode::Redstone, false, true);
|
||||
let cycle = |b| button_narration(b, state, [0.0; 4], &keys).what;
|
||||
let expect = |message: &str, next: &str| What::Cycle {
|
||||
message: message.into(),
|
||||
next: next.into(),
|
||||
active: true,
|
||||
};
|
||||
assert_eq!(
|
||||
cycle(Button::Output),
|
||||
expect("options.generic_value(advMode.trackOutput, O)", "X")
|
||||
);
|
||||
assert_eq!(
|
||||
cycle(Button::Mode),
|
||||
expect(
|
||||
"options.generic_value(advMode.mode, advMode.mode.redstone)",
|
||||
"advMode.mode.sequence"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
cycle(Button::Conditional),
|
||||
expect(
|
||||
"options.generic_value(advMode.type, advMode.mode.unconditional)",
|
||||
"advMode.mode.conditional"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
cycle(Button::Auto),
|
||||
expect(
|
||||
"options.generic_value(advMode.triggering, advMode.mode.autoexec.bat)",
|
||||
"advMode.mode.redstoneTriggered"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
cycle(Button::Done),
|
||||
What::Button {
|
||||
message: "gui.done".into(),
|
||||
active: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2056,11 +2056,14 @@ impl Game<'_> {
|
||||
return Err(err("commands.summon.failed", &[]));
|
||||
}
|
||||
"experience_orb" => {
|
||||
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck
|
||||
// like any other orb spawn.
|
||||
let at = self.unstuck_orb(pos);
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(horizon_client::entities::ExperienceOrb {
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(pos[0], pos[1], pos[2]),
|
||||
previous: Vec3::new(pos[0], pos[1], pos[2]),
|
||||
position: Vec3::new(at[0], at[1], at[2]),
|
||||
previous: Vec3::new(at[0], at[1], at[2]),
|
||||
velocity: Vec3::new(0.0, 0.0, 0.0),
|
||||
value: 0,
|
||||
age: 0,
|
||||
|
||||
@@ -73,8 +73,10 @@ impl Game<'_> {
|
||||
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
let name = namespaced(effect.id);
|
||||
let new = !m.has_effect(name);
|
||||
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
|
||||
m.add_effect_with(name, duration, amplifier, false, particles);
|
||||
self.mod_effect_added(name, Some(id as i32), amplifier, new);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Commands from the control socket (`horizon-ctl`), run on the game thread.
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, items::ItemStack};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -7,7 +8,7 @@ use std::{
|
||||
sync::mpsc::Sender,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
const HELP: &[&str] = &[
|
||||
"status game, player, streaming and renderer state",
|
||||
@@ -36,6 +37,7 @@ const HELP: &[&str] = &[
|
||||
"chat-log [N] the newest N chat messages (default 10)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
|
||||
"stats [reset] frame-time summary since the last reset",
|
||||
"narration the narrator's status and the last messages it was asked to say",
|
||||
"bench SECONDS reset stats, wait, then reply with the summary",
|
||||
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
|
||||
"open WORLD [SEED] [creative] open or create a world from the title screen",
|
||||
@@ -107,7 +109,7 @@ impl Game<'_> {
|
||||
let _ = flight.reply.send(summary.to_string());
|
||||
}
|
||||
}
|
||||
pub(super) fn poll_control(&mut self, event_loop: &ActiveEventLoop) {
|
||||
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
if let Some((deadline, reply)) = self.pending_bench.take() {
|
||||
if Instant::now() >= deadline {
|
||||
let _ = reply.send(self.frame_stats.summary().to_string());
|
||||
@@ -116,6 +118,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
horizon_client::widgets::end_frame(self.frames);
|
||||
self.narrate_screen();
|
||||
self.advance_agent();
|
||||
for request in self.ui_wait.ready() {
|
||||
self.answer(request, event_loop);
|
||||
@@ -137,7 +140,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
|
||||
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &dyn EventLoopCtl) {
|
||||
let reply = match self.control_command(&request.line, &request.reply, event_loop) {
|
||||
Ok(Some(value)) => value,
|
||||
// Deferred: the command keeps its own reply channel.
|
||||
@@ -151,7 +154,7 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
line: &str,
|
||||
reply: &Sender<String>,
|
||||
event_loop: &ActiveEventLoop,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
) -> Result<Option<Value>> {
|
||||
// A tap's or an up's release must run even when a dialog, the sign
|
||||
// editor or a screen consumes the key, there is no world, or a
|
||||
@@ -168,7 +171,7 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
line: &str,
|
||||
reply: &Sender<String>,
|
||||
event_loop: &ActiveEventLoop,
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
) -> Result<Option<Value>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
if self.pending_save.is_some() && words.first() != Some(&"quit") {
|
||||
@@ -317,6 +320,8 @@ impl Game<'_> {
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// KeyboardHandler.keyPress: the open screen's narration hears it.
|
||||
super::narrator::narrate_key(&mut self.narration, key, arg(2)? != "up");
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
if arg(2)? != "up" {
|
||||
@@ -667,6 +672,8 @@ impl Game<'_> {
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// Typed keys: the open screen's narration hears them.
|
||||
self.narration.keyboard_action();
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
@@ -756,6 +763,15 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| self.ui.chat.recent_lines(n));
|
||||
Ok(Some(json!({ "chat": lines })))
|
||||
}
|
||||
"narration" => {
|
||||
// NarratorStatus's options.txt names: off, all, chat, system.
|
||||
let status = horizon_narrator::status().key().rsplit('.').next();
|
||||
Ok(Some(json!({
|
||||
"status": status,
|
||||
"active": horizon_narrator::is_active(),
|
||||
"recent": horizon_narrator::recent(),
|
||||
})))
|
||||
}
|
||||
"stats" => {
|
||||
let summary = self.frame_stats.summary();
|
||||
if words.get(1) == Some(&"reset") {
|
||||
|
||||
@@ -91,6 +91,8 @@ impl Game<'_> {
|
||||
key(&format!("key.hotbar.{}", index + 1)),
|
||||
],
|
||||
);
|
||||
// handleHotbarLoadOrSave: the overlay, said at once.
|
||||
self.say_system_now(&message);
|
||||
self.titles.set_overlay(message, false);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
use super::{Dim, Game, dispenser, view_vector};
|
||||
use super::{Dim, Game, InteractTarget, dispenser, view_vector};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -227,6 +227,20 @@ fn flame_geometry(
|
||||
pub(super) fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
|
||||
m.seen(player)
|
||||
}
|
||||
/// Whether a mob a packet named may take the click: `mob_hit` skips a mob in
|
||||
/// its death animation, the one the player rides (it shares the player's root
|
||||
/// vehicle) and the camera entity the pick starts from, so a packet that names
|
||||
/// one of those does nothing rather than reaching the feed, shear, leash or
|
||||
/// name paths the pick path refuses.
|
||||
pub(super) fn named_mob_pickable(
|
||||
alive: bool,
|
||||
id: u32,
|
||||
riding: Option<u32>,
|
||||
camera: Option<u32>,
|
||||
) -> bool {
|
||||
alive && Some(id) != riding && Some(id) != camera
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn player_view(&self) -> Option<PlayerView> {
|
||||
let p = self.player.as_ref()?;
|
||||
@@ -459,6 +473,107 @@ impl Game<'_> {
|
||||
self.lan_entity_event(id as i32, if body { 65 } else { 49 }, at);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
|
||||
/// `MobEvent::ArmorHurt` reported: every worn piece that wears when its
|
||||
/// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking
|
||||
/// applied, and at its maximum the slot empties and the equipment-break
|
||||
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52
|
||||
/// for the armour, 65 for the body). A fire hit leaves a fire-resistant
|
||||
/// piece alone (`ItemStack.canBeHurtBy`).
|
||||
fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) {
|
||||
use horizon_client::mobs::drops;
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
if damage <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let body_armour = drops::head_is_body(m.species());
|
||||
let amount = ((damage / 4.0) as u32).max(1);
|
||||
let name = damage_type(kind, false);
|
||||
let fire = self
|
||||
.motion
|
||||
.health
|
||||
.damage
|
||||
.id(name)
|
||||
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
|
||||
// A piece's maximum, or none when it may not lose durability at all.
|
||||
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
|
||||
let info = me.res.items().get(&stack.item)?;
|
||||
if !info.equip_damage_on_hurt
|
||||
|| info.max_damage == 0
|
||||
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|
||||
|| (fire && me.res.items().resists_fire(stack, name))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(info.max_damage)
|
||||
}
|
||||
let mut broke: Vec<i8> = Vec::new();
|
||||
// The four armour slots, then the body slot (`extra.head`).
|
||||
let event_of = horizon_client::mobs::item_break::equipment_break_event;
|
||||
for (index, slot) in [
|
||||
(0usize, EquipmentSlot::Head),
|
||||
(1, EquipmentSlot::Chest),
|
||||
(2, EquipmentSlot::Legs),
|
||||
(3, EquipmentSlot::Feet),
|
||||
] {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = m.extra.armor[index].clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(max) = wearable(self, &stack, fire, name) else {
|
||||
continue;
|
||||
};
|
||||
let loss = self.durability_loss(&stack, amount);
|
||||
if loss == 0 {
|
||||
continue;
|
||||
}
|
||||
let lost = stack.damage + loss;
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if lost >= max {
|
||||
m.extra.armor[index] = None;
|
||||
broke.push(event_of(slot));
|
||||
} else if let Some(worn) = m.extra.armor[index].as_mut() {
|
||||
worn.damage = lost;
|
||||
}
|
||||
}
|
||||
if body_armour {
|
||||
let body = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.item_by_slot(EquipmentSlot::Body));
|
||||
if let Some(body) = body
|
||||
&& let Some(max) = wearable(self, &body, fire, name)
|
||||
{
|
||||
let loss = self.durability_loss(&body, amount);
|
||||
if loss > 0 {
|
||||
let lost = body.damage + loss;
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if lost >= max {
|
||||
m.extra.head = None;
|
||||
m.extra.body_damage = 0;
|
||||
broke.push(event_of(EquipmentSlot::Body));
|
||||
} else {
|
||||
m.extra.body_damage = lost as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for event in broke {
|
||||
// LivingEntity.onEquippedItemBroken through the level's broadcast.
|
||||
self.lan_entity_event(id as i32, event, at);
|
||||
}
|
||||
}
|
||||
/// What a converted frog gets once its tadpole has grown up: the spawn
|
||||
/// finalization of a conversion, then persistence.
|
||||
fn finalize_converted(&mut self, id: u32) {
|
||||
@@ -661,6 +776,11 @@ impl Game<'_> {
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.items()),
|
||||
lid_openers: &self.lid_openers,
|
||||
mod_effects: if self.remote.is_none() {
|
||||
Some(self)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
self.level_players()
|
||||
@@ -1496,6 +1616,7 @@ impl Game<'_> {
|
||||
self.drown_particles(at, v);
|
||||
}
|
||||
MobEvent::SunProtectionWear { mob } => self.wear_sun_protection(mob),
|
||||
MobEvent::ArmorHurt { mob, amount, kind } => self.hurt_mob_armor(mob, amount, kind),
|
||||
MobEvent::EntityEvent { mob, event, at } => {
|
||||
self.lan_entity_event(mob as i32, event, at)
|
||||
}
|
||||
@@ -1634,8 +1755,14 @@ impl Game<'_> {
|
||||
owner,
|
||||
} => self.add_fangs(at, yaw, delay, owner),
|
||||
MobEvent::Shoot(..) => {}
|
||||
MobEvent::ExplosionSource { mob, charged, name } => {
|
||||
MobEvent::ExplosionSource {
|
||||
mob,
|
||||
charged,
|
||||
name,
|
||||
in_water,
|
||||
} => {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
self.combat.explosion_in_water = in_water;
|
||||
// The creeper causes the blast's damage, but it is out of
|
||||
// the mob list already: its name comes with the event.
|
||||
self.combat.exploded.push((mob, name));
|
||||
@@ -1688,6 +1815,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
self.combat.explosion_source_named = false;
|
||||
self.combat.explosion_in_water = false;
|
||||
self.combat.explosion_cause = None;
|
||||
result?
|
||||
}
|
||||
@@ -4156,12 +4284,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.combat.explosion_owner = owner;
|
||||
self.combat.explosion_in_water = p.touching_water;
|
||||
// The blast's source names the owner, so a dragon it kills
|
||||
// remembers the player who shot it.
|
||||
self.set_blast_by_player(p.owner == Some(Who::Player));
|
||||
let result = self.explode(atp, *power as f32);
|
||||
self.set_blast_by_player(false);
|
||||
self.combat.explosion_owner = None;
|
||||
self.combat.explosion_in_water = false;
|
||||
result?;
|
||||
}
|
||||
(
|
||||
@@ -4398,12 +4528,28 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// Right click on a mob (Mob.interact / mobInteract). Returns true when consumed.
|
||||
pub(super) fn interact_mob(&mut self) -> Result<bool> {
|
||||
let Some((index, distance)) = self.mob_hit() else {
|
||||
/// Right click on a mob (Mob.interact / mobInteract), on the mob under the
|
||||
/// crosshair or on the one a guest's interact packet named: returns true
|
||||
/// when consumed.
|
||||
pub(super) fn interact_mob(&mut self, target: Option<InteractTarget>) -> Result<bool> {
|
||||
let Some((index, distance)) = (match target {
|
||||
// The id the packet named, under the filters the crosshair pick
|
||||
// applies (`mob_hit`): a mob in its death animation is not clicked
|
||||
// by either path, so naming one in a packet must not reach the
|
||||
// feed, shear, leash or name paths either.
|
||||
Some(t) => Self::named_entity(&self.mobs, t.id, |m| m.id)
|
||||
.filter(|&i| {
|
||||
let m = &self.mobs[i];
|
||||
named_mob_pickable(m.alive(), m.id, self.riding, self.remote_camera_id())
|
||||
})
|
||||
.map(|i| (i, 0.0)),
|
||||
None => self.mob_hit(),
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if let Some(hit) = self.pick_unfiltered()
|
||||
// Only a client's own pick is stopped by a block: the range check passed.
|
||||
if target.is_none()
|
||||
&& let Some(hit) = self.pick_unfiltered()
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
let d = ((hit.point[0] - eye[0]).powi(2)
|
||||
@@ -4568,6 +4714,10 @@ impl Game<'_> {
|
||||
.get("minecraft:parrot_poisonous_food")
|
||||
.is_some_and(|t| t.contains(&*item));
|
||||
let player_at = self.eye_position();
|
||||
let player_feet = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
let acting = self.acting_player_uuid();
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
|
||||
@@ -4589,6 +4739,9 @@ impl Game<'_> {
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let mut handled = true;
|
||||
let mut drops: Vec<(String, u32)> = Vec::new();
|
||||
// `Sheep.shear`'s drops add a spread on top of the ItemEntity's own
|
||||
// motion; `MushroomCow.shear`'s do not.
|
||||
let mut wool = false;
|
||||
let is_food = m.kind.food.contains(&item);
|
||||
let baby_turtle = species == Species::Turtle && m.is_baby();
|
||||
// MushroomCow.getEffectsFromItemStack: the item's block supplies them.
|
||||
@@ -4601,6 +4754,8 @@ impl Game<'_> {
|
||||
// `Some(true)` for the four spell particles, `Some(false)` for the two
|
||||
// smoke puffs of a cow that already holds effects.
|
||||
let mut stew_particles: Option<bool> = None;
|
||||
// `Ocelot.mobInteract`: whether the fish won its trust.
|
||||
let mut ocelot_trust: Option<bool> = None;
|
||||
match species {
|
||||
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
|
||||
give = Some("minecraft:milk_bucket");
|
||||
@@ -4680,6 +4835,7 @@ impl Game<'_> {
|
||||
&mut self.world.random,
|
||||
);
|
||||
drops.extend(stacks.into_iter().map(|s| (s.item.to_string(), s.count)));
|
||||
wool = true;
|
||||
sound = Some("entity.sheep.shear");
|
||||
self.damage_held_tool();
|
||||
}
|
||||
@@ -5026,6 +5182,30 @@ impl Game<'_> {
|
||||
}
|
||||
consume = true;
|
||||
}
|
||||
// Ocelot.mobInteract: raw fish from a hand within 3 blocks, while it comes for the
|
||||
// food (`temptGoal.isRunning`), is used up; one time in three it trusts the player
|
||||
// (entity event 41, hearts), otherwise smoke (event 40). Anything else is
|
||||
// `Animal.mobInteract`.
|
||||
Species::Ocelot
|
||||
if is_food
|
||||
&& !m.extra.trusting
|
||||
&& m.ai.goals.is_running("OcelotTemptGoal")
|
||||
&& player_feet.is_some_and(|p| {
|
||||
let d = [
|
||||
p[0] - m.position.x,
|
||||
p[1] - m.position.y,
|
||||
p[2] - m.position.z,
|
||||
];
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < 9.0
|
||||
}) =>
|
||||
{
|
||||
consume = true;
|
||||
let trusts = m.random.next_int_bound(3).unwrap_or(1) == 0;
|
||||
if trusts {
|
||||
m.extra.trusting = true;
|
||||
}
|
||||
ocelot_trust = Some(trusts);
|
||||
}
|
||||
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
|
||||
_ if is_food
|
||||
&& m.kind.flags.animal
|
||||
@@ -5061,6 +5241,10 @@ impl Game<'_> {
|
||||
if !handled {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(trusts) = ocelot_trust {
|
||||
// `spawnTrustingParticles` on the clients: entity event 41 (hearts) or 40 (smoke).
|
||||
self.mob_event_particles(self.mobs[index].id, if trusts { 41 } else { 40 });
|
||||
}
|
||||
if let Some(fresh) = stew_particles {
|
||||
// `SpellParticleOption.create(EFFECT, -1, 1)` is the plain white
|
||||
// effect particle; the smoke is the already-fed answer.
|
||||
@@ -5147,11 +5331,21 @@ impl Game<'_> {
|
||||
}
|
||||
for (item, count) in drops {
|
||||
for _ in 0..count {
|
||||
let v = [
|
||||
(self.world.random.next_float() - self.world.random.next_float()) as f64 * 0.1,
|
||||
self.world.random.next_float() as f64 * 0.05,
|
||||
(self.world.random.next_float() - self.world.random.next_float()) as f64 * 0.1,
|
||||
];
|
||||
// `spawnAtLocation(stack, 1.0)`: an ItemEntity a block above
|
||||
// the feet that starts with the constructor's own motion, and
|
||||
// `setDefaultPickUpDelay`. Sheep.shear adds its spread on top;
|
||||
// MushroomCow.shear builds the entity by hand instead, so its
|
||||
// mushrooms keep the constructor's motion and no pickup delay.
|
||||
let mut v = dispenser::new_item_motion();
|
||||
if wool {
|
||||
v[0] += (self.world.random.next_float() - self.world.random.next_float())
|
||||
as f64
|
||||
* 0.1;
|
||||
v[1] += self.world.random.next_float() as f64 * 0.05;
|
||||
v[2] += (self.world.random.next_float() - self.world.random.next_float())
|
||||
as f64
|
||||
* 0.1;
|
||||
}
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item: item.as_str().into(),
|
||||
@@ -5159,9 +5353,9 @@ impl Game<'_> {
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
[center[0], center[1] + 0.5, center[2]],
|
||||
[feet[0], feet[1] + 1.0, feet[2]],
|
||||
v,
|
||||
10,
|
||||
if wool { 10 } else { 0 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5617,9 +5811,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if is_player {
|
||||
// PlayerRenderer's CapeLayer, WingsLayer and SpinAttackEffectLayer.
|
||||
// Only the cape hides with the body; WingsLayer and
|
||||
// SpinAttackEffectLayer have no `isInvisible` check.
|
||||
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
|
||||
// and SpinAttackEffectLayer. Only the cape hides with the body;
|
||||
// the others have no `isInvisible` check.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
if !layers_only {
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
@@ -5632,6 +5826,7 @@ impl Game<'_> {
|
||||
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
@@ -6369,6 +6564,7 @@ mod pearl_tests {
|
||||
sea_level: 63,
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
mod_effects: None,
|
||||
};
|
||||
let pw = ProjWorld {
|
||||
w: &w,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
|
||||
//! only an entity whose `Pos` is the click point's height. Placement uses
|
||||
//! outline bounds, collision shapes and suffocation predicates.
|
||||
use super::{Game, Hit};
|
||||
use super::{Game, Hit, InteractTarget};
|
||||
use horizon_client::entities::{Cushion, dye_index};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
@@ -307,12 +307,19 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.interact`: sit down unless sneaking or already occupied.
|
||||
pub(super) fn interact_cushion(&mut self) -> bool {
|
||||
let Some((i, d, _)) = self.cushion_hit() else {
|
||||
/// `Cushion.interact`: sit down unless sneaking or already occupied. The
|
||||
/// cushion under the crosshair, or the one a guest's interact packet named.
|
||||
pub(super) fn interact_cushion(&mut self, target: Option<InteractTarget>) -> bool {
|
||||
let Some((i, d, _)) = (match target {
|
||||
Some(t) => {
|
||||
Self::named_entity(&self.cushions, t.id, |c| c.id).map(|i| (i, 0.0, [0.0; 3]))
|
||||
}
|
||||
None => self.cushion_hit(),
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) {
|
||||
// Only a client's own pick is stopped by a block: the range check passed.
|
||||
if target.is_none() && !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// isSecondaryUseActive.
|
||||
|
||||
@@ -40,6 +40,7 @@ impl DebugHost for Host<'_, '_> {
|
||||
let g = &mut *self.game;
|
||||
if without_menu && g.screen == Screen::Playing {
|
||||
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
|
||||
g.pause_focus = None;
|
||||
g.screen = Screen::Paused;
|
||||
g.capture(false);
|
||||
g.mining = None;
|
||||
@@ -515,13 +516,18 @@ impl Game<'_> {
|
||||
let prefix = Text::translate("debug.prefix", &[])
|
||||
.with_color(color)
|
||||
.bold();
|
||||
self.system_message(
|
||||
self.show_debug_chat(
|
||||
Text::empty()
|
||||
.append(prefix)
|
||||
.append(Text::literal(" "))
|
||||
.append(message),
|
||||
);
|
||||
}
|
||||
/// `Minecraft.showDebugChat`: a client system message the narrator says.
|
||||
fn show_debug_chat(&mut self, message: Text) {
|
||||
self.say_system_queued(&message);
|
||||
self.system_message(message);
|
||||
}
|
||||
fn debug_feedback(&mut self, message: Text) {
|
||||
self.debug_decorated(YELLOW, message);
|
||||
}
|
||||
@@ -662,7 +668,7 @@ impl Game<'_> {
|
||||
DebugAction::DumpVersion => {
|
||||
self.debug_translated("debug.version.header");
|
||||
for line in self.version_lines() {
|
||||
self.system_message(line);
|
||||
self.show_debug_chat(line);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ use std::{
|
||||
};
|
||||
|
||||
pub(super) mod events;
|
||||
mod management;
|
||||
mod report;
|
||||
mod status;
|
||||
|
||||
@@ -195,6 +196,37 @@ fn load_anticheat() -> horizon_anticheat::Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[chat_filter]` (docs/chat-filter.md), like `[anticheat]`: on by default, off (with an
|
||||
/// error logged) when the section is wrong. The mutes are kept beside server.properties.
|
||||
fn load_chat_filter() -> Option<horizon_chat_filter::Guard> {
|
||||
let text = std::fs::read_to_string("server.toml").unwrap_or_default();
|
||||
let config = match horizon_chat_filter::Config::from_server_toml(&text) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
log(
|
||||
"ERROR",
|
||||
&format!("server.toml: {e}; the chat filter stays off"),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !config.enabled {
|
||||
return None;
|
||||
}
|
||||
let (guard, problem) = horizon_chat_filter::Guard::load(config, std::path::Path::new("."));
|
||||
if let Some(problem) = problem {
|
||||
log("WARN", &problem);
|
||||
}
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
|
||||
guard.filter.len()
|
||||
),
|
||||
);
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
/// The secret for Velocity's player information forwarding, from `forwarding.secret` beside server.properties.
|
||||
/// No file means no forwarding. A file that is empty or can't be read stops the start, because an empty secret
|
||||
/// would let anyone sign a login, and a typo must not leave a server open that was meant to be behind a proxy.
|
||||
@@ -470,6 +502,8 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
|
||||
/// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it).
|
||||
const FAR_PROPERTY: &str = "horizon-max-view-distance";
|
||||
/// server.properties' line for `server.toml`'s `admin_feedback_to_ops` (horizon-server writes it).
|
||||
const ADMIN_FEEDBACK_PROPERTY: &str = "horizon-admin-feedback-to-ops";
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
fn parse_identifier(text: &str) -> Option<String> {
|
||||
@@ -610,6 +644,7 @@ impl Game<'_> {
|
||||
pub(super) fn run_dedicated(&mut self) -> Result<()> {
|
||||
let result = self.run_dedicated_inner();
|
||||
if let Err(error) = &result {
|
||||
self.native_players_leaving();
|
||||
self.native_lifecycle_world_failed(&error.to_string());
|
||||
self.native_lifecycle_close_failed(&error.to_string());
|
||||
}
|
||||
@@ -889,6 +924,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
let anticheat = load_anticheat();
|
||||
let chat_filter = load_chat_filter();
|
||||
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
|
||||
let far = props
|
||||
.raw
|
||||
@@ -906,9 +942,15 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
host.hub.set_anticheat(anticheat);
|
||||
host.hub.set_chat_filter(chat_filter);
|
||||
host.hub.spawn_protection = props.spawn_protection;
|
||||
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
|
||||
host.hub.command_spam_threshold = props.command_spam_threshold_seconds.wrapping_mul(20);
|
||||
// Off: an operator's commands reach no other player, only the log.
|
||||
host.hub.admin_feedback_to_ops = props
|
||||
.raw
|
||||
.get(ADMIN_FEEDBACK_PROPERTY)
|
||||
.is_none_or(|v| v.trim() != "false");
|
||||
}
|
||||
log(
|
||||
"INFO",
|
||||
@@ -1336,6 +1378,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"whitelist" => self.console_whitelist(state, &rest),
|
||||
// The chat filter's (game/chat_filter.rs), on servers that filter.
|
||||
"unmute"
|
||||
if rest.len() == 1 && self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
|
||||
{
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let reply = self.unmute(&mut host, rest[0], "Server");
|
||||
self.lan = Some(host);
|
||||
self.console_reply(lit(&reply));
|
||||
}
|
||||
}
|
||||
"ban" if !rest.is_empty() => {
|
||||
let Some(user) = self.resolve(rest[0]) else {
|
||||
return;
|
||||
@@ -1716,21 +1768,15 @@ impl Game<'_> {
|
||||
// MinecraftServer.stopServer's start: everyone is still here.
|
||||
self.native_server_stopping(true);
|
||||
log("INFO", "Stopping server");
|
||||
self.native_players_leaving();
|
||||
log("INFO", "Saving players");
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.flush_native_net(&mut host);
|
||||
for g in &host.hub.guests {
|
||||
// The vehicle the guest rides, for the file that names it.
|
||||
let vehicle = self.root_vehicle_uuid(g.vehicle);
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
|
||||
}
|
||||
// PlayerList.removeAll: each player's file, with the vehicle it
|
||||
// alone rides, then its records.
|
||||
self.save_leaving_guests(&mut host);
|
||||
self.lan = Some(host);
|
||||
}
|
||||
if let Some(h) = self.lan.as_ref() {
|
||||
for g in &h.hub.guests {
|
||||
self.save_guest_records(h, g);
|
||||
}
|
||||
}
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
let conns: Vec<u32> = h.hub.guests.iter().map(|g| g.conn).collect();
|
||||
for conn in conns {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! The management protocol's list edits, which `horizon-server` forwards on the control endpoint as
|
||||
//! `management <connection> {"method", "entries"}` (its management_methods.rs): vanilla 26.3's
|
||||
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
|
||||
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
|
||||
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
|
||||
use super::{ServerState, log};
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
server::{
|
||||
profiles,
|
||||
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
|
||||
},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_protocol::text::translatable;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A `UserBan`: the player, the reason, the source, the end.
|
||||
type Ban = BanEntry<NameAndId>;
|
||||
/// An `IpBan`: the address, the reason, the source, the end.
|
||||
type IpBan = BanEntry<String>;
|
||||
|
||||
/// `OperatorService.Op`: the player and the level and bypass asked for, which a stored operator always has.
|
||||
#[derive(PartialEq)]
|
||||
struct Op {
|
||||
user: NameAndId,
|
||||
level: Option<i32>,
|
||||
bypass: Option<bool>,
|
||||
}
|
||||
|
||||
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
|
||||
fn shown(user: &NameAndId) -> String {
|
||||
format!(
|
||||
"NameAndId[id={}, name={}]",
|
||||
horizon_protocol::crypto::uuid_string(user.id),
|
||||
user.name
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn management(&mut self, state: &mut ServerState, request: &str) -> Value {
|
||||
let (connection, body) = request.split_once(' ').unwrap_or((request, "{}"));
|
||||
let rpc =
|
||||
|message: String| log("INFO", &format!("RPC Connection #{connection}: {message}"));
|
||||
let Ok(body) = serde_json::from_str::<Value>(body) else {
|
||||
return json!({ "error": "a management request is a connection number and a JSON object" });
|
||||
};
|
||||
let entries = body["entries"].as_array().cloned().unwrap_or_default();
|
||||
let lists = state.lists.clone();
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
"allowlist/add" => {
|
||||
for user in entries.iter().filter_map(|e| self.management_user(e)) {
|
||||
rpc(format!("Add player '{}' to allowlist", shown(&user)));
|
||||
lists.lock().unwrap().white.add(WhiteEntry { user });
|
||||
}
|
||||
}
|
||||
"allowlist/remove" => {
|
||||
for user in entries.iter().filter_map(|e| self.management_user(e)) {
|
||||
rpc(format!("Remove player '{}' from allowlist", shown(&user)));
|
||||
lists.lock().unwrap().white.remove(&user);
|
||||
}
|
||||
rpc("Kick unlisted players".into());
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
"allowlist/set" => {
|
||||
let wanted: Vec<NameAndId> = entries
|
||||
.iter()
|
||||
.filter_map(|e| self.management_user(e))
|
||||
.collect();
|
||||
let current: Vec<NameAndId> = lists
|
||||
.lock()
|
||||
.unwrap()
|
||||
.white
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| e.user.clone())
|
||||
.collect();
|
||||
for user in current.iter().filter(|u| !wanted.contains(u)) {
|
||||
rpc(format!("Remove player '{}' from allowlist", shown(user)));
|
||||
lists.lock().unwrap().white.remove(user);
|
||||
}
|
||||
for user in wanted.iter().filter(|u| !current.contains(u)) {
|
||||
rpc(format!("Add player '{}' to allowlist", shown(user)));
|
||||
lists
|
||||
.lock()
|
||||
.unwrap()
|
||||
.white
|
||||
.add(WhiteEntry { user: user.clone() });
|
||||
}
|
||||
rpc("Kick unlisted players".into());
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
"allowlist/clear" => {
|
||||
rpc("Clear allowlist".into());
|
||||
lists.lock().unwrap().white.clear();
|
||||
}
|
||||
"bans/add" => {
|
||||
let bans: Vec<Ban> = entries
|
||||
.iter()
|
||||
.filter_map(|e| self.management_ban(e))
|
||||
.collect();
|
||||
for ban in bans {
|
||||
self.management_add_ban(&rpc, state, ban);
|
||||
}
|
||||
}
|
||||
"bans/remove" => {
|
||||
for user in entries.iter().filter_map(|e| self.management_user(e)) {
|
||||
rpc(format!("Remove player '{}' from banlist", shown(&user)));
|
||||
lists.lock().unwrap().bans.remove(&user);
|
||||
}
|
||||
}
|
||||
"bans/set" => {
|
||||
let wanted: Vec<Ban> = entries
|
||||
.iter()
|
||||
.filter_map(|e| self.management_ban(e))
|
||||
.collect();
|
||||
let current: Vec<Ban> = lists
|
||||
.lock()
|
||||
.unwrap()
|
||||
.bans
|
||||
.entries()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
for ban in current.iter().filter(|b| !wanted.contains(b)) {
|
||||
rpc(format!("Remove player '{}' from banlist", shown(&ban.user)));
|
||||
lists.lock().unwrap().bans.remove(&ban.user);
|
||||
}
|
||||
for ban in wanted.into_iter().filter(|b| !current.contains(b)) {
|
||||
self.management_add_ban(&rpc, state, ban);
|
||||
}
|
||||
}
|
||||
// MinecraftBanListServiceImpl.clearUserBans logs nothing.
|
||||
"bans/clear" => lists.lock().unwrap().bans.clear(),
|
||||
"ip_bans/add" => {
|
||||
for entry in &entries {
|
||||
// An address, else the address of the player named, if online.
|
||||
let ip = entry["ip"]
|
||||
.as_str()
|
||||
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok());
|
||||
let ip = ip.map(str::to_owned).or_else(|| {
|
||||
let player = entry.get("player")?;
|
||||
self.management_online(player).map(|(_, ip)| ip)
|
||||
});
|
||||
if let Some(ip) = ip {
|
||||
self.management_ip_ban(&rpc, state, management_ip_ban(entry, ip));
|
||||
}
|
||||
}
|
||||
}
|
||||
"ip_bans/remove" => {
|
||||
for ip in entries.iter().filter_map(|e| e["ip"].as_str()) {
|
||||
rpc(format!("Remove ip '{ip}' from ban list"));
|
||||
lists.lock().unwrap().ip_bans.remove(&ip.to_owned());
|
||||
}
|
||||
}
|
||||
"ip_bans/set" => {
|
||||
let wanted: Vec<IpBan> = entries
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let ip = e["ip"]
|
||||
.as_str()
|
||||
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok())?;
|
||||
Some(management_ip_ban(e, ip.to_owned()))
|
||||
})
|
||||
.collect();
|
||||
let current: Vec<IpBan> = lists
|
||||
.lock()
|
||||
.unwrap()
|
||||
.ip_bans
|
||||
.entries()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
for ban in current.iter().filter(|b| !wanted.contains(b)) {
|
||||
rpc(format!("Remove ip '{}' from ban list", ban.user));
|
||||
lists.lock().unwrap().ip_bans.remove(&ban.user);
|
||||
}
|
||||
for ban in wanted.into_iter().filter(|b| !current.contains(b)) {
|
||||
self.management_ip_ban(&rpc, state, ban);
|
||||
}
|
||||
}
|
||||
"ip_bans/clear" => {
|
||||
rpc("Clear ip ban list".into());
|
||||
lists.lock().unwrap().ip_bans.clear();
|
||||
}
|
||||
"operators/add" => {
|
||||
let ops: Vec<Op> = entries
|
||||
.iter()
|
||||
.filter_map(|e| self.management_op(e))
|
||||
.collect();
|
||||
for op in ops {
|
||||
self.management_add_op(&rpc, state, op);
|
||||
}
|
||||
}
|
||||
"operators/remove" => {
|
||||
for user in entries.iter().filter_map(|e| self.management_user(e)) {
|
||||
rpc(format!("Deop '{}'", shown(&user)));
|
||||
lists.lock().unwrap().ops.remove(&user);
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
}
|
||||
"operators/set" => {
|
||||
let wanted: Vec<Op> = entries
|
||||
.iter()
|
||||
.filter_map(|e| self.management_op(e))
|
||||
.collect();
|
||||
let current: Vec<Op> = lists
|
||||
.lock()
|
||||
.unwrap()
|
||||
.ops
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| Op {
|
||||
user: e.user.clone(),
|
||||
level: Some(e.level),
|
||||
bypass: Some(e.bypasses_player_limit),
|
||||
})
|
||||
.collect();
|
||||
for op in current.iter().filter(|o| !wanted.contains(o)) {
|
||||
rpc(format!("Deop '{}'", shown(&op.user)));
|
||||
lists.lock().unwrap().ops.remove(&op.user);
|
||||
}
|
||||
for op in wanted.into_iter().filter(|o| !current.contains(o)) {
|
||||
self.management_add_op(&rpc, state, op);
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
}
|
||||
"operators/clear" => {
|
||||
rpc("Clear operator list".into());
|
||||
lists.lock().unwrap().ops.clear();
|
||||
self.sync_ops(&lists);
|
||||
}
|
||||
other => return json!({ "error": format!("unknown management method {other:?}") }),
|
||||
}
|
||||
json!({ "ok": true })
|
||||
}
|
||||
|
||||
/// `addUserBan`, then the banned player, if online, is sent away.
|
||||
fn management_add_ban(&mut self, rpc: &impl Fn(String), state: &ServerState, ban: Ban) {
|
||||
let reason = ban.reason.clone().unwrap_or_else(|| {
|
||||
Text::translate("multiplayer.disconnect.banned.reason.default", &[]).plain()
|
||||
});
|
||||
rpc(format!(
|
||||
"Add player '{}' to banlist. Reason: '{reason}'",
|
||||
ban.user.name
|
||||
));
|
||||
let id = ban.user.id;
|
||||
state.lists.lock().unwrap().bans.add(ban);
|
||||
self.kick_uuid(id, translatable("multiplayer.disconnect.banned", &[]));
|
||||
}
|
||||
|
||||
/// `addIpBan`, then whoever is online from that address is sent away.
|
||||
fn management_ip_ban(&mut self, rpc: &impl Fn(String), state: &ServerState, ban: IpBan) {
|
||||
rpc(format!("Add ip '{}' to ban list", ban.user));
|
||||
let ip = ban.user.clone();
|
||||
state.lists.lock().unwrap().ip_bans.add(ban);
|
||||
let banned: Vec<u128> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.address.ip().to_string() == ip)
|
||||
.map(|g| g.uuid)
|
||||
.collect()
|
||||
});
|
||||
for uuid in banned {
|
||||
self.kick_uuid(uuid, translatable("multiplayer.disconnect.ip_banned", &[]));
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerList.op`: the level asked for, else `op-permission-level`; the bypass asked for, else the one
|
||||
/// the player had. The commands an online player may run follow (`sync_ops`, after the edit).
|
||||
fn management_add_op(&mut self, rpc: &impl Fn(String), state: &ServerState, op: Op) {
|
||||
rpc(format!("Op '{}'", shown(&op.user)));
|
||||
let mut lists = state.lists.lock().unwrap();
|
||||
let had = lists
|
||||
.ops
|
||||
.get(&op.user)
|
||||
.is_some_and(|e| e.bypasses_player_limit);
|
||||
let entry = OpEntry {
|
||||
level: op.level.unwrap_or(lists.op_level),
|
||||
bypasses_player_limit: op.bypass.unwrap_or(had),
|
||||
user: op.user,
|
||||
};
|
||||
lists.ops.add(entry);
|
||||
drop(lists);
|
||||
self.sync_ops(&state.lists);
|
||||
}
|
||||
|
||||
/// An `OperatorDto`: the player found, and the level and bypass when given.
|
||||
fn management_op(&self, entry: &Value) -> Option<Op> {
|
||||
Some(Op {
|
||||
user: self.management_user(&entry["player"])?,
|
||||
level: entry["permissionLevel"].as_i64().map(|l| l as i32),
|
||||
bypass: entry["bypassesPlayerLimit"].as_bool(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `PlayerListService.getPlayer(id, name)`: an online player, by id first; their address.
|
||||
fn management_online(&self, player: &Value) -> Option<(u128, String)> {
|
||||
let id = player["id"]
|
||||
.as_str()
|
||||
.and_then(|s| u128::from_str_radix(s, 16).ok());
|
||||
let name = player["name"].as_str();
|
||||
let guest = self
|
||||
.lan
|
||||
.as_ref()?
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| match (id, name) {
|
||||
(Some(id), _) => g.uuid == id,
|
||||
(None, Some(name)) => g.name.eq_ignore_ascii_case(name),
|
||||
(None, None) => false,
|
||||
})?;
|
||||
Some((guest.uuid, guest.address.ip().to_string()))
|
||||
}
|
||||
|
||||
/// `UserBanDto.toUserBan`: the player found, the source "Management server" unless one is given; the ban
|
||||
/// starts now.
|
||||
fn management_ban(&self, entry: &Value) -> Option<Ban> {
|
||||
let user = self.management_user(&entry["player"])?;
|
||||
let source = entry["source"].as_str().unwrap_or("Management server");
|
||||
let reason = entry["reason"].as_str().map(str::to_owned);
|
||||
Some(BanEntry::new(
|
||||
user,
|
||||
Some(source.to_owned()),
|
||||
entry["expires"].as_i64(),
|
||||
reason,
|
||||
))
|
||||
}
|
||||
|
||||
/// `MinecraftPlayerListService.getUser`: by id, the user cache, else the session service; by name, the
|
||||
/// user cache, else the profile service (an offline server's offline player).
|
||||
fn management_user(&self, player: &Value) -> Option<NameAndId> {
|
||||
if let Some(id) = player["id"]
|
||||
.as_str()
|
||||
.and_then(|s| u128::from_str_radix(s, 16).ok())
|
||||
{
|
||||
let cached = super::USER_CACHE
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.and_then(|c| c.get_by_id(id));
|
||||
return cached.or_else(|| profiles::find_by_id(&profiles::discovery_url(), id));
|
||||
}
|
||||
self.resolve_name(player["name"].as_str()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// `IpBan`: the reason, the source "Management server" unless one is given, the end; it starts now.
|
||||
fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
|
||||
let source = entry["source"].as_str().unwrap_or("Management server");
|
||||
let reason = entry["reason"].as_str().map(str::to_owned);
|
||||
BanEntry::new(
|
||||
ip,
|
||||
Some(source.to_owned()),
|
||||
entry["expires"].as_i64(),
|
||||
reason,
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! What `horizon-server` asks the dedicated server on its control endpoint (`--control-socket`, docs/server.md
|
||||
//! section 2): `server-status` answers with the tick rate, the tick times `/tick query` reports, the players
|
||||
//! and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
|
||||
//! `server-events <since>` answers the typed events after that number (events.rs).
|
||||
//! `server-events <since>` answers the typed events after that number (events.rs); `management <connection>
|
||||
//! <json>` edits the lists as vanilla's management protocol does (management.rs).
|
||||
use super::{ServerState, capture_log};
|
||||
use crate::game::Game;
|
||||
use serde_json::{Value, json};
|
||||
@@ -45,6 +46,7 @@ impl Game<'_> {
|
||||
Some(("server-events", since)) => {
|
||||
super::events::since(since.trim().parse().unwrap_or(0))
|
||||
}
|
||||
Some(("management", rest)) => self.management(state, rest),
|
||||
Some(("console", line)) => {
|
||||
// Command feedback is logged as system chat; the reply carries the text alone.
|
||||
let output: Vec<String> = capture_log(|| self.console_command(state, line))
|
||||
|
||||
@@ -45,23 +45,41 @@ pub(super) fn aim_deflect(
|
||||
p.acceleration = 0.1;
|
||||
}
|
||||
|
||||
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge
|
||||
/// 0.3125, from its feet up) grown by the pick radius.
|
||||
/// `Entity.getPickRadius`: only `AbstractHurtingProjectile` has one (1); a shulker
|
||||
/// bullet's is 0.
|
||||
fn pick_radius(p: &Projectile) -> f64 {
|
||||
if matches!(p.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
0.0
|
||||
} else {
|
||||
PICK_RADIUS
|
||||
}
|
||||
}
|
||||
|
||||
/// A projectile a player's blow reaches: a redirectable one, or a shulker bullet
|
||||
/// (`isPickable` and `isAttackable`), which `ShulkerBullet.hurtServer` pops.
|
||||
pub(super) fn attackable(p: &Projectile) -> bool {
|
||||
redirectable(p) || (!p.stuck && matches!(p.kind, ProjectileKind::ShulkerBullet { .. }))
|
||||
}
|
||||
|
||||
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge or a
|
||||
/// shulker bullet 0.3125, from its feet up) grown by the pick radius, which only
|
||||
/// `AbstractHurtingProjectile` has (`getPickRadius`: 1; a shulker bullet's is 0).
|
||||
fn pick_box(p: &Projectile) -> Aabb {
|
||||
let size = match p.kind {
|
||||
ProjectileKind::LargeFireball { .. } => 1.0,
|
||||
_ => 0.3125,
|
||||
};
|
||||
let half = size / 2.0 + PICK_RADIUS;
|
||||
let radius = pick_radius(p);
|
||||
let half = size / 2.0 + radius;
|
||||
Aabb::new(
|
||||
Vec3::new(
|
||||
p.position.x - half,
|
||||
p.position.y - PICK_RADIUS,
|
||||
p.position.y - radius,
|
||||
p.position.z - half,
|
||||
),
|
||||
Vec3::new(
|
||||
p.position.x + half,
|
||||
p.position.y + size + PICK_RADIUS,
|
||||
p.position.y + size + radius,
|
||||
p.position.z + half,
|
||||
),
|
||||
)
|
||||
@@ -77,7 +95,7 @@ impl Game<'_> {
|
||||
.projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| redirectable(p))
|
||||
.filter(|(_, p)| attackable(p))
|
||||
.filter_map(|(i, p)| ray_box(eye, dir, reach, pick_box(p)).map(|t| (i, t)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
@@ -103,10 +121,37 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.deflect(i);
|
||||
if matches!(
|
||||
self.creatures.projectiles[i].kind,
|
||||
ProjectileKind::ShulkerBullet { .. }
|
||||
) {
|
||||
self.hit_shulker_bullet(i);
|
||||
} else {
|
||||
self.deflect(i);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `ShulkerBullet.hurtServer` from a player's blow: the hurt sound, fifteen crit
|
||||
/// particles (`sendParticles`, spread 0.2) and `destroy`. The blow is
|
||||
/// `Player.attack` on a target that is not a mob: its sounds, then the swing.
|
||||
pub(super) fn hit_shulker_bullet(&mut self, i: usize) {
|
||||
let p = self.creatures.projectiles.remove(i);
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.audio
|
||||
.play("minecraft:entity.shulker_bullet.hurt", Some(at), 1.0, 1.0);
|
||||
if let Some(ty) = horizon_client::particles::ParticleType::from_name("crit") {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
self.particles
|
||||
.send_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15);
|
||||
self.lan_level_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15, None);
|
||||
}
|
||||
let (_, _, _, magic) = self.melee_damage("minecraft:shulker_bullet", false);
|
||||
self.vehicle_attack_sounds(p.aabb(), magic.then_some(p.id as i32));
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
}
|
||||
|
||||
/// `Player.canInteractWithEntity(box, 3.0)` as the server checks an
|
||||
/// attack packet: the projectile's box within the reach plus 3 of the eyes.
|
||||
pub(super) fn within_interaction_range(&self, i: usize) -> bool {
|
||||
@@ -115,9 +160,10 @@ impl Game<'_> {
|
||||
};
|
||||
let b = pick_box(p);
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let r = pick_radius(&self.creatures.projectiles[i]);
|
||||
let (lo, hi) = (
|
||||
[lo.x + PICK_RADIUS, lo.y + PICK_RADIUS, lo.z + PICK_RADIUS],
|
||||
[hi.x - PICK_RADIUS, hi.y - PICK_RADIUS, hi.z - PICK_RADIUS],
|
||||
[lo.x + r, lo.y + r, lo.z + r],
|
||||
[hi.x - r, hi.y - r, hi.z - r],
|
||||
);
|
||||
let d2: f64 = (0..3)
|
||||
.map(|a| (eye[a] - eye[a].clamp(lo[a], hi[a])).powi(2))
|
||||
@@ -192,6 +238,22 @@ mod tests {
|
||||
let small = Projectile::fireball(ProjectileKind::SmallFireball, at, None, [1.0, 0.0, 0.0]);
|
||||
assert!(redirectable(&large));
|
||||
assert!(!redirectable(&small));
|
||||
// A shulker bullet takes a blow, with no pick radius round its box.
|
||||
let bullet = Projectile::new(
|
||||
ProjectileKind::ShulkerBullet {
|
||||
target: Who::Player,
|
||||
dir: None,
|
||||
steps: 0,
|
||||
delta: [0.0; 3],
|
||||
seed: 0,
|
||||
},
|
||||
at,
|
||||
None,
|
||||
);
|
||||
assert!(attackable(&bullet) && !redirectable(&bullet));
|
||||
assert!(!attackable(&small));
|
||||
let b = pick_box(&bullet);
|
||||
assert_eq!(b.max_position().y, 64.3125);
|
||||
// The pick box: a block round the fireball's 1 x 1 box.
|
||||
let b = pick_box(&large);
|
||||
assert_eq!(b.min_position().x, -1.5);
|
||||
|
||||
@@ -15,6 +15,7 @@ use horizon_client::{
|
||||
multiline_edit::MultiLineEdit,
|
||||
text::{ClickEvent, Text},
|
||||
ui,
|
||||
widgets::{self, ScreenInfo},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::time::Instant;
|
||||
@@ -111,6 +112,18 @@ impl Game<'_> {
|
||||
let d = &self.dialogs;
|
||||
d.open.is_some() || d.waiting.is_some() || d.warning
|
||||
}
|
||||
/// Whether the dialog's focus is one of its text inputs, which takes the
|
||||
/// keys.
|
||||
pub(super) fn dialog_typing(&self) -> bool {
|
||||
let d = &self.dialogs;
|
||||
!d.warning
|
||||
&& d.waiting.is_none()
|
||||
&& d.open.as_ref().is_some_and(|o| {
|
||||
o.focus
|
||||
.and_then(|f| o.fields.get(f))
|
||||
.is_some_and(Option::is_some)
|
||||
})
|
||||
}
|
||||
|
||||
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
|
||||
/// under the warning screen, it is what Back returns to.
|
||||
@@ -144,6 +157,11 @@ impl Game<'_> {
|
||||
});
|
||||
dialog.set_list(&entries.unwrap_or_default());
|
||||
}
|
||||
self.open_dialog(dialog);
|
||||
}
|
||||
|
||||
/// The parsed dialog's screen, its inputs at their initial values.
|
||||
fn open_dialog(&mut self, dialog: Dialog) {
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
let font = &self.gui.atlas.font;
|
||||
let fields = dialog
|
||||
@@ -200,6 +218,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.keys.clear();
|
||||
self.physical_keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
@@ -368,6 +387,14 @@ impl Game<'_> {
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
if self.dialogs.warning {
|
||||
let (title, message, [disconnect, back]) = warning_texts();
|
||||
// DialogScreen.WarningScreen, a ConfirmScreen: its title and its
|
||||
// message.
|
||||
widgets::begin_screen(|| {
|
||||
ScreenInfo::new(
|
||||
"dialog/warning",
|
||||
widgets::join_for_narration(&[&title.plain(), &message.plain()]),
|
||||
)
|
||||
});
|
||||
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -381,6 +408,12 @@ impl Game<'_> {
|
||||
let ticks = (since.elapsed().as_millis() / 50) as u32;
|
||||
let (label, visible, active) = waiting_button(ticks);
|
||||
let title = Text::translate("gui.waitingForResponse.title", &[]);
|
||||
widgets::begin_screen(|| ScreenInfo::new("dialog/waiting", title.plain()));
|
||||
// WaitingForResponseScreen.tick: the close button turning active
|
||||
// after five seconds is narrated at once.
|
||||
if active && ticks < 102 {
|
||||
widgets::narrate_now();
|
||||
}
|
||||
let top = ((h - 53.0) / 2.0).trunc();
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
@@ -408,6 +441,11 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let [ax, ay, aw, ah] = layout.area;
|
||||
open.scroll.clamp(ay, ay + ah, layout.content);
|
||||
// DialogScreen: titled by its dialog; a new dialog is a new screen.
|
||||
widgets::begin_screen(|| {
|
||||
let title = open.dialog.title.plain();
|
||||
ScreenInfo::new(format!("dialog:{title}"), title)
|
||||
});
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&open.dialog.title,
|
||||
@@ -419,6 +457,19 @@ impl Game<'_> {
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
let warn_hover = ui::hovered(cursor, wx, wy, ww, wh);
|
||||
horizon_client::widgets::record("warning", layout.warning, true);
|
||||
// The warning ImageButton: its narration message and tooltip, first
|
||||
// in tab order (group -10).
|
||||
widgets::narratable(|| {
|
||||
let t = |key: &str| Text::translate(key, &[]).plain();
|
||||
widgets::Narratable::button(
|
||||
t("menu.custom_screen_info.button_narration"),
|
||||
layout.warning,
|
||||
true,
|
||||
)
|
||||
.hovered(warn_hover)
|
||||
.hint(t("menu.custom_screen_info.tooltip"))
|
||||
.order(-10)
|
||||
});
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
WARNING_SPRITES[if warn_hover { 2 } else { 0 }],
|
||||
@@ -445,16 +496,29 @@ impl Game<'_> {
|
||||
}
|
||||
Part::Field { input, rect } => {
|
||||
let focused = open.focus == Some(input);
|
||||
// The edit box is named after its input's label.
|
||||
let label = match &open.dialog.inputs[input].control {
|
||||
Control::Text { label, .. } if widgets::narrating() => label.plain(),
|
||||
_ => String::new(),
|
||||
};
|
||||
match open.fields.get_mut(input) {
|
||||
Some(Some(Field::Line(edit))) => {
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.focused = focused;
|
||||
if widgets::narrating() {
|
||||
edit.message = label;
|
||||
}
|
||||
edit.draw(batch, atlas, None);
|
||||
}
|
||||
Some(Some(Field::Multi(edit))) => {
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.set_focused(focused);
|
||||
edit.draw(batch, atlas);
|
||||
// MultiLineEditBox.updateWidgetNarration: as an EditBox.
|
||||
widgets::narratable(|| {
|
||||
widgets::Narratable::edit_box(label, edit.value(), rect)
|
||||
.focused(focused)
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -473,6 +537,9 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
horizon_client::widgets::record(&label.plain(), rect, true);
|
||||
widgets::narratable(|| {
|
||||
widgets::Narratable::checkbox(label.plain(), on, rect, true).hovered(hover)
|
||||
});
|
||||
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
@@ -494,6 +561,25 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
ui::button(batch, atlas, x, y, cw, ch, &label, true, hover);
|
||||
// A CycleButton: the option a click moves to.
|
||||
widgets::last_narratable(|n| {
|
||||
let control = &open.dialog.inputs[input].control;
|
||||
let next = match (control, open.values.get(input)) {
|
||||
(
|
||||
Control::SingleOption { options, .. },
|
||||
Some(InputValue::Option(i)),
|
||||
) if !options.is_empty() => {
|
||||
let (id, display, _) = &options[(i + 1) % options.len()];
|
||||
display.clone().unwrap_or_else(|| Text::literal(id)).plain()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
n.what = widgets::What::Cycle {
|
||||
message: label.plain(),
|
||||
next,
|
||||
active: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
Part::Slider { input, rect } => {
|
||||
let [x, y, sw, sh] = rect;
|
||||
@@ -548,6 +634,9 @@ impl Game<'_> {
|
||||
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
|
||||
let b = buttons[index];
|
||||
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
|
||||
if let Some(t) = &b.tooltip {
|
||||
widgets::last_narratable(|n| n.hint = Some(t.plain()));
|
||||
}
|
||||
if hover && let Some(t) = &b.tooltip {
|
||||
tooltip = Some(t.clone());
|
||||
}
|
||||
@@ -698,6 +787,7 @@ impl Game<'_> {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
open.focus = Some(input);
|
||||
open.dragging = Some(input);
|
||||
if let Some(v) = open.values.get_mut(input) {
|
||||
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
|
||||
@@ -770,16 +860,17 @@ impl Game<'_> {
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
|
||||
let hovered = layout
|
||||
.placed(open.scroll.amount)
|
||||
.find_map(|(part, _)| match part {
|
||||
Part::Cycle {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => Some(input),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(input) = hovered {
|
||||
let hovered_cycle =
|
||||
layout
|
||||
.placed(open.scroll.amount)
|
||||
.find_map(|(part, _)| match part {
|
||||
Part::Cycle {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => Some(input),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(input) = hovered_cycle {
|
||||
// CycleButton.mouseScrolled: up goes back.
|
||||
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
|
||||
return;
|
||||
@@ -914,6 +1005,32 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(focus) = open.focus
|
||||
&& let Some(input) = open.dialog.inputs.get(focus)
|
||||
&& let Control::NumberRange {
|
||||
start,
|
||||
end,
|
||||
step,
|
||||
width,
|
||||
..
|
||||
} = &input.control
|
||||
&& let Some(InputValue::Slider(val)) = open.values.get_mut(focus)
|
||||
&& matches!(key, KeyCode::ArrowLeft | KeyCode::ArrowRight)
|
||||
{
|
||||
let delta = if let Some(step) = step {
|
||||
let range = (end - start).abs();
|
||||
if range > 0.0 { *step / range } else { 0.01 }
|
||||
} else {
|
||||
1.0 / (*width as f32 - 8.0).max(1.0)
|
||||
};
|
||||
let step_sign = if key == KeyCode::ArrowRight {
|
||||
delta
|
||||
} else {
|
||||
-delta
|
||||
};
|
||||
*val = (*val + step_sign).clamp(0.0, 1.0);
|
||||
return Ok(());
|
||||
}
|
||||
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! exit portal, the egg and the next gateway; and its rendering and boss bar.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
health::HurtSource,
|
||||
hud::HudBatch,
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
@@ -468,7 +469,7 @@ impl Dragon {
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: bool,
|
||||
counts: Option<HurtSource<'_>>,
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
) -> Hurt {
|
||||
@@ -484,11 +485,12 @@ impl Dragon {
|
||||
self.hurt(part, damage, counts, arrow)
|
||||
}
|
||||
/// `EnderDragon.hurt(part, source, damage)`: whether the hit landed
|
||||
/// (an arrow is spent, else it turns back). `counts` is
|
||||
/// `source.getEntity() instanceof Player` or a damage type that always
|
||||
/// hurts dragons (explosions); `arrow` a direct arrow or wind charge,
|
||||
/// which a perched dragon shrugs off (`AbstractDragonSittingPhase.onHurt`).
|
||||
fn hurt(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt {
|
||||
/// (an arrow is spent, else it turns back). `counts` is the source
|
||||
/// `reallyHurt` takes when `source.getEntity() instanceof Player` or its
|
||||
/// type always hurts dragons (explosions), and none otherwise; `arrow` a
|
||||
/// direct arrow or wind charge, which a perched dragon shrugs off
|
||||
/// (`AbstractDragonSittingPhase.onHurt`).
|
||||
fn hurt(&mut self, part: usize, damage: f32, counts: Option<HurtSource>, arrow: bool) -> Hurt {
|
||||
if matches!(self.phase, Phase::Dying { .. }) || !self.alive() {
|
||||
return Hurt::Refused;
|
||||
}
|
||||
@@ -502,10 +504,22 @@ impl Dragon {
|
||||
if damage < 0.01 {
|
||||
return Hurt::Refused;
|
||||
}
|
||||
if !counts {
|
||||
let Some(source) = counts else {
|
||||
return Hurt::Landed { full: false };
|
||||
};
|
||||
let before = self.health;
|
||||
// reallyHurt: LivingEntity.hurtServer, past its guards: a native mod's
|
||||
// hook. A canceled hit still returns true.
|
||||
let hurting = horizon_client::health::Hurting::Dragon(self.id as i32, source);
|
||||
if horizon_client::health::hurt_server_canceled(hurting, &mut damage) {
|
||||
return Hurt::Landed { full: false };
|
||||
}
|
||||
let before = self.health;
|
||||
if damage < 0.0 {
|
||||
damage = 0.0;
|
||||
}
|
||||
if !damage.is_finite() {
|
||||
damage = f32::MAX;
|
||||
}
|
||||
// LivingEntity.hurtServer: within the cooldown only what exceeds the last hit.
|
||||
let full = if self.invulnerable > 10 {
|
||||
if damage <= self.last_hurt {
|
||||
@@ -603,6 +617,23 @@ impl Dragon {
|
||||
self.phase = phase;
|
||||
}
|
||||
}
|
||||
/// The source `reallyHurt` takes from a hit `by` the player of that entity
|
||||
/// id: `DamageSources.explosion(crystal, player)` is `player_explosion` with
|
||||
/// a player behind it, who is the causing entity, and the direct one of a
|
||||
/// melee hit (a projectile's direct entity isn't known here).
|
||||
fn hurt_source(
|
||||
kind: DamageKind,
|
||||
by: Option<i32>,
|
||||
direct: Option<&ProjectileKind>,
|
||||
) -> HurtSource<'static> {
|
||||
let causing = by.unwrap_or(0);
|
||||
let melee = direct.is_none() && super::creatures::melee(kind);
|
||||
HurtSource {
|
||||
damage_type: super::creatures::damage_type(kind, by.is_some()),
|
||||
causing,
|
||||
direct: if melee { causing } else { 0 },
|
||||
}
|
||||
}
|
||||
fn wrap(d: f32) -> f32 {
|
||||
let r = d.rem_euclid(360.0);
|
||||
if r >= 180.0 { r - 360.0 } else { r }
|
||||
@@ -749,6 +780,21 @@ impl Game<'_> {
|
||||
fn arena_loaded(&self) -> bool {
|
||||
(-2..=1).all(|cz| (-2..=1).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
}
|
||||
/// A player's entity id: the acting player's body has it, another
|
||||
/// player's comes from the LAN host (0 when it is out on a guest's turn,
|
||||
/// and for an in-frame host with none).
|
||||
fn fight_entity(&self, who: FightWho) -> i32 {
|
||||
if who == self.acting_player() {
|
||||
return self.player.as_ref().map_or(0, |p| p.entity_id);
|
||||
}
|
||||
let host = self.lan.as_ref();
|
||||
match who {
|
||||
FightWho::Host => host.map_or(0, |h| h.host_entity),
|
||||
FightWho::Guest(conn) => host
|
||||
.and_then(|h| h.hub.guest(conn))
|
||||
.map_or(0, |g| g.entity_id),
|
||||
}
|
||||
}
|
||||
/// The player acting now: the guest whose turn it is, else the host.
|
||||
fn acting_player(&self) -> FightWho {
|
||||
self.lan_acting.map_or(FightWho::Host, FightWho::Guest)
|
||||
@@ -962,7 +1008,14 @@ impl Game<'_> {
|
||||
self.fight.blast_by = by;
|
||||
// EndCrystal.hurtServer: ExplosionInteraction.BLOCK.
|
||||
let interaction = self.destroy_type("block_explosion_drop_decay");
|
||||
// EndCrystal is 2 by 2 blocks: its box decides `isInWater`.
|
||||
let (x, y, z) = (c.position.x, c.position.y, c.position.z);
|
||||
self.combat.explosion_in_water = self.bounds_in_water(Aabb::new(
|
||||
Vec3::new(x - 1.0, y, z - 1.0),
|
||||
Vec3::new(x + 1.0, y + 2.0, z + 1.0),
|
||||
));
|
||||
let _ = self.server_explode(at, 6.0, interaction, false);
|
||||
self.combat.explosion_in_water = false;
|
||||
self.fight.blast_by = None;
|
||||
}
|
||||
if respawn_crystal {
|
||||
@@ -1090,12 +1143,14 @@ impl Game<'_> {
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
) -> Hurt {
|
||||
let by_id = by.map(|who| self.fight_entity(who));
|
||||
let source = counts.then(|| hurt_source(kind, by_id, direct));
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return Hurt::Refused;
|
||||
};
|
||||
let was_down = d.down();
|
||||
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
|
||||
let hurt = d.hurt_part(part, damage, counts, kind, direct);
|
||||
let hurt = d.hurt_part(part, damage, source, kind, direct);
|
||||
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
|
||||
// (100 ticks); a hit the damage cooldown absorbs returns before that.
|
||||
if counts
|
||||
@@ -3884,6 +3939,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The damage type of the hits below, which count.
|
||||
const HIT: Option<HurtSource> = Some(HurtSource {
|
||||
damage_type: "minecraft:player_attack",
|
||||
causing: 0,
|
||||
direct: 0,
|
||||
});
|
||||
fn dragon(phase: Phase) -> Dragon {
|
||||
let mut d = Dragon::new(Vec3::new(0.0, 70.0, 0.0), 0.0, 1);
|
||||
d.phase = phase;
|
||||
@@ -3901,13 +3962,13 @@ mod tests {
|
||||
fn a_perched_dragon_shrugs_arrows_off() {
|
||||
// AbstractDragonSittingPhase.onHurt: an arrow does nothing, melee does.
|
||||
let mut d = dragon(Phase::Scanning { time: 0 });
|
||||
assert_eq!(d.hurt(0, 9.0, true, true), Hurt::Shrugged);
|
||||
assert_eq!(d.hurt(0, 9.0, HIT, true), Hurt::Shrugged);
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
assert_eq!(d.hurt(0, 9.0, true, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 9.0);
|
||||
// In flight an arrow lands.
|
||||
let mut d = dragon(flying());
|
||||
assert_eq!(d.hurt(0, 9.0, true, true), Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt(0, 9.0, HIT, true), Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 9.0);
|
||||
}
|
||||
|
||||
@@ -3918,13 +3979,13 @@ mod tests {
|
||||
let large = ProjectileKind::LargeFireball { power: 1 };
|
||||
for part in [0, 2] {
|
||||
let mut d = dragon(flying());
|
||||
let hurt = d.hurt_part(part, 6.0, true, DamageKind::Fireball, Some(&large));
|
||||
let hurt = d.hurt_part(part, 6.0, HIT, DamageKind::Fireball, Some(&large));
|
||||
assert_eq!(hurt, Hurt::Refused);
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
}
|
||||
let small = ProjectileKind::SmallFireball;
|
||||
let mut d = dragon(flying());
|
||||
let hurt = d.hurt_part(0, 5.0, true, DamageKind::Fireball, Some(&small));
|
||||
let hurt = d.hurt_part(0, 5.0, HIT, DamageKind::Fireball, Some(&small));
|
||||
assert_eq!(hurt, Hurt::Refused);
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
}
|
||||
@@ -3935,16 +3996,16 @@ mod tests {
|
||||
// nothing, a breeze's BreezeWindCharge is not one.
|
||||
let charge = ProjectileKind::WindCharge;
|
||||
let mut d = dragon(Phase::Scanning { time: 0 });
|
||||
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&charge));
|
||||
let hurt = d.hurt_part(0, 1.0, HIT, DamageKind::WindCharge, Some(&charge));
|
||||
assert_eq!(hurt, Hurt::Shrugged);
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
let breeze = ProjectileKind::BreezeWindCharge;
|
||||
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&breeze));
|
||||
let hurt = d.hurt_part(0, 1.0, HIT, DamageKind::WindCharge, Some(&breeze));
|
||||
assert_eq!(hurt, Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 1.0);
|
||||
// In flight the player's charge lands too.
|
||||
let mut d = dragon(flying());
|
||||
let hurt = d.hurt_part(0, 1.0, true, DamageKind::WindCharge, Some(&charge));
|
||||
let hurt = d.hurt_part(0, 1.0, HIT, DamageKind::WindCharge, Some(&charge));
|
||||
assert_eq!(hurt, Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 1.0);
|
||||
}
|
||||
@@ -3957,17 +4018,17 @@ mod tests {
|
||||
let wither = super::super::wither::skull_source(Some(Who::Mob(7)), [0.0; 3]);
|
||||
assert_eq!((wither.amount, wither.kind), (8.0, DamageKind::WitherSkull));
|
||||
let mut d = dragon(Phase::Scanning { time: 0 });
|
||||
let hurt = d.hurt_part(2, wither.amount, false, wither.kind, Some(&skull));
|
||||
let hurt = d.hurt_part(2, wither.amount, None, wither.kind, Some(&skull));
|
||||
assert_eq!(hurt, Hurt::Landed { full: false });
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
// A player's skull: 8 on the head, 8 / 4 + 1 on the body.
|
||||
let player = super::super::wither::skull_source(Some(Who::Player), [0.0; 3]);
|
||||
let mut d = dragon(Phase::Scanning { time: 0 });
|
||||
let hurt = d.hurt_part(0, player.amount, true, player.kind, Some(&skull));
|
||||
let hurt = d.hurt_part(0, player.amount, HIT, player.kind, Some(&skull));
|
||||
assert_eq!(hurt, Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 8.0);
|
||||
let mut d = dragon(flying());
|
||||
d.hurt_part(2, player.amount, true, player.kind, Some(&skull));
|
||||
d.hurt_part(2, player.amount, HIT, player.kind, Some(&skull));
|
||||
assert_eq!(d.health, MAX_HEALTH - 3.0);
|
||||
}
|
||||
|
||||
@@ -3975,14 +4036,14 @@ mod tests {
|
||||
fn the_cooldown_lets_only_the_excess_through() {
|
||||
// LivingEntity.hurtServer: damageCooldownTime > 10 keeps lastHurt.
|
||||
let mut d = dragon(flying());
|
||||
assert_eq!(d.hurt(0, 6.0, true, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt(0, 4.0, true, false), Hurt::Landed { full: false });
|
||||
assert_eq!(d.hurt(0, 6.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt(0, 4.0, HIT, false), Hurt::Landed { full: false });
|
||||
assert_eq!(d.health, MAX_HEALTH - 6.0);
|
||||
assert_eq!(d.hurt(0, 10.0, true, false), Hurt::Landed { full: false });
|
||||
assert_eq!(d.hurt(0, 10.0, HIT, false), Hurt::Landed { full: false });
|
||||
assert_eq!(d.health, MAX_HEALTH - 10.0);
|
||||
// Ten ticks on, the window is over: a full hit again.
|
||||
d.invulnerable = 10;
|
||||
assert_eq!(d.hurt(0, 3.0, true, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt(0, 3.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 13.0);
|
||||
}
|
||||
|
||||
@@ -3990,28 +4051,131 @@ mod tests {
|
||||
fn parts_and_sources() {
|
||||
// Neither head nor neck: a quarter plus one.
|
||||
let mut d = dragon(flying());
|
||||
assert_eq!(d.hurt(2, 20.0, true, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt(2, 20.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH - 6.0);
|
||||
// Not a player's and not an explosion: true, but no damage.
|
||||
let mut d = dragon(flying());
|
||||
assert_eq!(d.hurt(0, 20.0, false, false), Hurt::Landed { full: false });
|
||||
assert_eq!(d.hurt(0, 20.0, None, false), Hurt::Landed { full: false });
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
// Too little after the part's cut, and nothing while dying.
|
||||
assert_eq!(d.hurt(3, 0.001, true, false), Hurt::Refused);
|
||||
assert_eq!(d.hurt(3, 0.001, HIT, false), Hurt::Refused);
|
||||
d.phase = Phase::Dying {
|
||||
target: None,
|
||||
time: 0,
|
||||
};
|
||||
assert_eq!(d.hurt(0, 20.0, true, false), Hurt::Refused);
|
||||
assert_eq!(d.hurt(0, 20.0, HIT, false), Hurt::Refused);
|
||||
}
|
||||
|
||||
/// The dragon hits the hook of these tests saw: entity, damage type,
|
||||
/// causing and direct entities, amount.
|
||||
type Seen = (String, i32, i32, f32);
|
||||
static SEEN: std::sync::Mutex<Vec<(i32, Seen)>> = std::sync::Mutex::new(Vec::new());
|
||||
fn seen(entity: i32) -> Vec<Seen> {
|
||||
let seen = SEEN.lock().unwrap();
|
||||
seen.iter()
|
||||
.filter(|s| s.0 == entity)
|
||||
.map(|s| s.1.clone())
|
||||
.collect()
|
||||
}
|
||||
/// Sets the native damage hook of these tests: it records the dragon
|
||||
/// hits of entities 9001 to 9005, cancels 9001's, doubles 9002's, makes
|
||||
/// 9003's -5 and 9004's NaN. `HURT_SERVER` is global, so it stays set for
|
||||
/// every test of the process; the other tests' dragons are entity 0.
|
||||
fn set_hook() {
|
||||
use horizon_client::health::{HURT_SERVER, Hurting};
|
||||
fn hook(hurting: Hurting<'_>, amount: &mut f32) -> bool {
|
||||
let Hurting::Dragon(entity @ 9001..=9005, s) = hurting else {
|
||||
return false;
|
||||
};
|
||||
let hit = (s.damage_type.to_owned(), s.causing, s.direct, *amount);
|
||||
SEEN.lock().unwrap().push((entity, hit));
|
||||
match entity {
|
||||
9002 => *amount *= 2.0,
|
||||
9003 => *amount = -5.0,
|
||||
9004 => *amount = f32::NAN,
|
||||
_ => {}
|
||||
}
|
||||
entity == 9001
|
||||
}
|
||||
let installed = *HURT_SERVER.get_or_init(|| hook);
|
||||
let ours: fn(Hurting<'_>, &mut f32) -> bool = hook;
|
||||
assert!(
|
||||
std::ptr::fn_addr_eq(installed, ours),
|
||||
"another native damage hook is set"
|
||||
);
|
||||
}
|
||||
fn dragon_as(entity: u32) -> Dragon {
|
||||
let mut d = dragon(flying());
|
||||
d.id = entity;
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_damage_hook_changes_or_cancels_a_dragons_hit() {
|
||||
set_hook();
|
||||
// EnderDragon.hurt returns true whatever reallyHurt did. A canceled
|
||||
// hit takes no health, starts no cooldown and isn't full: what
|
||||
// hurt_dragon_part reads to credit the player and play the hurt sound.
|
||||
let mut d = dragon_as(9001);
|
||||
let arrow = HurtSource {
|
||||
damage_type: "minecraft:arrow",
|
||||
causing: 12,
|
||||
direct: 0,
|
||||
};
|
||||
let hurt = d.hurt(0, 9.0, Some(arrow), false);
|
||||
assert_eq!(hurt, Hurt::Landed { full: false });
|
||||
assert_eq!(seen(9001), [("minecraft:arrow".to_owned(), 12, 0, 9.0)]);
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
assert_eq!((d.invulnerable, d.last_hurt, d.hurt_time), (0, 0.0, 0));
|
||||
// The hook sees the amount after the part's cut (20 / 4 + 1).
|
||||
let mut d = dragon_as(9002);
|
||||
assert_eq!(d.hurt(2, 20.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(
|
||||
seen(9002),
|
||||
[("minecraft:player_attack".to_owned(), 0, 0, 6.0)]
|
||||
);
|
||||
assert_eq!(d.health, MAX_HEALTH - 12.0);
|
||||
// Neither a hit that doesn't count nor one cut below 0.01 reaches
|
||||
// reallyHurt.
|
||||
let mut d = dragon_as(9005);
|
||||
assert_eq!(d.hurt(0, 9.0, None, false), Hurt::Landed { full: false });
|
||||
assert_eq!(d.hurt(3, 0.001, HIT, false), Hurt::Refused);
|
||||
assert!(seen(9005).is_empty());
|
||||
// LivingEntity.hurtServer: a negative amount hurts by 0, a NaN by
|
||||
// Float.MAX_VALUE (the killing blow: the dying flight).
|
||||
let mut d = dragon_as(9003);
|
||||
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, MAX_HEALTH);
|
||||
let mut d = dragon_as(9004);
|
||||
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.health, 1.0);
|
||||
assert!(matches!(d.phase, Phase::Dying { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hit_names_the_player_behind_it() {
|
||||
let source = |kind, by, direct| {
|
||||
let s = super::hurt_source(kind, by, direct);
|
||||
(s.damage_type, s.causing, s.direct)
|
||||
};
|
||||
let melee = source(DamageKind::PlayerAttack, Some(12), None);
|
||||
assert_eq!(melee, ("minecraft:player_attack", 12, 12));
|
||||
let arrow = ProjectileKind::Trident;
|
||||
let shot = source(DamageKind::Arrow, Some(12), Some(&arrow));
|
||||
assert_eq!(shot, ("minecraft:arrow", 12, 0));
|
||||
let blast = source(DamageKind::Explosion, Some(12), None);
|
||||
assert_eq!(blast, ("minecraft:player_explosion", 12, 0));
|
||||
let blast = source(DamageKind::Explosion, None, None);
|
||||
assert_eq!(blast, ("minecraft:explosion", 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_of_its_health_while_perched_makes_it_take_off() {
|
||||
let mut d = dragon(Phase::Flaming { ticks: 0 });
|
||||
d.hurt(0, 30.0, true, false);
|
||||
d.hurt(0, 30.0, HIT, false);
|
||||
assert!(d.phase.sitting());
|
||||
d.invulnerable = 0;
|
||||
d.hurt(0, 21.0, true, false);
|
||||
d.hurt(0, 21.0, HIT, false);
|
||||
assert!(matches!(d.phase, Phase::Takeoff { .. }));
|
||||
assert_eq!(d.sitting_damage, 0.0);
|
||||
}
|
||||
@@ -4035,7 +4199,7 @@ mod tests {
|
||||
assert!(covered.iter().all(|h| h.1 == 1.0));
|
||||
// Through the cooldown the strongest hit after the part's cut wins.
|
||||
for (part, amount) in open {
|
||||
d.hurt(part, amount, true, false);
|
||||
d.hurt(part, amount, HIT, false);
|
||||
}
|
||||
assert_eq!(d.health, MAX_HEALTH - 85.0);
|
||||
}
|
||||
|
||||
@@ -364,7 +364,9 @@ impl Game<'_> {
|
||||
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
|
||||
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
|
||||
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
|
||||
let (new, entity) = (!m.has_effect(id), m.id as i32);
|
||||
m.add_effect(id, ticks, amplifier);
|
||||
self.mod_effect_added(id, Some(entity), amplifier, new);
|
||||
}
|
||||
}
|
||||
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
|
||||
@@ -488,14 +490,7 @@ impl Game<'_> {
|
||||
.is_some_and(|i| s.damage >= i.max_damage)
|
||||
{
|
||||
self.armor[slot] = None;
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.audio.play(
|
||||
"minecraft:entity.item.break",
|
||||
Some(eye),
|
||||
0.8,
|
||||
0.8 + self.world.random.next_float() * 0.4,
|
||||
);
|
||||
}
|
||||
self.player_equipment_broke(super::armor_slot(slot), &stack);
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
@@ -760,16 +755,11 @@ impl Game<'_> {
|
||||
boots.damage += loss;
|
||||
if boots.damage >= max {
|
||||
self.armor[3] = None;
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.audio.play(
|
||||
"minecraft:entity.item.break",
|
||||
Some(eye),
|
||||
0.8,
|
||||
0.8 + self.world.random.next_float() * 0.4,
|
||||
);
|
||||
}
|
||||
// LivingEntity.breakItem: the broken boots' five crumbs.
|
||||
self.eat_particles(&stack, 5);
|
||||
// LivingEntity.onEquippedItemBroken: the broken boots.
|
||||
self.player_equipment_broke(
|
||||
horizon_world::player_inventory::EquipmentSlot::Feet,
|
||||
&stack,
|
||||
);
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::{Dim, Game};
|
||||
use horizon_client::nether_portal::ENTITY_DELAY;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
containers::BlockEntity,
|
||||
environment::Environment,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
@@ -39,8 +39,6 @@ pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
const GATEWAY_RADIUS: f64 = 96.0;
|
||||
const GATEWAY_Y: i32 = 75;
|
||||
/// File (in the End region folder) keeping End gateway exits.
|
||||
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -74,17 +72,21 @@ enum Arriving {
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
gateway: [i32; 3],
|
||||
entity: Option<&BlockEntity>,
|
||||
legacy: Option<([i32; 3], bool)>,
|
||||
) -> Self {
|
||||
/// The source of a new exit must stay loaded until its block entity is updated.
|
||||
pub(super) fn source_chunk(self) -> Option<(i32, i32)> {
|
||||
match self {
|
||||
Self::Find { gateway, .. } => Some((gateway[0] >> 4, gateway[2] >> 4)),
|
||||
Self::Known { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, using the gateway block entity.
|
||||
fn for_gateway(gateway: [i32; 3], entity: Option<&BlockEntity>) -> Self {
|
||||
let configured = match entity {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
match configured.or(legacy) {
|
||||
match configured {
|
||||
Some((exit, exact)) => Self::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
|
||||
@@ -105,17 +107,6 @@ fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
|
||||
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
|
||||
}
|
||||
|
||||
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
|
||||
/// entity has its own; true when it took the saved one.
|
||||
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
|
||||
if entity.exit_portal.is_some() {
|
||||
return false;
|
||||
}
|
||||
entity.exit_portal = Some(exit);
|
||||
entity.exact_teleport = exact;
|
||||
true
|
||||
}
|
||||
|
||||
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
|
||||
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
|
||||
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
|
||||
@@ -124,8 +115,6 @@ fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EndState {
|
||||
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
|
||||
exits: Option<HashMap<[i32; 3], ([i32; 3], bool)>>,
|
||||
job: Option<GatewayJob>,
|
||||
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
|
||||
gateway_cooldown: u32,
|
||||
@@ -932,74 +921,39 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gateway_exits(&mut self) -> &mut HashMap<[i32; 3], ([i32; 3], bool)> {
|
||||
if self.end.exits.is_none() {
|
||||
let path = horizon_client::save::dimension_dir(&self.world_root, "minecraft:the_end")
|
||||
.join(GATEWAY_FILE);
|
||||
let parsed: Option<Vec<([i32; 3], [i32; 3], bool)>> = std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok());
|
||||
self.end.exits = Some(
|
||||
parsed
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(g, e, x)| (g, (e, x)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
self.end.exits.as_mut().expect("loaded exits")
|
||||
}
|
||||
|
||||
fn save_gateway_exits(&mut self) -> Result<()> {
|
||||
let list: Vec<_> = self
|
||||
.gateway_exits()
|
||||
.iter()
|
||||
.map(|(g, (e, x))| (*g, *e, *x))
|
||||
.collect();
|
||||
let dir = horizon_client::save::dimension_dir(&self.world_root, "minecraft:the_end");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::write(dir.join(GATEWAY_FILE), serde_json::to_vec(&list)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
|
||||
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
if self.gateway_exits().remove(&gateway).is_some() {
|
||||
self.save_gateway_exits()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.setExitPosition`: the block entity alone owns the exit.
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
|
||||
// exit then waits in the side file until the gateway is used again (`gateway_job`).
|
||||
if self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
if !self.world.chunks.contains_key(&chunk) {
|
||||
return Err("gateway source chunk is not loaded".into());
|
||||
}
|
||||
// A gateway removed while an entity waits for the destination stays removed.
|
||||
if &*self.block_name(gateway) != "minecraft:end_gateway" {
|
||||
return Ok(());
|
||||
}
|
||||
if let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with((gateway[0], gateway[1], gateway[2]), || {
|
||||
BlockEntity::EndGateway(Default::default())
|
||||
})
|
||||
{
|
||||
g.exit_portal = Some(exit);
|
||||
g.exact_teleport = exact;
|
||||
self.world.modified.insert(chunk);
|
||||
return self.forget_gateway_exit(gateway);
|
||||
}
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
self.save_gateway_exits()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The exit a gateway already leads to: its block entity, or the side file
|
||||
/// that kept it for a gateway whose chunk is not loaded.
|
||||
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let saved = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
fn known_gateway_exit(&self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
|
||||
match self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&(gateway[0], gateway[1], gateway[2]))
|
||||
{
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|e| (e, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
|
||||
}
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
@@ -1021,32 +975,18 @@ impl Game<'_> {
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
|
||||
let mut known = self.gateway_exits().get(&gateway).copied();
|
||||
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
|
||||
// side file kept for it moves there, and the entry is dropped, so removing or replacing
|
||||
// the exit (`/data remove block`) is not undone by an old entry.
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
if let Some(saved) = known
|
||||
&& self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
if adopt_saved_exit(g, saved) {
|
||||
self.world.modified.insert(chunk);
|
||||
}
|
||||
known = None;
|
||||
let _ = self.forget_gateway_exit(gateway);
|
||||
}
|
||||
let job = GatewayJob::for_gateway(
|
||||
gateway,
|
||||
self.world
|
||||
.block_entities
|
||||
.get(&(gateway[0], gateway[1], gateway[2])),
|
||||
known,
|
||||
);
|
||||
if let Some(c) = job.source_chunk()
|
||||
&& !self.end.areas.contains(&c)
|
||||
{
|
||||
self.end.areas.push(c);
|
||||
self.streaming.extra.push((c, 1));
|
||||
}
|
||||
let destination = match job {
|
||||
GatewayJob::Known { exit, .. } => Vec3::new(
|
||||
f64::from(exit[0]) + 0.5,
|
||||
@@ -1108,6 +1048,13 @@ impl Game<'_> {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
if !self
|
||||
.world
|
||||
.chunks
|
||||
.contains_key(&(gateway[0] >> 4, gateway[2] >> 4))
|
||||
{
|
||||
return Err("gateway source chunk is not loaded".into());
|
||||
}
|
||||
// The player and the entities may resolve the same gateway: the
|
||||
// first one to arrive finds the exit, the others use it.
|
||||
let known = self.known_gateway_exit(gateway);
|
||||
@@ -1670,7 +1617,12 @@ impl Game<'_> {
|
||||
/// on from there. The traveller comes back while it still waits.
|
||||
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
|
||||
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
if !self.world.chunks.contains_key(&chunk(arrival.target)) {
|
||||
if !self.world.chunks.contains_key(&chunk(arrival.target))
|
||||
|| arrival
|
||||
.job
|
||||
.source_chunk()
|
||||
.is_some_and(|c| !self.world.chunks.contains_key(&c))
|
||||
{
|
||||
return Some(arrival);
|
||||
}
|
||||
let at = match self.resolve_gateway(arrival.job, arrival.target) {
|
||||
@@ -1738,7 +1690,16 @@ impl Game<'_> {
|
||||
)
|
||||
});
|
||||
let tickets = self.end.tickets.iter().filter(|_| in_end);
|
||||
for c in arrivals.chain(tickets.map(|&(c, _)| c)) {
|
||||
let sources = self
|
||||
.end
|
||||
.arrivals
|
||||
.iter()
|
||||
.filter_map(|a| a.job.source_chunk());
|
||||
for c in arrivals
|
||||
.chain(tickets.map(|&(c, _)| c))
|
||||
.chain(sources)
|
||||
.chain(self.gateway_source_chunks())
|
||||
{
|
||||
if !areas.contains(&c) {
|
||||
areas.push(c);
|
||||
self.streaming.extra.push((c, 1));
|
||||
@@ -1787,23 +1748,6 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_client::containers::EndGateway;
|
||||
|
||||
#[test]
|
||||
fn a_saved_exit_moves_into_a_gateway_without_one() {
|
||||
let saved = ([8, 100, 0], true);
|
||||
let mut bare = EndGateway::default();
|
||||
assert!(adopt_saved_exit(&mut bare, saved));
|
||||
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
|
||||
assert!(bare.exact_teleport);
|
||||
// The block entity's own exit wins over a stale entry.
|
||||
let mut own = EndGateway {
|
||||
exit_portal: Some([1, 2, 3]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!adopt_saved_exit(&mut own, saved));
|
||||
assert_eq!(own.exit_portal, Some([1, 2, 3]));
|
||||
assert!(!own.exact_teleport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_exit_keeps_the_gateways_exact_teleport() {
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
@@ -1825,7 +1769,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
|
||||
fn end_gateway_job_reads_the_block_entity_and_keeps_new_sources_loaded() {
|
||||
let gateway = [702, 100, 0];
|
||||
let exit = [8, 100, 0];
|
||||
for exact in [true, false] {
|
||||
@@ -1835,18 +1779,14 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
|
||||
GatewayJob::for_gateway(gateway, Some(&entity)),
|
||||
GatewayJob::Known { exit, exact },
|
||||
);
|
||||
}
|
||||
let empty = BlockEntity::EndGateway(EndGateway::default());
|
||||
for entity in [None, Some(&empty)] {
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
|
||||
GatewayJob::Known { exit, exact: false }
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, None),
|
||||
GatewayJob::for_gateway(gateway, entity),
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true
|
||||
@@ -1858,14 +1798,14 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, Some(&exact), None),
|
||||
GatewayJob::for_gateway(nearby, Some(&exact)),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, entity, None),
|
||||
GatewayJob::for_gateway(nearby, entity),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: false
|
||||
|
||||
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user